Skip to content

Commit eb38055

Browse files
authored
fix(codecatalyst): disallow non-vscode Dev Environment #3303
Problem: If customer changed the "IDE runtime" (in the CodeCatalyst console), AWS Toolkit will attempt to reconnect to the Dev Environment, which is inconsistent with other IDEs (jetbrains, cloud9). Solution: Check the `ides` field on connect. Disconnect if the ide runtime is not "vscode".
1 parent 79a1735 commit eb38055

File tree

9 files changed

+135
-39
lines changed

9 files changed

+135
-39
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Bug Fix",
3+
"description": "CodeCatalyst: Toolkit attempts reconnect to non-vscode Dev Environment"
4+
}

src/codecatalyst/activation.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { dontShow } from '../shared/localizedText'
2020
import { isCloud9 } from '../shared/extensionUtilities'
2121
import { Commands } from '../shared/vscode/commands2'
2222
import { getCodeCatalystConfig } from '../shared/clients/codecatalystClient'
23+
import { getThisDevEnv } from './model'
24+
import { isDevenvVscode } from './utils'
25+
import { getLogger } from '../shared/logger/logger'
2326

2427
const localize = nls.loadMessageBundle()
2528

@@ -54,13 +57,22 @@ export async function activate(ctx: ExtContext): Promise<void> {
5457
watchRestartingDevEnvs(ctx, authProvider)
5558
}
5659

57-
const devenvClient = new DevEnvClient()
58-
if (devenvClient.id) {
59-
ctx.extensionContext.subscriptions.push(registerDevfileWatcher(devenvClient))
60+
ctx.extensionContext.subscriptions.push(DevEnvClient.instance)
61+
if (DevEnvClient.instance.id) {
62+
ctx.extensionContext.subscriptions.push(registerDevfileWatcher(DevEnvClient.instance))
6063
}
6164

6265
const settings = PromptSettings.instance
6366
if (getCodeCatalystDevEnvId()) {
67+
const thisDevenv = await getThisDevEnv(authProvider)
68+
getLogger().info('codecatalyst: Dev Environment ides=%O', thisDevenv?.summary.ides)
69+
if (thisDevenv && !isDevenvVscode(thisDevenv.summary.ides)) {
70+
// Prevent Toolkit from reconnecting to a "non-vscode" devenv by actively closing it.
71+
// Can happen if devenv is switched to ides="cloud9", etc.
72+
vscode.commands.executeCommand('workbench.action.remote.close')
73+
return
74+
}
75+
6476
if (await settings.isPromptEnabled('remoteConnected')) {
6577
const message = localize(
6678
'AWS.codecatalyst.connectedMessage',

src/codecatalyst/devfile.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { checkUnsavedChanges } from '../shared/utilities/workspaceUtils'
1616
import { ToolkitError } from '../shared/errors'
1717

1818
async function updateDevfile(uri: vscode.Uri): Promise<void> {
19-
const client = new DevEnvClient()
19+
const client = DevEnvClient.instance
2020
if (!client.isCodeCatalystDevEnv()) {
2121
throw new Error('Cannot update devfile outside a Dev Environment')
2222
}
@@ -60,7 +60,7 @@ export class DevfileCodeLensProvider implements vscode.CodeLensProvider {
6060

6161
public constructor(
6262
registry: DevfileRegistry,
63-
private readonly client = new DevEnvClient(),
63+
private readonly client = DevEnvClient.instance,
6464
workspace: Workspace = vscode.workspace
6565
) {
6666
this.disposables.push(this._onDidChangeCodeLenses)

src/codecatalyst/explorer.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,14 @@
66
import * as vscode from 'vscode'
77
import { RootNode } from '../awsexplorer/localExplorer'
88
import { Connection, createBuilderIdConnection, isBuilderIdConnection } from '../credentials/auth'
9-
import { createClient, DevEnvironment } from '../shared/clients/codecatalystClient'
10-
import { UnknownError } from '../shared/errors'
9+
import { DevEnvironment } from '../shared/clients/codecatalystClient'
1110
import { isCloud9 } from '../shared/extensionUtilities'
1211
import { addColor, getIcon } from '../shared/icons'
13-
import { getLogger } from '../shared/logger/logger'
1412
import { TreeNode } from '../shared/treeview/resourceTreeDataProvider'
1513
import { Commands } from '../shared/vscode/commands2'
1614
import { CodeCatalystAuthenticationProvider } from './auth'
1715
import { CodeCatalystCommands } from './commands'
18-
import { ConnectedDevEnv, getConnectedDevEnv, getDevfileLocation } from './model'
16+
import { ConnectedDevEnv, getDevfileLocation, getThisDevEnv } from './model'
1917
import * as codecatalyst from './model'
2018

2119
const getStartedCommand = Commands.register(
@@ -127,7 +125,7 @@ export class CodeCatalystRootNode implements RootNode {
127125
}
128126

129127
public async getTreeItem() {
130-
this.devenv = await this.getDevEnv()
128+
this.devenv = await getThisDevEnv(this.authProvider)
131129

132130
const item = new vscode.TreeItem('CodeCatalyst', vscode.TreeItemCollapsibleState.Collapsed)
133131
item.contextValue = this.authProvider.isUsingSavedConnection
@@ -143,18 +141,4 @@ export class CodeCatalystRootNode implements RootNode {
143141

144142
return item
145143
}
146-
147-
private async getDevEnv() {
148-
try {
149-
await this.authProvider.restore()
150-
const conn = this.authProvider.activeConnection
151-
if (conn !== undefined && this.authProvider.auth.getConnectionState(conn) === 'valid') {
152-
const client = await createClient(conn)
153-
154-
return await getConnectedDevEnv(client)
155-
}
156-
} catch (err) {
157-
getLogger().warn(`codecatalyst: failed to get Dev Environment: ${UnknownError.cast(err).message}`)
158-
}
159-
}
160144
}

src/codecatalyst/model.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import globals from '../shared/extensionGlobals'
88
import * as vscode from 'vscode'
99
import * as path from 'path'
1010
import {
11+
createClient,
1112
CodeCatalystClient,
1213
DevEnvironment,
1314
CodeCatalystRepo,
@@ -21,11 +22,13 @@ import { writeFile } from 'fs-extra'
2122
import { sshAgentSocketVariable, startSshAgent, startVscodeRemote } from '../shared/extensions/ssh'
2223
import { ChildProcess } from '../shared/utilities/childProcess'
2324
import { ensureDependencies, hostNamePrefix } from './tools'
24-
import { isCodeCatalystVSCode } from './utils'
25+
import { isDevenvVscode } from './utils'
2526
import { Timeout } from '../shared/utilities/timeoutUtils'
2627
import { Commands } from '../shared/vscode/commands2'
2728
import { areEqual } from '../shared/utilities/pathUtils'
2829
import { fileExists } from '../shared/filesystemUtilities'
30+
import { CodeCatalystAuthenticationProvider } from './auth'
31+
import { UnknownError } from '../shared/errors'
2932

3033
export type DevEnvironmentId = Pick<DevEnvironment, 'id' | 'org' | 'project'>
3134

@@ -150,7 +153,7 @@ export interface ConnectedDevEnv {
150153

151154
export async function getConnectedDevEnv(
152155
codeCatalystClient: CodeCatalystClient,
153-
devenvClient = new DevEnvClient()
156+
devenvClient = DevEnvClient.instance
154157
): Promise<ConnectedDevEnv | undefined> {
155158
const devEnvId = devenvClient.id
156159
if (!devEnvId || !devenvClient.isCodeCatalystDevEnv()) {
@@ -173,6 +176,23 @@ export async function getConnectedDevEnv(
173176
return { summary, devenvClient: devenvClient }
174177
}
175178

179+
/**
180+
* Gets the current devenv that Toolkit is running in, if any.
181+
*/
182+
export async function getThisDevEnv(authProvider: CodeCatalystAuthenticationProvider) {
183+
try {
184+
await authProvider.restore()
185+
const conn = authProvider.activeConnection
186+
if (conn !== undefined && authProvider.auth.getConnectionState(conn) === 'valid') {
187+
const client = await createClient(conn)
188+
return await getConnectedDevEnv(client)
189+
}
190+
} catch (err) {
191+
getLogger().warn(`codecatalyst: failed to get Dev Environment: ${UnknownError.cast(err).message}`)
192+
}
193+
return undefined
194+
}
195+
176196
/**
177197
* Everything needed to connect to a dev environment via VS Code or `ssh`
178198
*/
@@ -313,7 +333,7 @@ export function associateDevEnv(
313333
const devenvs = await client
314334
.listResources('devEnvironment')
315335
.flatten()
316-
.filter(env => env.repositories.length > 0 && isCodeCatalystVSCode(env.ides))
336+
.filter(env => env.repositories.length > 0 && isDevenvVscode(env.ides))
317337
.toMap(env => `${env.org.name}.${env.project.name}.${env.repositories[0].repositoryName}`)
318338

319339
yield* repos.map(repo => ({

src/codecatalyst/reconnect.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { showViewLogsMessage } from '../shared/utilities/messages'
1515
import { CodeCatalystAuthenticationProvider } from './auth'
1616
import { getCodeCatalystDevEnvId } from '../shared/vscode/env'
1717
import globals from '../shared/extensionGlobals'
18-
import { recordSource } from './utils'
18+
import { isDevenvVscode, recordSource } from './utils'
1919

2020
const localize = nls.loadMessageBundle()
2121

@@ -28,6 +28,7 @@ export function watchRestartingDevEnvs(ctx: ExtContext, authProvider: CodeCataly
2828
if (restartHandled || conn === undefined || authProvider.auth.getConnectionState(conn) !== 'valid') {
2929
return
3030
}
31+
getLogger().info(`codecatalyst: reconnect: onDidChangeActiveConnection: startUrl=${conn.startUrl}`)
3132

3233
const client = await createClient(conn)
3334
const envId = getCodeCatalystDevEnvId()
@@ -81,7 +82,7 @@ async function reconnectDevEnvs(client: CodeCatalystClient, ctx: ExtContext): Pr
8182
const polledDevEnvs = devenvNames.join(', ')
8283
const progressTitle = localize(
8384
'AWS.codecatalyst.reconnect.restarting',
84-
'The following devenvs are restarting: {0}',
85+
'Dev Environments restarting: {0}',
8586
polledDevEnvs
8687
)
8788
vscode.window.withProgress(
@@ -143,6 +144,7 @@ async function pollDevEnvs(
143144
await setWatchedDevEnvStatus(memento, devenvs, true)
144145

145146
const shouldCloseRootInstance = Object.keys(devenvs).length === 1
147+
getLogger().info(`codecatalyst: reconnect: pollDevEnvs: ${Object.keys(devenvs).length}`)
146148

147149
while (Object.keys(devenvs).length > 0) {
148150
if (token.isCancellationRequested) {
@@ -152,7 +154,6 @@ async function pollDevEnvs(
152154

153155
for (const id in devenvs) {
154156
const details = devenvs[id]
155-
156157
const devenvName = getDevEnvName(details.alias, id)
157158

158159
try {
@@ -162,18 +163,31 @@ async function pollDevEnvs(
162163
projectName: details.projectName,
163164
})
164165

166+
const ide = metadata.ides?.[0]?.name
167+
getLogger().info(`codecatalyst: reconnect: ides=${ide} statusReason=${metadata.statusReason}`)
168+
165169
if (metadata?.status === 'RUNNING') {
166170
progress.report({
167171
message: `Dev Environment ${devenvName} is now running. Attempting to reconnect.`,
168172
})
169173

170174
openReconnectedDevEnv(client, id, details, shouldCloseRootInstance)
171175

172-
// We no longer need to watch this devenv anymore because it's already being re-opened in SSH
176+
// Don't watch this devenv, it is already being re-opened in SSH.
177+
delete devenvs[id]
178+
} else if (!isDevenvVscode(metadata.ides)) {
179+
// Technically vscode _can_ connect to a ideRuntime=jetbrains/cloud9 devenv, but
180+
// we refuse to anyway so that the experience is consistent with other IDEs
181+
// (jetbrains/cloud9) which are not capable of connecting to a devenv that lacks
182+
// their runtime/bootstrap files.
183+
const ide = metadata.ides?.[0]
184+
const toIde = ide ? ` to "${ide.name}"` : ''
185+
progress.report({ message: `Dev Environment ${devenvName} was switched${toIde}` })
186+
// Don't watch devenv that is no longer connectable.
173187
delete devenvs[id]
174188
} else if (isTerminating(metadata)) {
175189
progress.report({ message: `Dev Environment ${devenvName} is terminating` })
176-
// We no longer need to watch a devenv that is in a terminating state
190+
// Don't watch devenv that is terminating.
177191
delete devenvs[id]
178192
} else if (isExpired(details.previousConnectionTimestamp)) {
179193
progress.report({ message: `Dev Environment ${devenvName} has expired` })
@@ -192,7 +206,6 @@ function isTerminating(devenv: Pick<DevEnvironment, 'status'>): boolean {
192206
if (!devenv.status) {
193207
return false
194208
}
195-
196209
return devenv.status === 'FAILED' || devenv.status === 'DELETING' || devenv.status === 'DELETED'
197210
}
198211

src/codecatalyst/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export function openCodeCatalystUrl(o: CodeCatalystResource) {
5252
vscode.env.openExternal(vscode.Uri.parse(url))
5353
}
5454

55-
export function isCodeCatalystVSCode(ides: Ides | undefined): boolean {
55+
/** Returns true if the dev env has a "vscode" IDE runtime. */
56+
export function isDevenvVscode(ides: Ides | undefined): boolean {
5657
return ides !== undefined && ides.findIndex(ide => ide.name === 'VSCode') !== -1
5758
}
5859

src/codecatalyst/wizards/selectResource.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { AsyncCollection } from '../../shared/utilities/asyncCollection'
1717
import { getRelativeDate } from '../../shared/utilities/textUtilities'
1818
import { isValidResponse } from '../../shared/wizards/wizard'
1919
import { associateDevEnv, docs } from '../model'
20-
import { getHelpUrl, isCodeCatalystVSCode } from '../utils'
20+
import { getHelpUrl, isDevenvVscode } from '../utils'
2121

2222
export function createRepoLabel(r: codecatalyst.CodeCatalystRepo): string {
2323
return `${r.org.name} / ${r.project.name} / ${r.name}`
@@ -144,7 +144,7 @@ export function createDevEnvPrompter(
144144
): QuickPickPrompter<codecatalyst.DevEnvironment> {
145145
const helpUri = isCloud9() ? docs.cloud9.devenv : docs.vscode.devenv
146146
const envs = proj ? client.listDevEnvironments(proj) : client.listResources('devEnvironment')
147-
const filtered = envs.map(arr => arr.filter(env => isCodeCatalystVSCode(env.ides)))
147+
const filtered = envs.map(arr => arr.filter(env => isDevenvVscode(env.ides)))
148148
const isData = <T>(obj: T | DataQuickPickItem<T>['data']): obj is T => {
149149
return typeof obj !== 'function' && isValidResponse(obj)
150150
}

src/shared/clients/devenvClient.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,65 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import * as vscode from 'vscode'
67
import got from 'got'
8+
import globals from '../extensionGlobals'
9+
import { getLogger } from '../logger/logger'
710
import { getCodeCatalystDevEnvId } from '../vscode/env'
811

912
const environmentAuthToken = '__MDE_ENV_API_AUTHORIZATION_TOKEN'
1013
const environmentEndpoint = 'http://127.0.0.1:1339'
1114

12-
export class DevEnvClient {
13-
public constructor(private readonly endpoint: string = environmentEndpoint) {}
15+
/**
16+
* Client to the MDE quasi-IMDS localhost endpoint.
17+
*/
18+
export class DevEnvClient implements vscode.Disposable {
19+
static #instance: DevEnvClient
20+
private readonly timer
21+
private lastStatus = ''
22+
private onStatusChangeFn: undefined | ((oldStatus: string, newStatus: string) => void)
23+
24+
/** Singleton instance (to avoid multiple polling workers). */
25+
public static get instance() {
26+
return (this.#instance ??= new this())
27+
}
28+
29+
/** @internal */
30+
public constructor(private readonly endpoint: string = environmentEndpoint) {
31+
if (!this.id) {
32+
getLogger().debug('codecatalyst: DevEnvClient skipped (local)')
33+
this.timer = undefined
34+
} else {
35+
getLogger().info('codecatalyst: DevEnvClient started')
36+
this.timer = globals.clock.setInterval(async () => {
37+
const r = await this.getStatus()
38+
if (this.lastStatus !== r.status) {
39+
const newStatus = r.status ?? 'NULL'
40+
getLogger().info(
41+
'codecatalyst: DevEnvClient: status change (old=%s new=%s)%s%s',
42+
this.lastStatus,
43+
newStatus,
44+
r.actionId ? ` action=${r.actionId}` : '',
45+
r.message ? `: "${r.message}"` : ''
46+
)
47+
if (this.onStatusChangeFn) {
48+
this.onStatusChangeFn(this.lastStatus, newStatus)
49+
}
50+
this.lastStatus = newStatus ?? 'NULL'
51+
}
52+
}, 1000)
53+
}
54+
}
55+
56+
public onStatusChange(fn: (oldStatus: string, newStatus: string) => void) {
57+
this.onStatusChangeFn = fn
58+
}
59+
60+
public dispose() {
61+
if (this.timer) {
62+
globals.clock.clearInterval(this.timer)
63+
}
64+
}
1465

1566
public get id(): string | undefined {
1667
return getCodeCatalystDevEnvId()
@@ -33,6 +84,9 @@ export class DevEnvClient {
3384
}
3485

3586
// Get status and action type
87+
//
88+
// Example:
89+
// { status: 'IMAGES-UPDATE-AVAILABLE', location: 'devfile.yaml' }
3690
public async getStatus(): Promise<GetStatusResponse> {
3791
const response = await this.got<GetStatusResponse>('status')
3892

@@ -75,4 +129,12 @@ export interface StartDevfileRequest {
75129
recreateHomeVolumes?: boolean
76130
}
77131

78-
export type Status = 'PENDING' | 'STABLE' | 'CHANGED'
132+
export type Status =
133+
| 'PENDING'
134+
| 'STABLE'
135+
| 'CHANGED'
136+
/**
137+
* The image on-disk in the DE is different from the one in the container registry.
138+
* Client should call "/devfile/pull" to pull the latest image from the registry.
139+
*/
140+
| 'IMAGES-UPDATE-AVAILABLE'

0 commit comments

Comments
 (0)