diff --git a/.gitignore b/.gitignore index 2c69dd1c6..7b270dea1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ build-tmp output *.testMarker src/test/container-features/configs/temp_lifecycle-hooks-alternative-order +test-secrets-temp.json diff --git a/src/spec-common/dotfiles.ts b/src/spec-common/dotfiles.ts index 18f40c939..524ff133b 100644 --- a/src/spec-common/dotfiles.ts +++ b/src/spec-common/dotfiles.ts @@ -19,18 +19,19 @@ const installCommands = [ 'script/setup', ]; -export async function installDotfiles(params: ResolverParameters, properties: ContainerProperties, dockerEnvP: Promise>) { +export async function installDotfiles(params: ResolverParameters, properties: ContainerProperties, dockerEnvP: Promise>, secretsP: Promise>) { 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 { diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index bad4fc180..d11bb5acd 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -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'; @@ -65,6 +66,7 @@ export interface ResolverParameters { containerSessionDataFolder?: string; skipPersistingCustomizationsFromFeatures: boolean; omitConfigRemotEnvFromMetadata?: boolean; + secretsFile?: string; } export interface LifecycleHook { @@ -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 : {}, @@ -340,7 +343,26 @@ export function probeRemoteEnv(params: ResolverParameters, containerProperties: } as Record)); } -export async function runLifecycleHooks(params: ResolverParameters, lifecycleHooksInstallMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, remoteEnv: Promise>, 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; + } + 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>, secrets: Promise>, stopForPersonalization: boolean): Promise<'skipNonBlocking' | 'prebuild' | 'stopForPersonalization' | 'done'> { const skipNonBlocking = params.lifecycleHook.skipNonBlocking; const waitFor = config.waitFor || defaultWaitFor; if (skipNonBlocking && waitFor === 'initializeCommand') { @@ -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'; } @@ -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'; } @@ -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>, rerun: boolean) { +async function runPostCreateCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, postCommandName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand', remoteEnv: Promise>, secrets: Promise>, 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>) { +async function runPostStartCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise>, secrets: Promise>) { 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) { @@ -424,12 +446,12 @@ async function updateMarkerFile(shellServer: ShellServer, location: string, cont } } -async function runPostAttachCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise>) { - await runLifecycleCommands(params, lifecycleCommandOriginMap, containerProperties, 'postAttachCommand', remoteEnv, true); +async function runPostAttachCommand(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, remoteEnv: Promise>, secrets: Promise>) { + 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>, doRun: boolean) { +async function runLifecycleCommands(params: ResolverParameters, lifecycleCommandOriginMap: LifecycleHooksInstallMap, containerProperties: ContainerProperties, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise>, secrets: Promise>, doRun: boolean) { const commandsForHook = lifecycleCommandOriginMap[lifecycleHookName]; if (commandsForHook.length === 0) { return; @@ -437,11 +459,11 @@ async function runLifecycleCommands(params: ResolverParameters, lifecycleCommand 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>, doRun: boolean) { +async function runLifecycleCommand({ lifecycleHook: postCreate }: ResolverParameters, containerProperties: ContainerProperties, userCommand: LifecycleCommand, userCommandOrigin: string, lifecycleHookName: 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand', remoteEnv: Promise>, secrets: Promise>, doRun: boolean) { let hasCommand = false; if (typeof userCommand === 'string') { hasCommand = userCommand.trim().length > 0; @@ -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) { diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 9253be7e7..161338610 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -63,6 +63,7 @@ export interface ProvisionOptions { installCommand?: string; targetPath?: string; }; + secretsFile?: string; experimentalLockfile?: boolean; experimentalFrozenLockfile?: boolean; } @@ -92,7 +93,7 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string } export async function createDockerParams(options: ProvisionOptions, disposables: (() => Promise | undefined)[]): Promise { - 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; @@ -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, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index e29ea7163..efe93101e 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -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'; @@ -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' }, }) @@ -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) { @@ -233,6 +235,7 @@ async function provision({ }, updateRemoteUserUIDDefault, remoteEnv: envListToObj(addRemoteEnvs), + secretsFile, additionalCacheFroms: addCacheFroms, useBuildKit: buildkit, buildxPlatform: undefined, @@ -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; @@ -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 | undefined)[] = []; const dispose = async () => { @@ -805,6 +810,7 @@ async function doRunUserCommands({ targetPath: dotfilesTargetPath, }, containerSessionDataFolder, + secretsFile }, disposables); const { common } = params; @@ -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, diff --git a/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/createMarker.sh b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/createMarker.sh index d6533e732..14158d6ea 100755 --- a/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/createMarker.sh +++ b/src/test/container-features/configs/lifecycle-hooks-inline-commands/.devcontainer/createMarker.sh @@ -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}'...." \ No newline at end of file diff --git a/src/test/container-features/lifecycleHooks.test.ts b/src/test/container-features/lifecycleHooks.test.ts index ffd771924..9986700bd 100644 --- a/src/test/container-features/lifecycleHooks.test.ts +++ b/src/test/container-features/lifecycleHooks.test.ts @@ -97,6 +97,176 @@ describe('Feature lifecycle hooks', function () { }); }); + describe('lifecycle-hooks-inline-commands with secrets', () => { + const testFolder = `${__dirname}/configs/lifecycle-hooks-inline-commands`; + + describe('devcontainer up with secrets', () => { + let containerId: string | null = null; + + before(async () => { + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + const secrets = { + 'SECRET1': 'SecretValue1', + }; + await shellExec(`printf '${JSON.stringify(secrets)}' > ${testFolder}/test-secrets-temp.json`, undefined, undefined, true); + containerId = (await devContainerUp(cli, testFolder, { 'logLevel': 'trace', extraArgs: `--secrets-file ${testFolder}/test-secrets-temp.json` })).containerId; + }); + + after(async () => { + await devContainerDown({ containerId }); + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + await shellExec(`rm -f ${testFolder}/test-secrets-temp.json`, undefined, undefined, true); + }); + + it('secrets should be availale to the lifecycle hooks during up command', async () => { + { + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} ls -altr`); + assert.strictEqual(res.error, null); + + const actualMarkerFiles = res.stdout; + console.log(actualMarkerFiles); + + const expectedTestMarkerFiles = [ + '0.panda.onCreateCommand.testMarker', + '3.panda.updateContentCommand.testMarker', + '6.panda.postCreateCommand.testMarker', + '9.panda.postStartCommand.testMarker', + '12.panda.postAttachCommand.testMarker', + + '1.tiger.onCreateCommand.testMarker', + '4.tiger.updateContentCommand.testMarker', + '7.tiger.postCreateCommand.testMarker', + '10.tiger.postStartCommand.testMarker', + '13.tiger.postAttachCommand.testMarker', + + '2.devContainer.onCreateCommand.testMarker', + '5.devContainer.updateContentCommand.testMarker', + '8.devContainer.postCreateCommand.testMarker', + '11.devContainer.postStartCommand.testMarker', + '14.devContainer.postAttachCommand.testMarker', + ]; + + for (const file of expectedTestMarkerFiles) { + assert.match(actualMarkerFiles, new RegExp(file)); + + // assert file contents to ensure secrets were available to the command + const catResp = await shellExec(`${cli} exec --workspace-folder ${testFolder} cat ${file}`); + assert.strictEqual(catResp.error, null); + assert.match(catResp.stdout, /SECRET1=SecretValue1/); + } + + // This shouldn't have happened _yet_. + assert.notMatch(actualMarkerFiles, /15.panda.postStartCommand.testMarker/); + } + + // Stop the container. + await devContainerStop({ containerId }); + + { + // Attempt to bring the same container up, which should just re-run the postStart and postAttach hooks + const resume = await devContainerUp(cli, testFolder, { logLevel: 'trace', extraArgs: `--secrets-file ${testFolder}/test-secrets-temp.json` }); + assert.equal(resume.containerId, containerId); // Restarting the same container. + assert.equal(resume.outcome, 'success'); + + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} ls -altr`); + assert.strictEqual(res.error, null); + + const actualMarkerFiles = res.stdout; + console.log(actualMarkerFiles); + + const expectedTestMarkerFiles = [ + '15.panda.postStartCommand.testMarker', + '16.tiger.postStartCommand.testMarker', + '17.devContainer.postStartCommand.testMarker', + '18.panda.postAttachCommand.testMarker', + '19.tiger.postAttachCommand.testMarker', + '20.devContainer.postAttachCommand.testMarker', + ]; + + for (const file of expectedTestMarkerFiles) { + assert.match(actualMarkerFiles, new RegExp(file)); + + // assert file contents to ensure secrets were available to the command + const catResp = await shellExec(`${cli} exec --workspace-folder ${testFolder} cat ${file}`); + assert.strictEqual(catResp.error, null); + assert.match(catResp.stdout, /SECRET1=SecretValue1/); + } + } + }); + }); + + describe('devcontainer run-user-commands with secrets', () => { + let containerId: string | null = null; + + before(async () => { + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + const secrets = { + 'SECRET1': 'SecretValue1', + }; + await shellExec(`printf '${JSON.stringify(secrets)}' > ${testFolder}/test-secrets-temp.json`, undefined, undefined, true); + containerId = (await devContainerUp(cli, testFolder, { 'logLevel': 'trace', extraArgs: '--skip-post-create' })).containerId; + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + }); + + after(async () => { + await devContainerDown({ containerId }); + await shellExec(`rm -f ${testFolder}/*.testMarker`, undefined, undefined, true); + await shellExec(`rm -f ${testFolder}/test-secrets-temp.json`, undefined, undefined, true); + }); + + it('secrets should be availale to the lifecycle hooks during run-user-commands command', async () => { + { + const expectedTestMarkerFiles = [ + '0.panda.onCreateCommand.testMarker', + '3.panda.updateContentCommand.testMarker', + '6.panda.postCreateCommand.testMarker', + '9.panda.postStartCommand.testMarker', + '12.panda.postAttachCommand.testMarker', + + '1.tiger.onCreateCommand.testMarker', + '4.tiger.updateContentCommand.testMarker', + '7.tiger.postCreateCommand.testMarker', + '10.tiger.postStartCommand.testMarker', + '13.tiger.postAttachCommand.testMarker', + + '2.devContainer.onCreateCommand.testMarker', + '5.devContainer.updateContentCommand.testMarker', + '8.devContainer.postCreateCommand.testMarker', + '11.devContainer.postStartCommand.testMarker', + '14.devContainer.postAttachCommand.testMarker', + ]; + + // Marker files should not exist, as we are yet to run the `run-user-commands` command + const lsResBefore = await shellExec(`${cli} exec --workspace-folder ${testFolder} ls -altr`); + assert.strictEqual(lsResBefore.error, null); + const actualMarkerFilesBefore = lsResBefore.stdout; + console.log(actualMarkerFilesBefore); + for (const file of expectedTestMarkerFiles) { + assert.notMatch(actualMarkerFilesBefore, new RegExp(file)); + } + + // Run `run-user-commands` command with secrets + const res = await shellExec(`${cli} run-user-commands --workspace-folder ${testFolder} --log-level trace --secrets-file ${testFolder}/test-secrets-temp.json`); + assert.strictEqual(res.error, null); + + // Assert marker files + const lsResAfter = await shellExec(`${cli} exec --workspace-folder ${testFolder} ls -altr`); + assert.strictEqual(lsResAfter.error, null); + const actualMarkerFilesAfter = lsResAfter.stdout; + console.log(actualMarkerFilesAfter); + for (const file of expectedTestMarkerFiles) { + assert.match(actualMarkerFilesAfter, new RegExp(file)); + + // assert file contents to ensure secrets were available to the command + const catResp = await shellExec(`${cli} exec --workspace-folder ${testFolder} cat ${file}`); + assert.strictEqual(catResp.error, null); + assert.match(catResp.stdout, /SECRET1=SecretValue1/); + } + } + }); + }); + }); + describe('lifecycle-hooks-alternative-order', () => { // This is the same test as 'lifecycle-hooks-inline-commands' // but with the the 'installsAfter' order changed (tiger -> panda -> devContainer).