Skip to content

Commit

Permalink
Merge branch 'main' into cli_issue_#904_solution
Browse files Browse the repository at this point in the history
  • Loading branch information
gauravsaini04 authored Nov 22, 2024
2 parents 39028a3 + 7178ceb commit ddb0c0b
Show file tree
Hide file tree
Showing 5 changed files with 96 additions and 60 deletions.
82 changes: 46 additions & 36 deletions src/spec-common/injectHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,38 +487,48 @@ async function runLifecycleCommand({ lifecycleHook }: ResolverParameters, contai
},
onDidChangeDimensions: lifecycleHook.output.onDidChangeDimensions,
}, LogLevel.Info);
try {
const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder;
async function runSingleCommand(postCommand: string | string[], name?: string) {
const progressDetail = typeof postCommand === 'string' ? postCommand : postCommand.join(' ');
infoOutput.event({
type: 'progress',
name: progressName,
status: 'running',
stepDetail: progressDetail
});

// If we have a command name then the command is running in parallel and
// we need to hold output until the command is done so that the output
// doesn't get interleaved with the output of other commands.
const printMode = name ? 'off' : 'continuous';
const env = { ...(await remoteEnv), ...(await secrets) };
const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder;
async function runSingleCommand(postCommand: string | string[], name?: string) {
const progressDetails = typeof postCommand === 'string' ? postCommand : postCommand.join(' ');
infoOutput.event({
type: 'progress',
name: progressName,
status: 'running',
stepDetail: progressDetails
});
// If we have a command name then the command is running in parallel and
// we need to hold output until the command is done so that the output
// doesn't get interleaved with the output of other commands.
const printMode = name ? 'off' : 'continuous';
const env = { ...(await remoteEnv), ...(await secrets) };
try {
const { cmdOutput } = await runRemoteCommand({ ...lifecycleHook, output: infoOutput }, containerProperties, typeof postCommand === 'string' ? ['/bin/sh', '-c', postCommand] : postCommand, remoteCwd, { remoteEnv: env, pty: true, print: printMode });

// 'name' is set when parallel execution syntax is used.
if (name) {
infoOutput.raw(`\x1b[1mRunning ${name} from ${userCommandOrigin}...\x1b[0m\r\n${cmdOutput}\r\n`);
infoOutput.raw(`\x1b[1mRunning ${name} of ${lifecycleHookName} from ${userCommandOrigin}...\x1b[0m\r\n${cmdOutput}\r\n`);
}
} catch (err) {
if (printMode === 'off' && err?.cmdOutput) {
infoOutput.raw(`\r\n\x1b[1m${err.cmdOutput}\x1b[0m\r\n\r\n`);
}
if (err && (err.code === 130 || err.signal === 2)) { // SIGINT seen on darwin as code === 130, would also make sense as signal === 2.
infoOutput.raw(`\r\n\x1b[1m${name ? `${name} of ${lifecycleHookName}` : lifecycleHookName} from ${userCommandOrigin} interrupted.\x1b[0m\r\n\r\n`);
} else {
if (err?.code) {
infoOutput.write(toErrorText(`${name ? `${name} of ${lifecycleHookName}` : lifecycleHookName} from ${userCommandOrigin} failed with exit code ${err.code}. Skipping any further user-provided commands.`));
}
throw new ContainerError({
description: `${name ? `${name} of ${lifecycleHookName}` : lifecycleHookName} from ${userCommandOrigin} failed.`,
originalError: err
});
}

infoOutput.event({
type: 'progress',
name: progressName,
status: 'succeeded',
});
}
}

infoOutput.raw(`\x1b[1mRunning the ${lifecycleHookName} from ${userCommandOrigin}...\x1b[0m\r\n\r\n`);
infoOutput.raw(`\x1b[1mRunning the ${lifecycleHookName} from ${userCommandOrigin}...\x1b[0m\r\n\r\n`);

try {
let commands;
if (typeof userCommand === 'string' || Array.isArray(userCommand)) {
commands = [runSingleCommand(userCommand)];
Expand All @@ -528,24 +538,24 @@ async function runLifecycleCommand({ lifecycleHook }: ResolverParameters, contai
return runSingleCommand(command, name);
});
}
await Promise.all(commands);

const results = await Promise.allSettled(commands); // Wait for all commands to finish (successfully or not) before continuing.
const rejection = results.find(p => p.status === 'rejected');
if (rejection) {
throw (rejection as PromiseRejectedResult).reason;
}
infoOutput.event({
type: 'progress',
name: progressName,
status: 'succeeded',
});
} catch (err) {
infoOutput.event({
type: 'progress',
name: progressName,
status: 'failed',
});
if (err && (err.code === 130 || err.signal === 2)) { // SIGINT seen on darwin as code === 130, would also make sense as signal === 2.
infoOutput.raw(`\r\n\x1b[1m${lifecycleHookName} interrupted.\x1b[0m\r\n\r\n`);
} else {
if (err?.code) {
infoOutput.write(toErrorText(`${lifecycleHookName} failed with exit code ${err.code}. Skipping any further user-provided commands.`));
}
throw new ContainerError({
description: `The ${lifecycleHookName} in the ${userCommandOrigin} failed.`,
originalError: err,
});
}
throw err;
}
}
}
Expand Down
43 changes: 25 additions & 18 deletions src/spec-node/featuresCLI/resolveDependencies.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import * as path from 'path';
import * as jsonc from 'jsonc-parser';
import { Argv } from 'yargs';
import { LogLevel, mapLogLevel } from '../../spec-utils/log';
import { getPackageConfig } from '../../spec-utils/product';
import { createLog } from '../devContainers';
import { UnpackArgv } from '../devContainersSpecCLI';
import { isLocalFile, readLocalFile } from '../../spec-utils/pfs';
import { DevContainerConfig, DevContainerFeature } from '../../spec-configuration/configuration';
import { isLocalFile } from '../../spec-utils/pfs';
import { DevContainerFeature } from '../../spec-configuration/configuration';
import { buildDependencyGraph, computeDependsOnInstallationOrder, generateMermaidDiagram } from '../../spec-configuration/containerFeaturesOrder';
import { OCISourceInformation, processFeatureIdentifier, userFeaturesToArray } from '../../spec-configuration/containerFeaturesConfiguration';
import { readLockfile } from '../../spec-configuration/lockfile';
import { runAsyncHandler } from '../utils';
import { loadNativeModule } from '../../spec-common/commonUtils';
import { getCLIHost } from '../../spec-common/cliHost';
import { ContainerError } from '../../spec-common/errors';
import { uriToFsPath } from '../../spec-configuration/configurationCommonUtils';
import { workspaceFromPath } from '../../spec-utils/workspaces';
import { readDevContainerConfigFile } from '../configContainer';
import { URI } from 'vscode-uri';


interface JsonOutput {
installOrder?: {
Expand Down Expand Up @@ -61,28 +68,28 @@ async function featuresResolveDependencies({
configPath = path.join(workspaceFolder, '.devcontainer', 'devcontainer.json');
}

// Load dev container config
const buffer = await readLocalFile(configPath);
if (!buffer) {
output.write(`Could not load devcontainer.json file from path ${configPath}`, LogLevel.Error);
process.exit(1);
}
const params = {
output,
env: process.env,
};

// Parse dev container config
const config: DevContainerConfig = jsonc.parse(buffer.toString());
if (!config || !config.features) {
output.write(`No Features object in configuration '${configPath}'`, LogLevel.Error);
process.exit(1);
const cwd = workspaceFolder || process.cwd();
const cliHost = await getCLIHost(cwd, loadNativeModule, true);
const workspace = workspaceFromPath(cliHost.path, workspaceFolder);
const configFile: URI = URI.file(path.resolve(process.cwd(), configPath));
const configs = await readDevContainerConfigFile(cliHost, workspace, configFile, false, output, undefined, undefined);

if (configFile && !configs) {
throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) not found.` });
}
const configWithRaw = configs!.config;
const { config } = configWithRaw;

const userFeaturesConfig = userFeaturesToArray(config);
if (!userFeaturesConfig) {
output.write(`Could not parse features object in configuration '${configPath}'`, LogLevel.Error);
process.exit(1);
}
const params = {
output,
env: process.env,
};

const { lockfile } = await readLockfile(config);
const processFeature = async (_userFeature: DevContainerFeature) => {
Expand Down
1 change: 1 addition & 0 deletions src/test/cli.up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('Dev Containers CLI', function () {
});

describe('Command up', () => {

it('should execute successfully with valid config', async () => {
const res = await shellExec(`${cli} up --workspace-folder ${__dirname}/configs/image --include-configuration --include-merged-configuration`);
const response = JSON.parse(res.stdout);
Expand Down
18 changes: 18 additions & 0 deletions src/test/configs/poetry-example/.devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Example devcontainer.json configuration,
// wired into the vscode launch task (.vscode/launch.json)
{
"image": "mcr.microsoft.com/devcontainers/base:latest",
"features": {
"ghcr.io/devcontainers/features/python:1": {
"version": "latest"
}
},
"postStartCommand": {
"poetry setup": [
"/bin/bash",
"-i",
"-c",
"python3 -m venv $HOME/.local && source $HOME/.local/bin/activate && poetry install"
]
}
}
12 changes: 6 additions & 6 deletions src/test/container-features/lifecycleHooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,10 @@ describe('Feature lifecycle hooks', function () {
assert.match(containerUpStandardError, /Running the postAttachCommand from devcontainer.json/);

assert.match(outputOfExecCommand, /helperScript.devContainer.parallel_postCreateCommand_1.testMarker/);
assert.match(containerUpStandardError, /Running parallel1 from devcontainer.json.../);
assert.match(containerUpStandardError, /Running parallel1 of postCreateCommand from devcontainer.json.../);

assert.match(outputOfExecCommand, /helperScript.devContainer.parallel_postCreateCommand_2.testMarker/);
assert.match(containerUpStandardError, /Running parallel2 from devcontainer.json.../);
assert.match(containerUpStandardError, /Running parallel2 of postCreateCommand from devcontainer.json.../);

// Since lifecycle scripts are executed relative to the workspace folder,
// to run a script bundled with the Feature, the Feature author needs to copy that script to a persistent directory.
Expand All @@ -429,10 +429,10 @@ describe('Feature lifecycle hooks', function () {
assert.match(containerUpStandardError, /Running the postAttachCommand from Feature '\.\/rabbit'/);

assert.match(outputOfExecCommand, /helperScript.rabbit.parallel_postCreateCommand_1.testMarker/);
assert.match(containerUpStandardError, /Running parallel1 from Feature '\.\/rabbit'/);
assert.match(containerUpStandardError, /Running parallel1 of postCreateCommand from Feature '\.\/rabbit'/);

assert.match(outputOfExecCommand, /helperScript.rabbit.parallel_postCreateCommand_2.testMarker/);
assert.match(containerUpStandardError, /Running parallel2 from Feature '\.\/rabbit'/);
assert.match(containerUpStandardError, /Running parallel2 of postCreateCommand from Feature '\.\/rabbit'/);


// -- 'Otter' Feature
Expand All @@ -449,10 +449,10 @@ describe('Feature lifecycle hooks', function () {
assert.match(containerUpStandardError, /Running the postAttachCommand from Feature '\.\/otter'/);

assert.match(outputOfExecCommand, /helperScript.otter.parallel_postCreateCommand_1.testMarker/);
assert.match(containerUpStandardError, /Running parallel1 from Feature '\.\/otter'/);
assert.match(containerUpStandardError, /Running parallel1 of postCreateCommand from Feature '\.\/otter'/);

assert.match(outputOfExecCommand, /helperScript.otter.parallel_postCreateCommand_2.testMarker/);
assert.match(containerUpStandardError, /Running parallel2 from Feature '\.\/otter'/);
assert.match(containerUpStandardError, /Running parallel2 of postCreateCommand from Feature '\.\/otter'/);

// -- Assert that at no point did logging the lifecycle hook fail.
assert.notMatch(containerUpStandardError, /Running the (.*) from \?\?\?/);
Expand Down

0 comments on commit ddb0c0b

Please sign in to comment.