Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 3 additions & 2 deletions src/spec-node/featuresCLI/testCommandImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { CLIHost } from '../../spec-common/cliHost';
import { launch, ProvisionOptions, createDockerParams } from '../devContainers';
import { doExec } from '../devContainersSpecCLI';
import { LaunchResult, staticExecParams, staticProvisionParams, testLibraryScript } from './utils';
import { DockerResolverParameters } from '../utils';
import { DockerResolverParameters, normalizeDevContainerLabelPath } from '../utils';
import { DevContainerConfig } from '../../spec-configuration/configuration';
import { FeaturesTestCommandInput } from './test';
import { cpDirectoryLocal, rmLocal } from '../../spec-utils/pfs';
Expand Down Expand Up @@ -546,7 +546,8 @@ async function launchProject(params: DockerResolverParameters, workspaceFolder:
const { common } = params;
let response = {} as LaunchResult;

const idLabels = [`devcontainer.local_folder=${workspaceFolder}`, `devcontainer.is_test_run=true`];
const normalizedWorkspaceFolder = normalizeDevContainerLabelPath(process.platform, workspaceFolder);
const idLabels = [`devcontainer.local_folder=${normalizedWorkspaceFolder}`, `devcontainer.is_test_run=true`];
const options: ProvisionOptions = {
...staticProvisionParams,
workspaceFolder,
Expand Down
90 changes: 83 additions & 7 deletions src/spec-node/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { CommonDevContainerConfig, ContainerProperties, getContainerProperties,
import { Workspace } from '../spec-utils/workspaces';
import { URI } from 'vscode-uri';
import { ShellServer } from '../spec-common/shellServer';
import { inspectContainer, inspectImage, getEvents, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer } from '../spec-shutdown/dockerUtils';
import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer } from '../spec-shutdown/dockerUtils';
import { getRemoteWorkspaceFolder } from './dockerCompose';
import { findGitRootFolder } from '../spec-common/git';
import { parentURI, uriToFsPath } from '../spec-configuration/configurationCommonUtils';
Expand Down Expand Up @@ -614,21 +614,99 @@ export function getEmptyContextFolder(common: ResolverParameters) {
return common.cliHost.path.join(common.persistedFolder, 'empty-folder');
}

export function normalizeDevContainerLabelPath(platform: NodeJS.Platform, value: string): string {
if (platform !== 'win32') {
return value;
}

// Normalize separators and dot segments, then explicitly lowercase the drive
// letter because devcontainer.local_folder / devcontainer.config_file labels
// should compare case-insensitively on Windows.
const normalized = path.win32.normalize(value);
if (normalized.length >= 2 && normalized[1] === ':') {
return normalized[0].toLowerCase() + normalized.slice(1);
}

return normalized;
}

async function findDevContainerByNormalizedLabels(params: DockerResolverParameters | DockerCLIParameters, normalizedWorkspaceFolder: string, normalizedConfigFile: string) {
if (process.platform !== 'win32') {
return undefined;
}

const ids = await listContainers(params, true, [hostFolderLabel]);
if (!ids.length) {
return undefined;
}

const details = await inspectContainers(params, ids);
return details
.filter(container => container.State.Status !== 'removing')
.find(container => {
const labels = container.Config.Labels || {};
const containerWorkspaceFolder = labels[hostFolderLabel];
if (!containerWorkspaceFolder || normalizeDevContainerLabelPath('win32', containerWorkspaceFolder) !== normalizedWorkspaceFolder) {
return false;
}

const containerConfigFile = labels[configFileLabel];
return !!containerConfigFile
&& normalizeDevContainerLabelPath('win32', containerConfigFile) === normalizedConfigFile;
});
}

async function findLegacyDevContainerByNormalizedWorkspaceFolder(params: DockerResolverParameters | DockerCLIParameters, normalizedWorkspaceFolder: string) {
if (process.platform !== 'win32') {
return undefined;
}

const ids = await listContainers(params, true, [hostFolderLabel]);
if (!ids.length) {
return undefined;
}

const details = await inspectContainers(params, ids);
return details
.filter(container => container.State.Status !== 'removing')
.find(container => {
const labels = container.Config.Labels || {};
const containerWorkspaceFolder = labels[hostFolderLabel];
Comment thread
mrexodia marked this conversation as resolved.
if (!containerWorkspaceFolder) {
return false;
}

return normalizeDevContainerLabelPath('win32', containerWorkspaceFolder) === normalizedWorkspaceFolder;
});
}

export async function findContainerAndIdLabels(params: DockerResolverParameters | DockerCLIParameters, containerId: string | undefined, providedIdLabels: string[] | undefined, workspaceFolder: string | undefined, configFile: string | undefined, removeContainerWithOldLabels?: boolean | string) {
if (providedIdLabels) {
return {
container: containerId ? await inspectContainer(params, containerId) : await findDevContainer(params, providedIdLabels),
idLabels: providedIdLabels,
};
}

const normalizedWorkspaceFolder = workspaceFolder ? normalizeDevContainerLabelPath(process.platform, workspaceFolder) : workspaceFolder;
const normalizedConfigFile = configFile ? normalizeDevContainerLabelPath(process.platform, configFile) : configFile;
const newLabels = [`${hostFolderLabel}=${normalizedWorkspaceFolder}`, `${configFileLabel}=${normalizedConfigFile}`];
Comment thread
mrexodia marked this conversation as resolved.
Outdated
const oldLabels = [`${hostFolderLabel}=${normalizedWorkspaceFolder}`];

let container: ContainerDetails | undefined;
if (containerId) {
container = await inspectContainer(params, containerId);
} else if (workspaceFolder && configFile) {
container = await findDevContainer(params, [`${hostFolderLabel}=${workspaceFolder}`, `${configFileLabel}=${configFile}`]);
} else if (normalizedWorkspaceFolder && normalizedConfigFile) {
container = await findDevContainer(params, newLabels);
if (!container) {
container = await findDevContainerByNormalizedLabels(params, normalizedWorkspaceFolder, normalizedConfigFile);
}
if (!container) {
// Fall back to old labels.
container = await findDevContainer(params, [`${hostFolderLabel}=${workspaceFolder}`]);
container = await findDevContainer(params, oldLabels);
if (!container) {
container = await findLegacyDevContainerByNormalizedWorkspaceFolder(params, normalizedWorkspaceFolder);
}
if (container) {
if (container.Config.Labels?.[configFileLabel]) {
// But ignore containers with new labels.
Expand All @@ -645,9 +723,7 @@ export async function findContainerAndIdLabels(params: DockerResolverParameters
}
return {
container,
idLabels: !container || container.Config.Labels?.[configFileLabel] ?
[`${hostFolderLabel}=${workspaceFolder}`, `${configFileLabel}=${configFile}`] :
[`${hostFolderLabel}=${workspaceFolder}`],
idLabels: !container || container.Config.Labels?.[configFileLabel] ? newLabels : oldLabels,
};
}

Expand Down
4 changes: 3 additions & 1 deletion src/test/container-features/containerFeaturesOCI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@ describe('getRef()', async function () {
});

describe('Test OCI Pull', async function () {
this.timeout('10s');
// These tests fetch manifests/blobs from a live OCI registry, so allow
// extra time for auth/token exchange and transient network latency in CI.
this.timeout('60s');

it('Parse OCI identifier', async function () {
const feat = getRef(output, 'ghcr.io/codspace/features/ruby:1');
Expand Down
4 changes: 3 additions & 1 deletion src/test/container-features/featureHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ describe('validate processFeatureIdentifier', async function () {
console.log(`workspaceRoot = ${workspaceRoot}, defaultConfigPath = ${defaultConfigPath}`);

describe('VALID processFeatureIdentifier examples', async function () {
this.timeout('4s');
// These cases perform live OCI/GHCR requests, so allow extra time for
// registry auth/token exchange and transient network latency in CI.
this.timeout('20s');

it('should process v1 local-cache', async function () {
// Parsed out of a user's devcontainer.json
Expand Down
29 changes: 29 additions & 0 deletions src/test/labelPathNormalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import { assert } from 'chai';
import { normalizeDevContainerLabelPath } from '../spec-node/utils';

describe('normalizeDevContainerLabelPath', function () {
it('lowercases Windows drive letters', function () {
assert.equal(
normalizeDevContainerLabelPath('win32', 'C:\\CodeBlocks\\remill'),
'c:\\CodeBlocks\\remill'
);
});

it('normalizes Windows path separators', function () {
assert.equal(
normalizeDevContainerLabelPath('win32', 'C:/CodeBlocks/remill/.devcontainer/devcontainer.json'),
'c:\\CodeBlocks\\remill\\.devcontainer\\devcontainer.json'
);
});

it('leaves non-Windows paths unchanged', function () {
assert.equal(
normalizeDevContainerLabelPath('linux', '/workspaces/remill'),
'/workspaces/remill'
);
});
});