Skip to content

Commit

Permalink
Merge pull request #493 from devcontainers/eljog/secrets-support-1
Browse files Browse the repository at this point in the history
Secret support for `run-user-commands`& `up` commands
  • Loading branch information
eljog authored Apr 26, 2023
2 parents 6b5d0fc + 283b4bd commit 86604a8
Show file tree
Hide file tree
Showing 7 changed files with 229 additions and 24 deletions.
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

params.output.write('LifecycleCommandExecutionMap: ' + JSON.stringify(lifecycleHooksInstallMap, undefined, 4), LogLevel.Trace);

await runPostCreateCommand(params, lifecycleHooksInstallMap, containerProperties, 'onCreateCommand', remoteEnv, false);
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

0 comments on commit 86604a8

Please sign in to comment.