diff --git a/src/constants.ts b/src/constants.ts index db36207..48d2af8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -51,3 +51,6 @@ export const TOKEN_REFRESH_BUFFER = 300_000; // Refresh 5 minutes before expiry /** Projects root inside workspace pods */ export const PROJECTS_ROOT = '/projects'; + +/** Remote workspace port on which machine-exec listens */ +export const MACHINE_EXEC_PORT = 3333; diff --git a/src/extension.ts b/src/extension.ts index 8166f7f..e71d074 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,7 @@ import { TokenManager } from './auth/TokenManager'; /** Git commit SHA injected at build time by webpack DefinePlugin */ declare const BUILD_COMMIT: string; import { OAuthFlow } from './auth/OAuthFlow'; -import { ClusterDiscovery } from './auth/ClusterDiscovery'; +import { ClusterDiscovery, ClusterEndpoints } from './auth/ClusterDiscovery'; import { KubeClientFactory } from './kubernetes/KubeClientFactory'; import { WorkspaceTreeProvider } from './ui/WorkspaceTreeProvider'; import { WorkspaceTreeItem } from './ui/WorkspaceTreeItem'; @@ -27,7 +27,12 @@ import { CTX_CONNECTED, STATE_ACTIVE_CONNECTION, DEVSPACES_AUTHORITY, + LABEL_DEVWORKSPACE_ID, + MACHINE_EXEC_PORT, } from './constants'; +import { ActivityTrackerService } from './remote/ActivityTrackerService'; +import * as k8s from '@kubernetes/client-node'; +import * as net from 'net'; let logger: Logger; let oldConfig: vscode.WorkspaceConfiguration; @@ -67,7 +72,7 @@ export async function activate( // --- Detect if we're in a remote session --- const effectiveRemote = vscode.env.remoteName ?? ''; if (effectiveRemote.startsWith(DEVSPACES_AUTHORITY)) { - setupRemoteSession(context, effectiveRemote); + await setupRemoteSession(context, effectiveRemote); return; } @@ -77,6 +82,56 @@ export async function activate( logger.info('Dev Spaces Connector activated'); } +async function track(events: vscode.Event[], logger: Logger, context: vscode.ExtensionContext, authProvider: OpenShiftAuthProvider) { + const activeConn: ActiveConnectionInfo | undefined = context.globalState.get(STATE_ACTIVE_CONNECTION); + if (!activeConn) { + return; + } + + const clusterDiscovery: ClusterDiscovery = new ClusterDiscovery(); + const endpoints: ClusterEndpoints = await clusterDiscovery.discover(activeConn.clusterUrl); + if (!endpoints) { + return; + } + + const kubeClientFactory = new KubeClientFactory(); + const kubeConfig: k8s.KubeConfig = kubeClientFactory.createConfig(endpoints.apiUrl, await authProvider.getAccessToken()); + const coreApi = kubeConfig.makeApiClient(k8s.CoreV1Api); + const podList = await coreApi.listNamespacedPod({ + namespace: activeConn.namespace, + labelSelector: `${LABEL_DEVWORKSPACE_ID}=${activeConn.devworkspaceId}`, + }); + if (podList.items.length === 0) { + return; + } + + const pod = podList.items[0]; + const podName = pod.metadata?.name; + if (!podName) { + return; + } + + const forward = new k8s.PortForward(kubeConfig); + const server = net.createServer((socket) => { + void forward.portForward(activeConn.namespace, podName, [MACHINE_EXEC_PORT], socket, null, socket); + }); + + const localPort = await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(0, '127.0.0.1', () => resolve((server.address() as net.AddressInfo).port)); + }); + + const activityTracker = new ActivityTrackerService(logger, localPort); + + events.forEach((e: vscode.Event) => { + context.subscriptions.push( + e(async () => { + await activityTracker.resetTimeout(); + }) + ); + }); +} + export async function deactivate(): Promise { if (cleanupIntervalsFn) { cleanupIntervalsFn(); cleanupIntervalsFn = undefined; } if (cleanupFn) { @@ -146,7 +201,7 @@ function registerResolver(context: vscode.ExtensionContext): void { // Remote Session Setup // ========================================================================= -function setupRemoteSession(context: vscode.ExtensionContext, effectiveRemote: string): void { +async function setupRemoteSession(context: vscode.ExtensionContext, effectiveRemote: string): Promise { logger.info(`Running in remote session: ${effectiveRemote}`); vscode.commands.executeCommand('setContext', CTX_CONNECTED, true); vscode.commands.executeCommand('setContext', 'devspaces.isRemoteSession', true); @@ -183,6 +238,18 @@ function setupRemoteSession(context: vscode.ExtensionContext, effectiveRemote: s } else { logger.warn('Remote session: no active connection info found — remote commands unavailable'); } + + // --- Idling Support --- + const eventsToTrack = [ + vscode.workspace.onDidChangeTextDocument, + vscode.window.onDidChangeActiveTextEditor, + vscode.window.onDidChangeTextEditorSelection, + vscode.window.onDidChangeTextEditorViewColumn, + vscode.window.onDidChangeWindowState, + vscode.window.onDidChangeTerminalState, + vscode.window.onDidChangeActiveTerminal, + ]; + await track(eventsToTrack, logger, context, authProvider); } // ========================================================================= @@ -304,7 +371,7 @@ async function setupLocalSession(context: vscode.ExtensionContext): Promise { + if (this.isTimerRunning) { + this.isNewRequest = true; + return; + } + await this.sendRequestAndSetTimer(); + } + + private async sendRequestAndSetTimer(): Promise { + await this.sendRequest(ActivityTrackerService.RETRY_COUNT); + this.isNewRequest = false; + + setTimeout( + () => this.checkNewRequestsTimerCallback(), + ActivityTrackerService.REQUEST_PERIOD_MS + ); + this.isTimerRunning = true; + } + + private async checkNewRequestsTimerCallback(): Promise { + this.isTimerRunning = false; + + if (this.isNewRequest) { + await this.sendRequestAndSetTimer(); + } + } + + private async sendRequest( + attemptsLeft: number = ActivityTrackerService.RETRY_COUNT + ): Promise { + try { + await this.updateWorkspaceActivity(); + } catch (error) { + if (attemptsLeft > 0) { + await new Promise((resolve) => setTimeout(resolve, ActivityTrackerService.RETRY_REQUEST_PERIOD_MS)); + await this.sendRequest(--attemptsLeft); + } else { + this.logger.error(`Activity tracker: Failed to ping che-machine-exec: ' + ${(error instanceof Error) ? error.message : error}`); + if (!this.errorDisplayed) { + this.errorDisplayed = true; + await this.showErrorMessage(); + this.errorDisplayed = false; + } + } + } + } + + private async showErrorMessage(): Promise { + const viewText = 'View Logs'; + const response = await vscode.window.showErrorMessage( + this.getErrorMessage(), + viewText + ); + + if (response === viewText) { + this.logger.show(); + } + } + + private getErrorMessage(): string { + + let message = 'Failed to communicate with idling service.'; + + const idletimeout = process.env.SECONDS_OF_DW_INACTIVITY_BEFORE_IDLING; + if (idletimeout) { + const timeoutInSeconds = parseInt(idletimeout); + if (!isNaN(timeoutInSeconds)) { + message += ` This development environment may automatically terminate in ${this.getTimeString(timeoutInSeconds)}.`; + } + } else { + message += ' This development environment may automatically terminate soon.'; + } + + message += ' For environments with the ephemeral storage type, you may lose any unsaved work. Please contact an administrator.' + return message; + } + + private getTimeString(_seconds: number): string { + const hours = Math.floor(_seconds / 3600); + const minutes = Math.floor((_seconds % 3600) / 60); + const seconds = _seconds % 60; + + let output = ''; + + if (hours > 0) { + output += `${hours} hour`; + if (hours > 1) { + output += 's'; + } + } + + if (minutes > 0) { + output += ` ${minutes} minute`; + if (minutes > 1) { + output += 's'; + } + } + + if (seconds > 0) { + output += ` ${seconds} second`; + if (seconds > 1) { + output += 's'; + } + } + return output + } + + public async updateWorkspaceActivity(): Promise { + const requestUrl = `http://127.0.0.1:${this.port}/activity/tick`; + const response = await fetch(requestUrl, { method: 'POST' }); + if (!response.ok) { + const message = await response.text(); + throw new Error(`Activity tick request failed: ${response.status} ${response.statusText} - ${message}`); + } + } +}