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
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
75 changes: 71 additions & 4 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -77,6 +82,56 @@ export async function activate(
logger.info('Dev Spaces Connector activated');
}

async function track(events: vscode.Event<any>[], logger: Logger, context: vscode.ExtensionContext, authProvider: OpenShiftAuthProvider) {
const activeConn: ActiveConnectionInfo | undefined = context.globalState.get<ActiveConnectionInfo>(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<number>((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<any>) => {
context.subscriptions.push(
e(async () => {
await activityTracker.resetTimeout();
})
);
});
}

export async function deactivate(): Promise<void> {
if (cleanupIntervalsFn) { cleanupIntervalsFn(); cleanupIntervalsFn = undefined; }
if (cleanupFn) {
Expand Down Expand Up @@ -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<void> {
logger.info(`Running in remote session: ${effectiveRemote}`);
vscode.commands.executeCommand('setContext', CTX_CONNECTED, true);
vscode.commands.executeCommand('setContext', 'devspaces.isRemoteSession', true);
Expand Down Expand Up @@ -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);
}

// =========================================================================
Expand Down Expand Up @@ -304,7 +371,7 @@ async function setupLocalSession(context: vscode.ExtensionContext): Promise<void
const newConfig = vscode.workspace.getConfiguration('devspaces');

if (hasConfigKeyChanged('certificateValidation.enabled', oldConfig, newConfig)) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = newConfig.get('certificateValidation.enabled', true) ? '1' : '0';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = newConfig.get('certificateValidation.enabled', true) ? '1' : '0';
}

oldConfig = newConfig;
Expand Down
162 changes: 162 additions & 0 deletions src/remote/ActivityTrackerService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**********************************************************************
* Copyright (c) 2022 Red Hat, Inc.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
***********************************************************************/
import * as vscode from "vscode";
import { Logger } from "../util/Logger";

/**
* Receives activity updates and sends reset inactivity requests to the che-machine-exec /activity/tick endpoint.
* To avoid duplicate requests may send requests periodically. This means
* that, in the worst case, it might keep user's workspace alive for a longer period of time.
*/
export class ActivityTrackerService {
// Time before sending next request. If multiple requests are received during this period,
// only one request will be sent. A second request will be sent after this period ends.
private static REQUEST_PERIOD_MS = 1 * 60 * 1000;
// Time before resending request to che-machine-exec if a network error occurs.
private static RETRY_REQUEST_PERIOD_MS = 5 * 1000;
// Number of retries before give up if a network error occurs.
private static RETRY_COUNT = 5;

// Indicates state of the timer. If true timer is running.
private isTimerRunning: boolean;
// Flag which is used to check if new requests were received during timer awaiting.
private isNewRequest: boolean;
// Flag used to keep track whether the ping error warning was already displayed or not.
private errorDisplayed: boolean;
private logger: Logger;
port: number;

constructor(logger: Logger, port: number) {
this.isTimerRunning = false;
this.isNewRequest = false;
this.errorDisplayed = false;
this.logger = logger;
this.port = port;
}

/**
* Invoked each time when a client sends an activity request.
*/
async resetTimeout(): Promise<void> {
if (this.isTimerRunning) {
this.isNewRequest = true;
return;
}
await this.sendRequestAndSetTimer();
}

private async sendRequestAndSetTimer(): Promise<void> {
await this.sendRequest(ActivityTrackerService.RETRY_COUNT);
this.isNewRequest = false;

setTimeout(
() => this.checkNewRequestsTimerCallback(),
ActivityTrackerService.REQUEST_PERIOD_MS
);
this.isTimerRunning = true;
}

private async checkNewRequestsTimerCallback(): Promise<void> {
this.isTimerRunning = false;

if (this.isNewRequest) {
await this.sendRequestAndSetTimer();
}
}

private async sendRequest(
attemptsLeft: number = ActivityTrackerService.RETRY_COUNT
): Promise<void> {
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<void> {
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<void> {
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}`);
}
}
}