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
83 changes: 80 additions & 3 deletions src/vs/platform/agentHost/node/devContainerAgentHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import { EventEmitter as NodeEventEmitter } from 'events';
import { lstat, rename, rm, stat, writeFile } from 'fs/promises';
import { Duplex } from 'stream';
import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';
import { CancellationError, getErrorMessage } from '../../../base/common/errors.js';
import { CancellationError, getErrorMessage, isCancellationError } from '../../../base/common/errors.js';
import { Emitter } from '../../../base/common/event.js';
import { join, posix } from '../../../base/common/path.js';
import { StopWatch } from '../../../base/common/stopwatch.js';
import { findExecutable } from '../../../base/node/processes.js';
import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { vArray, vLiteral, vObj, vString } from '../../../base/common/validation.js';
import { vArray, vLiteral, vObj, vOptionalProp, vString } from '../../../base/common/validation.js';
import { localize } from '../../../nls.js';
import { ILogService } from '../../log/common/log.js';
import { IProductService } from '../../product/common/productService.js';
Expand Down Expand Up @@ -44,6 +44,7 @@ import {
} from './sshRemoteAgentHostHelpers.js';
import { ensureRemoteAgentHostCliInstalled } from './remoteAgentHostCliInstaller.js';
import { prepareOwnerOnlyDirectory } from './localAgentHostMetadata.js';
import { buildCreateDevContainerCacheCommand, buildLinkDevContainerServerCacheCommand, canAddDevContainerServerCacheMount, devContainerServerCacheMount, getDevContainerCliCachePath, getDevContainerServerCachePath } from './devContainerServerCache.js';

const LOG_PREFIX = '[DevContainerAgentHost]';
const DETECT_MUSL_COMMAND = 'if [ -e /etc/alpine-release ]; then printf musl; elif command -v ldd >/dev/null 2>&1; then case "$(ldd --version 2>&1)" in *musl*) printf musl;; esac; fi';
Expand Down Expand Up @@ -108,6 +109,7 @@ interface IDevContainerMount {
readonly Type: string;
readonly Source: string;
readonly Destination: string;
readonly Name?: string;
}

interface IGitIdentity {
Expand All @@ -125,6 +127,7 @@ const devContainerMountsValidator = vArray(vObj({
Type: vString(),
Source: vString(),
Destination: vString(),
Name: vOptionalProp(vString()),
}));

/** Testable relay abstraction owned by the shared-process launcher. */
Expand Down Expand Up @@ -201,9 +204,10 @@ export abstract class DevContainerAgentHostService extends Disposable implements

try {
this._logService.info(`${LOG_PREFIX} Starting Dev Container for ${config.workspaceFolder}`);
const cacheMountArgs = await this._getServerCacheMountArgs(config.connectionId, config.workspaceFolder, tokenSource.token);
const up = await this._runDevContainer(
config.connectionId,
['up', ...DEV_CONTAINER_LOG_ARGS, '--workspace-folder', config.workspaceFolder],
['up', ...DEV_CONTAINER_LOG_ARGS, '--workspace-folder', config.workspaceFolder, ...cacheMountArgs],
tokenSource.token,
);
const upResult = parseDevContainerUpResult(up.stdout);
Expand Down Expand Up @@ -242,13 +246,16 @@ export abstract class DevContainerAgentHostService extends Disposable implements

const serverDataFolderName = this._productService.serverDataFolderName ?? '.vscode-server-oss';
const quality = this._productService.quality || 'insider';
const cliCacheDir = await this._configureCaches(config.connectionId, upResult.containerId, serverDataFolderName, platform, exec, tokenSource.token);
const cliInstallation = await ensureRemoteAgentHostCliInstalled(exec, platform, {
serverDataFolderName,
quality,
commit: this._productService.commit,
reportInstalling: () => this._logService.info(`${LOG_PREFIX} Installing VS Code CLI in Dev Container...`),
logService: this._logService,
logPrefix: LOG_PREFIX,
cliCacheDir,
reportCacheStatus: message => this._reportOutput(config.connectionId, `${message}\n`),
});
const { cliBin } = cliInstallation;
const cliDataDir = getRemoteCLIDataDir(serverDataFolderName);
Expand Down Expand Up @@ -313,6 +320,76 @@ export abstract class DevContainerAgentHostService extends Disposable implements
}
}

private async _getServerCacheMountArgs(connectionId: string, workspaceFolder: string, token: CancellationToken): Promise<readonly string[]> {
try {
const config = await this._runDevContainer(connectionId, ['read-configuration', ...DEV_CONTAINER_LOG_ARGS, '--workspace-folder', workspaceFolder, '--include-merged-configuration'], token);
if (config.code !== 0) {
throw new Error(`Cannot read Dev Container configuration (exit ${config.code}): ${config.stderr}`);
}
if (canAddDevContainerServerCacheMount(config.stdout)) {
return ['--mount', devContainerServerCacheMount];
}
this._logService.info(`${LOG_PREFIX} Keeping configured container mounts; server cache sharing requires an existing vscode volume mount`);
} catch (error) {
if (isCancellationError(error) || token.isCancellationRequested) {
throw error;
}
this._logService.warn(`${LOG_PREFIX} Cannot add optional shared server cache mount`, error);
this._reportOutput(connectionId, `Cannot add optional shared server cache mount: ${getErrorMessage(error)}\n`);
}
return [];
}

private async _configureCaches(connectionId: string, containerId: string, serverDataFolderName: string, platform: { os: string; arch: string }, exec: ISshExec, token: CancellationToken): Promise<string | undefined> {
const reportError = (kind: string, error: Error) => {
this._logService.warn(`${LOG_PREFIX} Shared ${kind} cache unavailable; keeping the existing CLI cache`, error);
this._reportOutput(connectionId, `Shared ${kind} cache unavailable; keeping the existing CLI cache: ${getErrorMessage(error)}\n`);
};
try {
const mounts = await this._getContainerMounts(connectionId, containerId, token);
if (!mounts.some(mount => mount.Type === 'volume' && mount.Name === 'vscode' && mount.Destination === '/vscode')
|| mounts.some(mount => mount.Destination.startsWith('/vscode/'))) {
throw new Error('The shared vscode volume is not mounted at /vscode without nested mounts');
}
const { stdout } = await exec('id -u; id -g');
const [uid, gid] = stdout.trim().split(/\r?\n/);
let cliCacheDir: string | undefined;
for (const kind of ['server', 'CLI'] as const) {
if (kind === 'CLI' && !this._productService.commit) {
continue;
}
try {
const cachePath = kind === 'server' ? getDevContainerServerCachePath(serverDataFolderName, platform) : getDevContainerCliCachePath(serverDataFolderName, platform);
const created = await this._runLocalCommand('docker', [
'exec', '--user', 'root', containerId, '/bin/sh', '-c', buildCreateDevContainerCacheCommand(cachePath, uid, gid),
], await this._resolveShellEnvironment(), token);
if (created.code !== 0) {
throw new Error(`Unable to prepare shared ${kind} cache (exit ${created.code}): ${created.stderr}`);
}
if (kind === 'server') {
await exec(buildLinkDevContainerServerCacheCommand(serverDataFolderName, cachePath));
this._logService.info(`${LOG_PREFIX} Using shared server cache at ${cachePath}`);
this._reportOutput(connectionId, `Using shared server cache at ${cachePath}\n`);
} else {
cliCacheDir = cachePath;
}
} catch (error) {
if (isCancellationError(error) || token.isCancellationRequested) {
throw error;
}
reportError(kind, error);
}
}
return cliCacheDir;
} catch (error) {
if (isCancellationError(error) || token.isCancellationRequested) {
throw error;
}
reportError('server', error);
return undefined;
}
}

private async _configureGitSafeDirectory(connectionId: string, containerId: string, remoteWorkspaceFolder: string, exec: ISshExec, token: CancellationToken): Promise<void> {
const rootResult = await exec(
`command -v git >/dev/null 2>&1 && ROOT_FOLDER="$(git -C ${shellEscape(remoteWorkspaceFolder)} rev-parse --show-toplevel)" && test "$(stat -c %u "$ROOT_FOLDER")" != "$(id -u)" && printf '%s' "$ROOT_FOLDER"`,
Expand Down
109 changes: 109 additions & 0 deletions src/vs/platform/agentHost/node/devContainerServerCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { posix } from '../../../base/common/path.js';
import { vArray, vObj, vOptionalProp, vString, vUnion } from '../../../base/common/validation.js';
import { getRemoteCLIDataDir, shellEscape } from './sshRemoteAgentHostHelpers.js';

export const devContainerServerCacheMount = 'type=volume,source=vscode,target=/vscode,external=true';

const configurationValidator = vObj({
configuration: vObj({
dockerComposeFile: vOptionalProp(vUnion(vString(), vArray(vString()))),
workspaceMount: vOptionalProp(vString()),
runArgs: vOptionalProp(vArray(vString())),
}),
mergedConfiguration: vObj({
mounts: vOptionalProp(vArray(vUnion(vString(), vObj({ target: vString() })))),
}),
});

/** Compose controls its own mounts; do not shadow a configured mount to add an optional cache. */
export function canAddDevContainerServerCacheMount(output: string): boolean {
const { content, error } = configurationValidator.validate(JSON.parse(output));
if (error) {
throw new Error(`Invalid Dev Container configuration: ${error.message}`);
}
const { configuration, mergedConfiguration } = content;
const usesCachePath = (value: string) => /(?:^|[=:])\/vscode(?:\/|[,:\s]|$)/.test(value);
return configuration.dockerComposeFile === undefined
&& !configuration.runArgs?.some(usesCachePath)
&& !usesCachePath(configuration.workspaceMount ?? '')
&& !mergedConfiguration.mounts?.some(mount => usesCachePath(typeof mount === 'string' ? mount : mount.target));
}

export function getDevContainerServerCachePath(serverDataFolderName: string, platform: { os: string; arch: string }): string {
return getDevContainerCachePath(serverDataFolderName, platform, 'servers');
}

export function getDevContainerCliCachePath(serverDataFolderName: string, platform: { os: string; arch: string }): string {
return getDevContainerCachePath(serverDataFolderName, platform, 'bin');
}

function getDevContainerCachePath(serverDataFolderName: string, platform: { os: string; arch: string }, kind: 'servers' | 'bin'): string {
getRemoteCLIDataDir(serverDataFolderName);
const sharedFolderName = serverDataFolderName.replace(/^\.+/, '');
if (!sharedFolderName) {
throw new Error('Invalid Dev Container server data folder');
}
if (!['linux', 'alpine'].includes(platform.os) || !['x64', 'arm64', 'armhf'].includes(platform.arch)) {
throw new Error(`Unsupported Dev Container server cache platform: ${platform.os}-${platform.arch}`);
}
return posix.join('/vscode', sharedFolderName, 'cli', kind, `${platform.os}-${platform.arch}`);
}

/** Creates only missing cache directories, without changing ownership of an existing shared cache. */
export function buildCreateDevContainerCacheCommand(cachePath: string, uid: string, gid: string, cacheRoot = '/vscode'): string {
if (!/^\d+$/.test(uid) || !/^\d+$/.test(gid)
|| !posix.isAbsolute(cacheRoot) || cacheRoot.endsWith('/') || posix.normalize(cacheRoot) !== cacheRoot
|| !cachePath.startsWith(`${cacheRoot}/`) || cachePath.endsWith('/') || posix.normalize(cachePath) !== cachePath) {
throw new Error('Invalid Dev Container server cache path or user');
}
const parents: string[] = [];
for (let parent = posix.dirname(cachePath); parent !== cacheRoot; parent = posix.dirname(parent)) {
parents.unshift(parent);
}
return [
'set -eu',
'umask 022',
`test ! -L ${shellEscape(cacheRoot)}`,
`test -d ${shellEscape(cacheRoot)}`,
...parents.flatMap(parent => [
`test ! -L ${shellEscape(parent)}`,
`test -d ${shellEscape(parent)} || mkdir ${shellEscape(parent)} || test -d ${shellEscape(parent)}`,
]),
`test ! -L ${shellEscape(cachePath)}`,
`if mkdir ${shellEscape(cachePath)} 2>/dev/null; then chown ${shellEscape(`${uid}:${gid}`)} ${shellEscape(cachePath)}; else test -d ${shellEscape(cachePath)}; fi`,
].join('\n');
}

/** Linux containers support ln -T, which prevents races from placing a link inside an existing directory. */
export function buildLinkDevContainerServerCacheCommand(serverDataFolderName: string, cachePath: string): string {
return [
'set -eu',
`cli_dir=${getRemoteCLIDataDir(serverDataFolderName)}`,
`cache_dir=${shellEscape(cachePath)}`,
'servers="$cli_dir/servers"',
'if [ -L "$servers" ]; then',
' if [ "$(readlink "$servers")" != "$cache_dir" ]; then echo "Preserving existing servers symlink" >&2; exit 1; fi',
'elif [ -e "$servers" ]; then',
' echo "Preserving existing private server cache" >&2; exit 1',
'fi',
'cache_unavailable() {',
' if [ -L "$servers" ] && [ "$(readlink "$servers")" = "$cache_dir" ]; then rm -- "$servers"; fi',
' echo "$1" >&2',
' exit 1',
'}',
'test -d "$cache_dir" && test -w "$cache_dir" || cache_unavailable "Shared server cache is not writable"',
'for entry in "$cache_dir/lru.json" "$cache_dir/.locks"; do',
' if [ -L "$entry" ] || { [ -e "$entry" ] && [ ! -w "$entry" ]; }; then cache_unavailable "Shared server cache metadata is not writable"; fi',
'done',
'mkdir -p "$cli_dir"',
'if [ ! -L "$servers" ]; then',
' ln -sT "$cache_dir" "$servers" || { test -L "$servers" && test "$(readlink "$servers")" = "$cache_dir"; }',
'fi',
'printf "%s" "$cache_dir"',
].join('\n');
}
74 changes: 74 additions & 0 deletions src/vs/platform/agentHost/node/remoteAgentHostCliCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { posix } from '../../../base/common/path.js';
import { getRemoteCLIArchiveName, getRemoteCLIBin, getRemoteCLIInstallRoot, shellEscape } from './sshRemoteAgentHostHelpers.js';

/** Downloads immutable CLI entries under a process lock, then installs an independent private copy. */
export function buildInstallRemoteCliFromCacheCommand(cacheDir: string, serverDataFolderName: string, quality: string, commit: string, url: string): string {
const cliBin = getRemoteCLIBin(serverDataFolderName, quality, commit);
if (!posix.isAbsolute(cacheDir) || posix.normalize(cacheDir) !== cacheDir || !/^[a-z]+$/.test(quality)) {
throw new Error('Invalid CLI cache directory or quality');
}
const archive = getRemoteCLIArchiveName(quality);
const key = `${quality}-${commit}`;
const commitGlob = '[0-9a-f]'.repeat(40);
return [
'set -eu',
'umask 022',
`cache_dir=${shellEscape(cacheDir)}`,
`cache_key=${shellEscape(key)}`,
`archive=${shellEscape(archive)}`,
`install_root=${getRemoteCLIInstallRoot(serverDataFolderName)}`,
'test ! -L "$cache_dir" && test -d "$cache_dir" && test -w "$cache_dir"',
'command -v flock >/dev/null || { echo "CLI cache requires flock" >&2; exit 1; }',
'test ! -L "$cache_dir/.locks"',
'mkdir -p "$cache_dir/.locks"',
'test ! -L "$cache_dir/.locks/$cache_key"',
'exec 9>"$cache_dir/.locks/$cache_key"',
'flock 9',
'entry="$cache_dir/$cache_key"',
'staging="$cache_dir/.$cache_key.staging"',
'private_tmp=',
'trap \'rm -rf -- "$staging"; if [ -n "$private_tmp" ]; then rm -rf -- "$private_tmp"; fi\' 0',
'trap \'exit 1\' 1 2 15',
'test ! -L "$entry"',
'rm -rf -- "$staging"',
'if [ ! -e "$entry" ]; then',
' mkdir -p "$staging/content"',
` curl -fsSL ${shellEscape(url)} -o "$staging/archive.tar.gz"`,
' tar xzf "$staging/archive.tar.gz" -C "$staging/content"',
' test ! -L "$staging/content/$archive" && test -f "$staging/content/$archive"',
' chmod +x "$staging/content/$archive"',
' version=$("$staging/content/$archive" --version)',
` case "$version" in *${shellEscape(commit)}*) ;; *) echo "Downloaded CLI does not match the requested commit" >&2; exit 1 ;; esac`,
' mv -T "$staging/content" "$entry"',
'fi',
'test ! -L "$entry/$archive" && test -f "$entry/$archive" && test -x "$entry/$archive"',
'version=$("$entry/$archive" --version)',
`case "$version" in *${shellEscape(commit)}*) ;; *) echo "Cached CLI does not match the requested commit" >&2; exit 1 ;; esac`,
'mkdir -p "$install_root"',
'private_tmp=$(mktemp -d "$install_root/.cli-install-XXXXXX")',
'cp "$entry/$archive" "$private_tmp/$archive"',
`mv -fT "$private_tmp/$archive" ${cliBin}`,
'touch "$entry"',
'rm -rf -- "$staging" "$private_tmp"',
'trap - 0',
'flock -u 9',
// Pruning takes the same entry locks as copying; a private install never depends on cache lifetime.
'(',
' cd "$cache_dir"',
` ls -1dt -- ${quality}-${commitGlob} 2>/dev/null | tail -n +6 | while IFS= read -r old; do`,
' if [ "$old" != "$cache_key" ]; then',
' (',
' test ! -L ".locks/$old"',
' exec 8>".locks/$old"',
' if flock -n 8; then rm -rf -- "$old"; fi',
' )',
' fi',
' done',
')',
].join('\n');
}
28 changes: 27 additions & 1 deletion src/vs/platform/agentHost/node/remoteAgentHostCliInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { isCancellationError } from '../../../base/common/errors.js';
import { ILogService } from '../../log/common/log.js';
import { buildInstallRemoteCliFromCacheCommand } from './remoteAgentHostCliCache.js';
import {
buildCLIDownloadUrl,
buildCleanupOldCLIsCommand,
Expand All @@ -22,6 +24,8 @@ export interface IRemoteAgentHostCliInstallOptions {
readonly reportInstalling: () => void;
readonly logService: ILogService;
readonly logPrefix?: string;
readonly cliCacheDir?: string;
readonly reportCacheStatus?: (message: string) => void;
}

/** The resolved CLI path and whether this invocation installed it. */
Expand Down Expand Up @@ -77,7 +81,26 @@ async function ensurePinnedCliInstalled(
].join(' && ');

try {
await exec(installCommand);
let installedFromCache = false;
if (options.cliCacheDir) {
try {
await exec(buildInstallRemoteCliFromCacheCommand(options.cliCacheDir, options.serverDataFolderName, options.quality, commit, url));
installedFromCache = true;
const message = `Installed private CLI copy from shared cache at ${options.cliCacheDir}`;
options.logService.info(`${logPrefix} ${message}`);
options.reportCacheStatus?.(message);
} catch (error) {
if (isCancellationError(error)) {
throw error;
}
const message = `Shared CLI cache unavailable; downloading a private copy: ${error instanceof Error ? error.message : String(error)}`;
options.logService.warn(`${logPrefix} ${message}`);
options.reportCacheStatus?.(message);
}
}
if (!installedFromCache) {
await exec(installCommand);
}
const { code: versionCode } = await exec(`${cliBin} --version`, { ignoreExitCode: true });
if (versionCode !== 0) {
throw new Error(`CLI at ${cliBin} failed --version check after install (exit code ${versionCode})`);
Expand All @@ -86,6 +109,9 @@ async function ensurePinnedCliInstalled(
await exec(buildCleanupOldCLIsCommand(options.serverDataFolderName, options.quality), { ignoreExitCode: true });
return { cliBin, installed: true };
} catch (error) {
if (isCancellationError(error)) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
options.logService.warn(`${logPrefix} Could not install matching CLI for commit ${commit}: ${message}. Looking for a fallback CLI...`);
const fallback = await findFallbackCli(exec, options);
Expand Down
Loading
Loading