Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ build-tmp
output
*.testMarker
src/test/container-features/configs/temp_lifecycle-hooks-alternative-order
test-secrets-temp.json
5 changes: 3 additions & 2 deletions src/spec-common/dotfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,19 @@ const installCommands = [
'script/setup',
];

export async function installDotfiles(params: ResolverParameters, properties: ContainerProperties, dockerEnvP: Promise<Record<string, string>>) {
export async function installDotfiles(params: ResolverParameters, properties: ContainerProperties, dockerEnvP: Promise<Record<string, string>>, secretsP: Promise<Record<string, string>>) {
let { repository, installCommand, targetPath } = params.dotfilesConfiguration;
if (!repository) {
return;
}
const dockerEnv = await dockerEnvP;
const secrets = await secretsP;
if (repository.indexOf(':') === -1 && !/^\.{0,2}\//.test(repository)) {
repository = `https://github.com/${repository}.git`;
}
const shellServer = properties.shellServer;
const markerFile = getDotfilesMarkerFile(properties);
const env = Object.keys(dockerEnv)
const env = Object.keys({ ...dockerEnv, ...secrets })
.filter(key => !(key.startsWith('BASH_FUNC_') && key.endsWith('%%')))
.reduce((env, key) => `${env}${key}=${quoteValue(dockerEnv[key])} `, '');
try {
Expand Down
59 changes: 41 additions & 18 deletions src/spec-common/injectHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as path from 'path';
import * as fs from 'fs';
import { StringDecoder } from 'string_decoder';
import * as crypto from 'crypto';
import * as jsonc from 'jsonc-parser';

import { ContainerError, toErrorText, toWarningText } from './errors';
import { launch, ShellServer } from './shellServer';
Expand Down Expand Up @@ -65,6 +66,7 @@ export interface ResolverParameters {
containerSessionDataFolder?: string;
skipPersistingCustomizationsFromFeatures: boolean;
omitConfigRemotEnvFromMetadata?: boolean;
secretsFile?: string;
}

export interface LifecycleHook {
Expand Down Expand Up @@ -323,8 +325,9 @@ export async function setupInContainer(params: ResolverParameters, containerProp
const computeRemoteEnv = params.computeExtensionHostEnv || params.lifecycleHook.enabled;
const updatedConfig = containerSubstitute(params.cliHost.platform, config.configFilePath, containerProperties.env, config);
const remoteEnv = computeRemoteEnv ? probeRemoteEnv(params, containerProperties, updatedConfig) : Promise.resolve({});
const secrets = readSecretsFromFile(params);
if (params.lifecycleHook.enabled) {
await runLifecycleHooks(params, lifecycleCommandOriginMap, containerProperties, updatedConfig, remoteEnv, false);
await runLifecycleHooks(params, lifecycleCommandOriginMap, containerProperties, updatedConfig, remoteEnv, secrets, false);
}
return {
remoteEnv: params.computeExtensionHostEnv ? await remoteEnv : {},
Expand All @@ -340,7 +343,26 @@ export function probeRemoteEnv(params: ResolverParameters, containerProperties:
} as Record<string, string>));
}

export async function runLifecycleHooks(params: ResolverParameters, lifecycleHooksInstallMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, remoteEnv: Promise<Record<string, string>>, stopForPersonalization: boolean): Promise<'skipNonBlocking' | 'prebuild' | 'stopForPersonalization' | 'done'> {
export async function readSecretsFromFile(params: { output: Log; secretsFile?: string; cliHost: CLIHost }) {
const { secretsFile, cliHost } = params;
if (!secretsFile) {
return {};
}

try {
const fileBuff = await cliHost.readFile(secretsFile);
return jsonc.parse(fileBuff.toString()) as Record<string, string>;
}
catch (e) {
params.output.write(`Failed to read/parse secrets from file '${secretsFile}'`, LogLevel.Error);
throw new ContainerError({
description: 'Failed to read/parse secrets',
originalError: e
});
}
}

export async function runLifecycleHooks(params: ResolverParameters, lifecycleHooksInstallMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, remoteEnv: Promise<Record<string, string>>, secrets: Promise<Record<string, string>>, stopForPersonalization: boolean): Promise<'skipNonBlocking' | 'prebuild' | 'stopForPersonalization' | 'done'> {
const skipNonBlocking = params.lifecycleHook.skipNonBlocking;
const waitFor = config.waitFor || defaultWaitFor;
if (skipNonBlocking && waitFor === 'initializeCommand') {
Expand All @@ -349,12 +371,12 @@ export async function runLifecycleHooks(params: ResolverParameters, lifecycleHoo

Comment thread
joshspicer marked this conversation as resolved.
params.output.write('LifecycleCommandExecutionMap: ' + JSON.stringify(lifecycleHooksInstallMap, undefined, 4), LogLevel.Trace);

await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'onCreateCommand', remoteEnv, false);
Comment thread
eljog marked this conversation as resolved.
await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'onCreateCommand', remoteEnv, secrets, false);
if (skipNonBlocking && waitFor === 'onCreateCommand') {
return 'skipNonBlocking';
}

await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'updateContentCommand', remoteEnv, !!params.prebuild);
await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'updateContentCommand', remoteEnv, secrets, !!params.prebuild);
if (skipNonBlocking && waitFor === 'updateContentCommand') {
return 'skipNonBlocking';
}
Expand All @@ -363,26 +385,26 @@ export async function runLifecycleHooks(params: ResolverParameters, lifecycleHoo
return 'prebuild';
}

await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'postCreateCommand', remoteEnv, false);
await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'postCreateCommand', remoteEnv, secrets, false);
if (skipNonBlocking && waitFor === 'postCreateCommand') {
return 'skipNonBlocking';
}

if (params.dotfilesConfiguration) {
await installDotfiles(params, containerProperties, remoteEnv);
await installDotfiles(params, containerProperties, remoteEnv, secrets);
}

if (stopForPersonalization) {
return 'stopForPersonalization';
}

await runPostStartCommand(params, lifecycleHooksInstallMap, containerProperties, remoteEnv);
await runPostStartCommand(params, lifecycleHooksInstallMap, containerProperties, remoteEnv, secrets);
if (skipNonBlocking && waitFor === 'postStartCommand') {
return 'skipNonBlocking';
}

if (!params.skipPostAttach) {
await runPostAttachCommand(params, lifecycleHooksInstallMap, containerProperties, remoteEnv);
await runPostAttachCommand(params, lifecycleHooksInstallMap, containerProperties, remoteEnv, secrets);
}
return 'done';
}
Expand All @@ -403,16 +425,16 @@ export async function getOSRelease(shellServer: ShellServer) {
return { hardware, id, version };
}

async function runPostCreateCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, postCommandName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand', remoteEnv: Promise<Record<string, string>>, rerun: boolean) {
async function runPostCreateCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, postCommandName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand', remoteEnv: Promise<Record<string, string>>, secrets: Promise<Record<string, string>>, rerun: boolean) {
const markerFile = path.posix.join(containerProperties.userDataFolder, `.${postCommandName}Marker`);
const doRun = !!containerProperties.createdAt && await updateMarkerFile(containerProperties.shellServer, markerFile, containerProperties.createdAt) || rerun;
await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, postCommandName, remoteEnv, doRun);
await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, postCommandName, remoteEnv, secrets, doRun);
}

async function runPostStartCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise<Record<string, string>>) {
async function runPostStartCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise<Record<string, string>>, secrets: Promise<Record<string, string>>) {
const markerFile = path.posix.join(containerProperties.userDataFolder, '.postStartCommandMarker');
const doRun = !!containerProperties.startedAt && await updateMarkerFile(containerProperties.shellServer, markerFile, containerProperties.startedAt);
await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, 'postStartCommand', remoteEnv, doRun);
await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, 'postStartCommand', remoteEnv, secrets, doRun);
}

async function updateMarkerFile(shellServer: ShellServer, location: string, content: string) {
Expand All @@ -424,24 +446,24 @@ async function updateMarkerFile(shellServer: ShellServer, location: string, cont
}
}

async function runPostAttachCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise<Record<string, string>>) {
await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, 'postAttachCommand', remoteEnv, true);
async function runPostAttachCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise<Record<string, string>>, secrets: Promise<Record<string, string>>) {
await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, 'postAttachCommand', remoteEnv, secrets, true);
}


async function runLifecycleCommands(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise<Record<string, string>>, doRun: boolean) {
async function runLifecycleCommands(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise<Record<string, string>>, secrets: Promise<Record<string, string>>, doRun: boolean) {
const commandsForHook = lifecycleCommandOriginMap[lifecycleHookName];
if (commandsForHook.length === 0) {
return;
}

for (const { command, origin } of commandsForHook) {
const displayOrigin = origin ? (origin === 'devcontainer.json' ? origin : `Feature '${origin}'`) : '???'; /// '???' should never happen.
await runLifecycleCommand(params, containerProperties, command, displayOrigin, lifecycleHookName, remoteEnv, doRun);
await runLifecycleCommand(params, containerProperties, command, displayOrigin, lifecycleHookName, remoteEnv, secrets, doRun);
}
}

async function runLifecycleCommand({ lifecycleHook: postCreate }: ResolverParameters, containerProperties: ContainerProperties, userCommand: LifecycleCommand, userCommandOrigin: string, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise<Record<string, string>>, doRun: boolean) {
async function runLifecycleCommand({ lifecycleHook: postCreate }: ResolverParameters, containerProperties: ContainerProperties, userCommand: LifecycleCommand, userCommandOrigin: string, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise<Record<string, string>>, secrets: Promise<Record<string, string>>, doRun: boolean) {
let hasCommand = false;
if (typeof userCommand === 'string') {
hasCommand = userCommand.trim().length > 0;
Expand Down Expand Up @@ -492,7 +514,8 @@ async function runLifecycleCommand({ lifecycleHook: postCreate }: ResolverParame
// 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 { cmdOutput } = await runRemoteCommand({ ...postCreate, output: infoOutput }, containerProperties, typeof postCommand === 'string' ? ['/bin/sh', '-c', postCommand] : postCommand, remoteCwd, { remoteEnv: await remoteEnv, pty: true, print: printMode });
const env = { ...(await remoteEnv), ...(await secrets) };
const { cmdOutput } = await runRemoteCommand({ ...postCreate, 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) {
Expand Down
4 changes: 3 additions & 1 deletion src/spec-node/devContainers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface ProvisionOptions {
installCommand?: string;
targetPath?: string;
};
secretsFile?: string;
experimentalLockfile?: boolean;
experimentalFrozenLockfile?: boolean;
}
Expand Down Expand Up @@ -92,7 +93,7 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string
}

export async function createDockerParams(options: ProvisionOptions, disposables: (() => Promise<unknown> | undefined)[]): Promise<DockerResolverParameters> {
const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, mountWorkspaceGitRoot, remoteEnv, experimentalLockfile, experimentalFrozenLockfile } = options;
const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, mountWorkspaceGitRoot, remoteEnv, secretsFile, experimentalLockfile, experimentalFrozenLockfile } = options;
let parsedAuthority: DevContainerAuthority | undefined;
if (options.workspaceFolder) {
parsedAuthority = { hostPath: options.workspaceFolder } as DevContainerAuthority;
Expand Down Expand Up @@ -133,6 +134,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables:
backgroundTasks: [],
persistedFolder: persistedFolder || await getCacheFolder(cliHost), // Fallback to tmp folder, even though that isn't 'persistent'
remoteEnv,
secretsFile,
buildxPlatform: options.buildxPlatform,
buildxPush: options.buildxPush,
buildxOutput: options.buildxOutput,
Expand Down
13 changes: 10 additions & 3 deletions src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { SubstitutedConfig, createContainerProperties, createFeaturesTempFolder,
import { URI } from 'vscode-uri';
import { ContainerError } from '../spec-common/errors';
import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log';
import { probeRemoteEnv, runLifecycleHooks, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless';
import { probeRemoteEnv, runLifecycleHooks, runRemoteCommand, UserEnvProbe, setupInContainer, readSecretsFromFile } from '../spec-common/injectHeadless';
import { extendImage } from './containerFeatures';
import { DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils';
import { buildAndExtendDockerCompose, dockerComposeCLIConfig, getDefaultImageName, getProjectName, readDockerComposeConfig, readVersionPrefix } from './dockerCompose';
Expand Down Expand Up @@ -118,6 +118,7 @@ function provisionOptions(y: Argv) {
'dotfiles-target-path': { type: 'string', default: '~/dotfiles', description: 'The path to clone the dotfiles repository to. Defaults to `~/dotfiles`.' },
'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProbe results' },
'omit-config-remote-env-from-metadata': { type: 'boolean', default: false, hidden: true, description: 'Omit remoteEnv from devcontainer.json for container metadata label' },
'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' },
'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' },
'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' },
})
Expand Down Expand Up @@ -186,6 +187,7 @@ async function provision({
'dotfiles-target-path': dotfilesTargetPath,
'container-session-data-folder': containerSessionDataFolder,
'omit-config-remote-env-from-metadata': omitConfigRemotEnvFromMetadata,
'secrets-file': secretsFile,
'experimental-lockfile': experimentalLockfile,
'experimental-frozen-lockfile': experimentalFrozenLockfile,
}: ProvisionArgs) {
Expand Down Expand Up @@ -233,6 +235,7 @@ async function provision({
},
updateRemoteUserUIDDefault,
remoteEnv: envListToObj(addRemoteEnvs),
secretsFile,
additionalCacheFroms: addCacheFroms,
useBuildKit: buildkit,
buildxPlatform: undefined,
Expand Down Expand Up @@ -699,6 +702,7 @@ function runUserCommandsOptions(y: Argv) {
'dotfiles-install-command': { type: 'string', description: 'The command to run after cloning the dotfiles repository. Defaults to run the first file of `install.sh`, `install`, `bootstrap.sh`, `bootstrap`, `setup.sh` and `setup` found in the dotfiles repository`s root folder.' },
'dotfiles-target-path': { type: 'string', default: '~/dotfiles', description: 'The path to clone the dotfiles repository to. Defaults to `~/dotfiles`.' },
'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProbe results' },
'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' },
})
.check(argv => {
const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined;
Expand Down Expand Up @@ -756,6 +760,7 @@ async function doRunUserCommands({
'dotfiles-install-command': dotfilesInstallCommand,
'dotfiles-target-path': dotfilesTargetPath,
'container-session-data-folder': containerSessionDataFolder,
'secrets-file': secretsFile,
}: RunUserCommandsArgs) {
const disposables: (() => Promise<unknown> | undefined)[] = [];
const dispose = async () => {
Expand Down Expand Up @@ -805,6 +810,7 @@ async function doRunUserCommands({
targetPath: dotfilesTargetPath,
},
containerSessionDataFolder,
secretsFile
}, disposables);

const { common } = params;
Expand Down Expand Up @@ -837,8 +843,9 @@ async function doRunUserCommands({
const mergedConfig = mergeConfiguration(config.config, imageMetadata);
const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser);
const updatedConfig = containerSubstitute(cliHost.platform, config.config.configFilePath, containerProperties.env, mergedConfig);
const remoteEnv = probeRemoteEnv(common, containerProperties, updatedConfig);
const result = await runLifecycleHooks(common, lifecycleCommandOriginMapFromMetadata(imageMetadata), containerProperties, updatedConfig, remoteEnv, stopForPersonalization);
const remoteEnvP = probeRemoteEnv(common, containerProperties, updatedConfig);
const secretsP = readSecretsFromFile(common);
const result = await runLifecycleHooks(common, lifecycleCommandOriginMapFromMetadata(imageMetadata), containerProperties, updatedConfig, remoteEnvP, secretsP, stopForPersonalization);
return {
outcome: 'success' as 'success',
result,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ sleep 1s
[[ -f saved_value.testMarker ]] || echo 0 > saved_value.testMarker
n=$(< saved_value.testMarker)
echo "${n}.`date +%s%3N`" > "${n}.${MARKER_FILE_NAME}"
printenv >> "${n}.${MARKER_FILE_NAME}"
echo $(( n + 1 )) > saved_value.testMarker

echo "Ending '${MARKER_FILE_NAME}'...."
Loading