Skip to content

Commit 1a8bc5a

Browse files
committed
Merge branch 'main' of github.com:devcontainers/cli into fea/pkg
2 parents 1b5907d + a53630c commit 1a8bc5a

19 files changed

Lines changed: 668 additions & 81 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ Notable changes.
44

55
## January 2023
66

7+
### [0.29.0]
8+
9+
- Add `set-up` command. (https://github.com/microsoft/vscode-remote-release/issues/7872)
10+
11+
### [0.28.0]
12+
13+
- Features preamble: Add warnings for Feature renames & deprecation. (https://github.com/devcontainers/cli/pull/366)
14+
- Add dotfiles functionallity. (https://github.com/devcontainers/cli/pull/362)
15+
- Cache user env for performance improvement. (https://github.com/devcontainers/cli/pull/374)
16+
717
### [0.27.1]
818

919
- Fix: Modify argument regex to only allow certain set of values (https://github.com/devcontainers/cli/pull/361)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@devcontainers/cli",
33
"description": "Dev Containers CLI",
4-
"version": "0.27.1",
4+
"version": "0.29.0",
55
"bin": {
66
"devcontainer": "devcontainer.js"
77
},

src/spec-common/commonUtils.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -346,11 +346,12 @@ async function findLocalWindowsExecutable(command: string, cwd = process.cwd(),
346346
}
347347
}
348348
}
349-
// No PATH environment. Make path absolute to the cwd.
349+
// No PATH environment. Bail out.
350350
if (paths === void 0 || paths.length === 0) {
351-
output.write(`findLocalWindowsExecutable: No PATH to look up exectuable '${command}'.`);
352-
const fullPath = path.join(cwd, command);
353-
return await findLocalWindowsExecutableWithExtension(fullPath) || fullPath;
351+
output.write(`findLocalWindowsExecutable: No PATH to look up executable '${command}'.`);
352+
const err = new Error(`No PATH to look up executable '${command}'.`);
353+
(err as any).code = 'ENOENT';
354+
throw err;
354355
}
355356
// We have a simple file name. We get the path variable from the env
356357
// and try to find the executable on the path.
@@ -367,9 +368,11 @@ async function findLocalWindowsExecutable(command: string, cwd = process.cwd(),
367368
return withExtension;
368369
}
369370
}
371+
// Not found in PATH. Bail out.
370372
output.write(`findLocalWindowsExecutable: Exectuable '${command}' not found on PATH '${pathValue}'.`);
371-
const fullPath = path.join(cwd, command);
372-
return await findLocalWindowsExecutableWithExtension(fullPath) || fullPath;
373+
const err = new Error(`Exectuable '${command}' not found on PATH '${pathValue}'.`);
374+
(err as any).code = 'ENOENT';
375+
throw err;
373376
}
374377

375378
const pathext = process.env.PATHEXT;

src/spec-common/dotfiles.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as path from 'path';
7+
import { LogLevel } from '../spec-utils/log';
8+
9+
import { ResolverParameters, ContainerProperties, createFileCommand } from './injectHeadless';
10+
11+
const installCommands = [
12+
'install.sh',
13+
'install',
14+
'bootstrap.sh',
15+
'bootstrap',
16+
'script/bootstrap',
17+
'setup.sh',
18+
'setup',
19+
'script/setup',
20+
];
21+
22+
export async function installDotfiles(params: ResolverParameters, properties: ContainerProperties, dockerEnvP: Promise<Record<string, string>>) {
23+
let { repository, installCommand, targetPath } = params.dotfilesConfiguration;
24+
if (!repository) {
25+
return;
26+
}
27+
const dockerEnv = await dockerEnvP;
28+
if (repository.indexOf(':') === -1 && !/^\.{0,2}\//.test(repository)) {
29+
repository = `https://github.com/${repository}.git`;
30+
}
31+
const shellServer = properties.shellServer;
32+
const markerFile = getDotfilesMarkerFile(properties);
33+
const env = Object.keys(dockerEnv)
34+
.filter(key => !(key.startsWith('BASH_FUNC_') && key.endsWith('%%')))
35+
.reduce((env, key) => `${env}${key}=${quoteValue(dockerEnv[key])} `, '');
36+
try {
37+
params.output.event({
38+
type: 'progress',
39+
name: 'Installing Dotfiles',
40+
status: 'running',
41+
});
42+
if (installCommand) {
43+
await shellServer.exec(`# Clone & install dotfiles
44+
${createFileCommand(markerFile)} || (echo dotfiles marker found && exit 1) || exit 0
45+
command -v git >/dev/null 2>&1 || (echo git not found && exit 1) || exit 0
46+
[ -e ${targetPath} ] || ${env}git clone ${repository} ${targetPath} || exit $?
47+
if [ -x ${installCommand} ]
48+
then
49+
echo Executing script ${installCommand}
50+
cd ${targetPath} && ${env}${installCommand}
51+
else
52+
echo Error: ${installCommand} not executable
53+
exit 126
54+
fi
55+
`, { logOutput: 'continuous', logLevel: LogLevel.Info });
56+
} else {
57+
await shellServer.exec(`# Clone & install dotfiles
58+
${createFileCommand(markerFile)} || (echo dotfiles marker found && exit 1) || exit 0
59+
command -v git >/dev/null 2>&1 || (echo git not found && exit 1) || exit 0
60+
[ -e ${targetPath} ] || ${env}git clone ${repository} ${targetPath} || exit $?
61+
cd ${targetPath}
62+
for f in ${installCommands.join(' ')}
63+
do
64+
if [ -e $f ]
65+
then
66+
installCommand=$f
67+
break
68+
fi
69+
done
70+
if [ -z "$installCommand" ]
71+
then
72+
dotfiles=$(ls -d ${targetPath}/.* 2>/dev/null | grep -v -E '/(.|..|.git)$')
73+
if [ ! -z "$dotfiles" ]
74+
then
75+
echo Linking dotfiles: $dotfiles
76+
ln -sf $dotfiles ~ 2>/dev/null
77+
else
78+
echo No dotfiles found.
79+
fi
80+
else
81+
if [ -x "$installCommand" ]
82+
then
83+
echo Executing script '${targetPath}'/"$installCommand"
84+
${env}./"$installCommand"
85+
else
86+
echo Error: '${targetPath}'/"$installCommand" not executable
87+
exit 126
88+
fi
89+
fi
90+
`, { logOutput: 'continuous', logLevel: LogLevel.Info });
91+
}
92+
params.output.event({
93+
type: 'progress',
94+
name: 'Installing Dotfiles',
95+
status: 'succeeded',
96+
});
97+
} catch (err) {
98+
params.output.event({
99+
type: 'progress',
100+
name: 'Installing Dotfiles',
101+
status: 'failed',
102+
});
103+
}
104+
}
105+
106+
function quoteValue(value: string | undefined) {
107+
return `'${(value || '').replace(/'+/g, '\'"$&"\'')}'`;
108+
}
109+
110+
function getDotfilesMarkerFile(properties: ContainerProperties) {
111+
return path.posix.join(properties.userDataFolder, '.dotfilesMarker');
112+
}

src/spec-common/injectHeadless.ts

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { containerSubstitute } from './variableSubstitution';
1919
import { delay } from './async';
2020
import { Log, LogEvent, LogLevel, makeLog, nullLog } from '../spec-utils/log';
2121
import { buildProcessTrees, findProcesses, Process, processTreeToString } from './proc';
22+
import { installDotfiles } from './dotfiles';
2223

2324
export enum ResolverProgress {
2425
Begin,
@@ -44,6 +45,7 @@ export interface ResolverParameters {
4445
env: NodeJS.ProcessEnv;
4546
cwd: string;
4647
isLocalContainer: boolean;
48+
dotfilesConfiguration: DotfilesConfiguration;
4749
progress: (current: ResolverProgress) => void;
4850
output: Log;
4951
allowSystemConfigChange: boolean;
@@ -62,6 +64,7 @@ export interface ResolverParameters {
6264
skipFeatureAutoMapping: boolean;
6365
skipPostAttach: boolean;
6466
experimentalImageMetadata: boolean;
67+
containerSessionDataFolder?: string;
6568
skipPersistingCustomizationsFromFeatures: boolean;
6669
}
6770

@@ -185,6 +188,12 @@ export interface ContainerProperties {
185188
launchRootShellServer?: () => Promise<ShellServer>;
186189
}
187190

191+
export interface DotfilesConfiguration {
192+
repository: string | undefined;
193+
installCommand: string | undefined;
194+
targetPath: string;
195+
}
196+
188197
export async function getContainerProperties(options: {
189198
params: ResolverParameters;
190199
createdAt: string | undefined;
@@ -347,6 +356,10 @@ export async function runPostCreateCommands(params: ResolverParameters, containe
347356
return 'skipNonBlocking';
348357
}
349358

359+
if (params.dotfilesConfiguration) {
360+
await installDotfiles(params, containerProperties, remoteEnv);
361+
}
362+
350363
if (stopForPersonalization) {
351364
return 'stopForPersonalization';
352365
}
@@ -513,7 +526,7 @@ async function createFile(shellServer: ShellServer, location: string) {
513526
}
514527
}
515528

516-
function createFileCommand(location: string) {
529+
export function createFileCommand(location: string) {
517530
return `test ! -f '${location}' && set -o noclobber && mkdir -p '${path.posix.dirname(location)}' && { > '${location}' ; } 2> /dev/null`;
518531
}
519532

@@ -655,22 +668,72 @@ async function patchEtcProfile(params: ResolverParameters, containerProperties:
655668
}
656669
}
657670

658-
async function probeUserEnv(params: { defaultUserEnvProbe: UserEnvProbe; allowSystemConfigChange: boolean; output: Log }, containerProperties: { shell: string; remoteExec: ExecFunction; installFolder?: string; env?: NodeJS.ProcessEnv; shellServer?: ShellServer; launchRootShellServer?: (() => Promise<ShellServer>); user?: string }, config?: CommonMergedDevContainerConfig) {
659-
const env = await runUserEnvProbe(params, containerProperties, config, 'cat /proc/self/environ', '\0');
671+
async function probeUserEnv(params: { defaultUserEnvProbe: UserEnvProbe; allowSystemConfigChange: boolean; output: Log; containerSessionDataFolder?: string }, containerProperties: { shell: string; remoteExec: ExecFunction; installFolder?: string; env?: NodeJS.ProcessEnv; shellServer?: ShellServer; launchRootShellServer?: (() => Promise<ShellServer>); user?: string }, config?: CommonMergedDevContainerConfig) {
672+
let userEnvProbe = getUserEnvProb(config, params);
673+
if (!userEnvProbe || userEnvProbe === 'none') {
674+
return {};
675+
}
676+
677+
let env = await readUserEnvFromCache(userEnvProbe, params, containerProperties.shellServer);
660678
if (env) {
661679
return env;
662680
}
663-
params.output.write('userEnvProbe: falling back to printenv');
664-
const env2 = await runUserEnvProbe(params, containerProperties, config, 'printenv', '\n');
665-
return env2 || {};
681+
682+
params.output.write('userEnvProbe: not found in cache');
683+
env = await runUserEnvProbe(userEnvProbe, params, containerProperties, 'cat /proc/self/environ', '\0');
684+
if (!env) {
685+
params.output.write('userEnvProbe: falling back to printenv');
686+
env = await runUserEnvProbe(userEnvProbe, params, containerProperties, 'printenv', '\n');
687+
}
688+
689+
if (env) {
690+
await updateUserEnvCache(env, userEnvProbe, params, containerProperties.shellServer);
691+
}
692+
693+
return env || {};
666694
}
667695

668-
async function runUserEnvProbe(params: { defaultUserEnvProbe: UserEnvProbe; allowSystemConfigChange: boolean; output: Log }, containerProperties: { shell: string; remoteExec: ExecFunction; installFolder?: string; env?: NodeJS.ProcessEnv; shellServer?: ShellServer; launchRootShellServer?: (() => Promise<ShellServer>); user?: string }, config: CommonMergedDevContainerConfig | undefined, cmd: string, sep: string) {
669-
let userEnvProbe = config?.userEnvProbe;
670-
params.output.write(`userEnvProbe: ${userEnvProbe || params.defaultUserEnvProbe}${userEnvProbe ? '' : ' (default)'}`);
671-
if (!userEnvProbe) {
672-
userEnvProbe = params.defaultUserEnvProbe;
696+
async function readUserEnvFromCache(userEnvProbe: UserEnvProbe, params: { output: Log; containerSessionDataFolder?: string }, shellServer?: ShellServer) {
697+
if (!shellServer || !params.containerSessionDataFolder) {
698+
return undefined;
699+
}
700+
701+
const cacheFile = getUserEnvCacheFilePath(userEnvProbe, params.containerSessionDataFolder);
702+
try {
703+
if (await isFile(shellServer, cacheFile)) {
704+
const { stdout } = await shellServer.exec(`cat '${cacheFile}'`);
705+
return JSON.parse(stdout);
706+
}
673707
}
708+
catch (e) {
709+
params.output.write(`Failed to read/parse user env cache: ${e}`, LogLevel.Error);
710+
}
711+
712+
return undefined;
713+
}
714+
715+
async function updateUserEnvCache(env: Record<string, string>, userEnvProbe: UserEnvProbe, params: { output: Log; containerSessionDataFolder?: string }, shellServer?: ShellServer) {
716+
if (!shellServer || !params.containerSessionDataFolder) {
717+
return;
718+
}
719+
720+
const cacheFile = getUserEnvCacheFilePath(userEnvProbe, params.containerSessionDataFolder);
721+
try {
722+
await shellServer.exec(`mkdir -p '${path.posix.dirname(cacheFile)}' && cat > '${cacheFile}' << 'envJSON'
723+
${JSON.stringify(env, null, '\t')}
724+
envJSON
725+
`);
726+
}
727+
catch (e) {
728+
params.output.write(`Failed to cache user env: ${e}`, LogLevel.Error);
729+
}
730+
}
731+
732+
function getUserEnvCacheFilePath(userEnvProbe: UserEnvProbe, cacheFolder: string): string {
733+
return path.posix.join(cacheFolder, `env-${userEnvProbe}.json`);
734+
}
735+
736+
async function runUserEnvProbe(userEnvProbe: UserEnvProbe, params: { allowSystemConfigChange: boolean; output: Log }, containerProperties: { shell: string; remoteExec: ExecFunction; installFolder?: string; env?: NodeJS.ProcessEnv; shellServer?: ShellServer; launchRootShellServer?: (() => Promise<ShellServer>); user?: string }, cmd: string, sep: string) {
674737
if (userEnvProbe === 'none') {
675738
return {};
676739
}
@@ -767,6 +830,15 @@ Merged: ${typeof env.PATH === 'string' ? `'${env.PATH}'` : 'None'}` : ''}`);
767830
}
768831
}
769832

833+
function getUserEnvProb(config: CommonMergedDevContainerConfig | undefined, params: { defaultUserEnvProbe: UserEnvProbe; allowSystemConfigChange: boolean; output: Log }) {
834+
let userEnvProbe = config?.userEnvProbe;
835+
params.output.write(`userEnvProbe: ${userEnvProbe || params.defaultUserEnvProbe}${userEnvProbe ? '' : ' (default)'}`);
836+
if (!userEnvProbe) {
837+
userEnvProbe = params.defaultUserEnvProbe;
838+
}
839+
return userEnvProbe;
840+
}
841+
770842
function mergePaths(shellPath: string, containerPath: string, rootUser: boolean) {
771843
const result = shellPath.split(':');
772844
let insertAt = 0;

src/spec-common/variableSubstitution.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import { URI } from 'vscode-uri';
1111

1212
export interface SubstitutionContext {
1313
platform: NodeJS.Platform;
14-
configFile: URI;
15-
localWorkspaceFolder: string | undefined;
16-
containerWorkspaceFolder: string | undefined;
14+
configFile?: URI;
15+
localWorkspaceFolder?: string;
16+
containerWorkspaceFolder?: string;
1717
env: NodeJS.ProcessEnv;
1818
}
1919

@@ -33,9 +33,9 @@ export function substitute<T extends object>(context: SubstitutionContext, value
3333
return substitute0(replace, value);
3434
}
3535

36-
export function beforeContainerSubstitute<T extends object>(idLabels: Record<string, string>, value: T): T {
36+
export function beforeContainerSubstitute<T extends object>(idLabels: Record<string, string> | undefined, value: T): T {
3737
let devcontainerId: string | undefined;
38-
return substitute0(replaceDevContainerId.bind(undefined, () => devcontainerId || (devcontainerId = devcontainerIdForLabels(idLabels))), value);
38+
return substitute0(replaceDevContainerId.bind(undefined, () => devcontainerId || (idLabels && (devcontainerId = devcontainerIdForLabels(idLabels)))), value);
3939
}
4040

4141
export function containerSubstitute<T extends object>(platform: NodeJS.Platform, configFile: URI | undefined, containerEnv: NodeJS.ProcessEnv, value: T): T {
@@ -124,10 +124,10 @@ function replaceContainerEnv(isWindows: boolean, configFile: URI | undefined, co
124124
}
125125
}
126126

127-
function replaceDevContainerId(getDevContainerId: () => string, match: string, variable: string) {
127+
function replaceDevContainerId(getDevContainerId: () => string | undefined, match: string, variable: string) {
128128
switch (variable) {
129129
case 'devcontainerId':
130-
return getDevContainerId();
130+
return getDevContainerId() || match;
131131

132132
default:
133133
return match;

src/spec-configuration/configuration.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ export interface DevContainerFeature {
3939
}
4040

4141
export interface DevContainerFromImageConfig {
42-
configFilePath: URI;
43-
image: string;
42+
configFilePath?: URI;
43+
image?: string; // Only optional when setting up an existing container as a dev container.
4444
name?: string;
4545
forwardPorts?: (number | string)[];
4646
appPort?: number | string | (number | string)[];

0 commit comments

Comments
 (0)