From 4a6bb5c649daa0ed3f02443fd48477dc769c5c8d Mon Sep 17 00:00:00 2001 From: Deyaaeldeen Almahallawi Date: Fri, 19 Feb 2021 18:32:41 -0500 Subject: [PATCH] [Dev tool] Add check-node-versions command (#13828) * [Dev tool] Add checkNodesVers command * address feedback * rename keep-docker-image to plural * rename a couple more flags to plural * fix --- common/tools/dev-tool/README.md | 1 + .../src/commands/samples/checkNodeVersions.ts | 306 ++++++++++++++++++ .../dev-tool/src/commands/samples/index.ts | 3 +- 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 common/tools/dev-tool/src/commands/samples/checkNodeVersions.ts diff --git a/common/tools/dev-tool/README.md b/common/tools/dev-tool/README.md index a64d9d8516c..3f9b7b78361 100644 --- a/common/tools/dev-tool/README.md +++ b/common/tools/dev-tool/README.md @@ -20,6 +20,7 @@ It provides a place to centralize scripts, resources, and processes for developm - `dev` (link samples to local sources for access to IntelliSense during development) - `prep` (prepare samples for local source-linked execution) - `run` (execute a sample or all samples within a directory) + - `check-node-versions` (execute samples with different node versions, typically in preparation for release) The `dev-tool about` command will print some information about how to use the command. All commands additionally accept the `--help` argument, which will print information about the usage of that specific command. For example, to show help information for the `resolve` command above, issue the command `dev-tool package resolve --help`. diff --git a/common/tools/dev-tool/src/commands/samples/checkNodeVersions.ts b/common/tools/dev-tool/src/commands/samples/checkNodeVersions.ts new file mode 100644 index 00000000000..7ccd5caec13 --- /dev/null +++ b/common/tools/dev-tool/src/commands/samples/checkNodeVersions.ts @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import fs from "fs-extra"; +import path from "path"; +import pr from "child_process"; +import os from "os"; + +import { createPrinter } from "../../util/printer"; +import { leafCommand, makeCommandInfo } from "../../framework/command"; +import { S_IRWXO } from "constants"; +import { resolveProject } from "../../util/resolveProject"; + +const log = createPrinter("check-node-versions-samples"); + +async function spawnCMD(cmd: string, args: string[], errorMessage?: string): Promise { + const spawnedProcess = pr.spawn(cmd, args); + await new Promise((resolve, reject) => { + spawnedProcess.on("exit", resolve); + spawnedProcess.on("error", (err: Error) => { + log.info(errorMessage); + reject(err); + }); + }); +} + +async function deleteDockerContainers(deleteContainerNames?: string[]): Promise { + if (deleteContainerNames) { + log.info(`Cleanup: deleting ${deleteContainerNames.join(", ")} docker containers`); + await spawnCMD( + "docker", + ["rm", ...deleteContainerNames, "-f"], + `Attempted to delete ${deleteContainerNames.join( + ", " + )} docker containers but encountered an error doing so` + ); + } +} + +async function deleteDockerImages(dockerImageNames?: string[]) { + if (dockerImageNames) { + log.info(`Cleanup: deleting ${dockerImageNames.join(", ")} docker images`); + await spawnCMD( + "docker", + ["rmi", ...dockerImageNames, "-f"], + `Attempted to delete the ${dockerImageNames.join( + ", " + )} docker images but encountered an error doing so` + ); + } +} + +async function deleteDockerContext(dockerContextDirectory?: string) { + if (dockerContextDirectory) { + log.info(`Cleanup: deleting the ${dockerContextDirectory} docker context directory`); + await spawnCMD("rm", ["-rf", dockerContextDirectory], undefined); + } +} + +async function cleanup( + dockerContextDirectory?: string, + dockerContainerNames?: string[], + dockerImageNames?: string[] +) { + await deleteDockerContext(dockerContextDirectory); + await deleteDockerContainers(dockerContainerNames); + await deleteDockerImages(dockerImageNames); +} + +function buildRunSamplesScript( + containerWorkspacePath: string, + artifactName: string, + envFileName: string, + logFilePath?: string +) { + function compileCMD(cmd: string, printToScreen?: boolean) { + return printToScreen ? cmd : `${cmd} >> ${logFilePath} 2>&1`; + } + const printToScreen = logFilePath === undefined; + const artifactPath = `${containerWorkspacePath}/${artifactName}`; + const envFilePath = `${containerWorkspacePath}/${envFileName}`; + const javascriptSamplesPath = `${containerWorkspacePath}/samples/javascript`; + const typescriptCompiledSamplesPath = `${containerWorkspacePath}/samples/typescript/dist`; + const scriptContent = `#!/bin/sh + +function install_dependencies_helper() { + local samples_path=\$1; + cd \${samples_path}; + ${compileCMD(`npm install ${artifactPath}`, printToScreen)} + ${compileCMD(`npm install`, printToScreen)} +} + +function install_packages() { + echo "Using node \$(node -v) to install dependencies"; + install_dependencies_helper ${containerWorkspacePath}/samples/javascript + install_dependencies_helper ${containerWorkspacePath}/samples/typescript; + cp ${envFilePath} ${containerWorkspacePath}/samples/javascript/; +} + +function run_samples() { + samples_path=\$1; + echo "Using node \$(node -v) to run samples in \${samples_path}"; + cd "\${samples_path}"; + for SAMPLE in *.js; do + node \${SAMPLE}; + done +} + +function build_typescript() { + echo "Using node \$(node -v) to build the typescript samples"; + cd ${containerWorkspacePath}/samples/typescript + ${compileCMD(`npm run build`, printToScreen)} + cp ${envFilePath} ${containerWorkspacePath}/samples/typescript/dist/ +} + +function main() { + install_packages; + run_samples "${javascriptSamplesPath}"; + build_typescript && run_samples "${typescriptCompiledSamplesPath}"; +} + +main`; + return scriptContent; +} + +function createDockerContextDirectory( + dockerContextDirectory: string, + containerWorkspacePath: string, + samples_path: string, + envPath: string, + artifactPath?: string, + logFilePath?: string +): void { + if (artifactPath === undefined) { + throw new Error("artifact_path is a required argument but it was not passed"); + } else if (!fs.existsSync(artifactPath)) { + throw new Error(`artifact path passed does not exist: ${artifactPath}`); + } + const artifactName = path.basename(artifactPath); + const envFileName = path.basename(envPath); + fs.copySync(samples_path, path.join(dockerContextDirectory, "samples")); + fs.copyFileSync(artifactPath, path.join(dockerContextDirectory, artifactName)); + fs.copyFileSync(envPath, path.join(dockerContextDirectory, envFileName)); + fs.writeFileSync( + path.join(dockerContextDirectory, "run_samples.sh"), + buildRunSamplesScript(containerWorkspacePath, artifactName, envFileName, logFilePath), + { mode: S_IRWXO } + ); +} + +async function runDockerContainer( + dockerContextDirectory: string, + dockerImageName: string, + dockerContainerName: string, + containerWorkspace: string, + stdoutListener: (chunk: string | Buffer) => void, + stderrListener: (chunk: string | Buffer) => void +): Promise { + const args = [ + "run", + "--name", + dockerContainerName, + "--workdir", + containerWorkspace, + "-v", + `${dockerContextDirectory}:${containerWorkspace}`, + dockerImageName, + "./run_samples.sh" + ]; + const dockerContainerRunProcess = pr.spawn("docker", args, { + cwd: dockerContextDirectory + }); + log.info(`Started running the docker container ${dockerContainerName}`); + dockerContainerRunProcess.stdout.on("data", stdoutListener); + dockerContainerRunProcess.stderr.on("data", stderrListener); + const exitCode = await new Promise((resolve, reject) => { + dockerContainerRunProcess.on("exit", resolve); + dockerContainerRunProcess.on("error", reject); + }); + if (exitCode === 0) { + log.info(`Docker container ${dockerContainerName} finished running`); + } else { + log.error(`Docker container ${dockerContainerName} encountered an error`); + } +} + +export const commandInfo = makeCommandInfo( + "check-node-versions", + "execute samples with different node versions, typically in preparation for release", + { + "artifact-path": { + kind: "string", + description: "Path to the downloaded artifact built by the release pipeline" + }, + directory: { + kind: "string", + description: "Base dir, default is process.cwd()", + default: process.cwd() + }, + "node-versions": { + kind: "string", + description: "A comma separated list of node versions to use", + default: "8,10,12" + }, + "context-directory-path": { + kind: "string", + description: "Absolute path to a directory used for mounting inside docker containers", + default: "" + }, + "keep-docker-context": { + kind: "boolean", + description: "Boolean to indicate whether to keep the current docker context directory", + default: false + }, + "log-in-file": { + kind: "boolean", + description: + "Boolean to indicate whether to save the the stdout and sterr for npm commands to the log.txt log file", + default: true + }, + "use-existing-docker-containers": { + kind: "boolean", + description: "Boolean to indicate whether to use existing docker containers if any", + default: false + }, + "keep-docker-containers": { + kind: "boolean", + description: "Boolean to indicate whether to keep docker containers", + default: false + }, + "keep-docker-images": { + kind: "boolean", + description: "Boolean to indicate whether to keep the downloaded docker images", + default: false + } + } +); + +export default leafCommand(commandInfo, async (options) => { + const nodeVersions = options["node-versions"]?.split(","); + const dockerContextDirectory: string = + options["context-directory-path"] === "" + ? await fs.mkdtemp(path.join(os.tmpdir(), "context")) + : options["context-directory-path"]; + const pkg = await resolveProject(options.directory); + const samplesPath = path.join(pkg.path, "samples"); + const envFilePath = path.join(pkg.path, ".env"); + const keepDockerContextDirectory = options["keep-docker-context"]; + const dockerImageNames = nodeVersions.map((version: string) => `node:${version}-alpine`); + const dockerContainerNames = nodeVersions.map((version: string) => `${version}-container`); + const containerWorkspace = "/workspace"; + const containerLogFilePath = options["log-in-file"] ? `${containerWorkspace}/log.txt` : undefined; + const useExistingDockerContainer = options["use-existing-docker-containers"]; + const keepDockerContainers = options["keep-docker-containers"]; + const keepDockerImages = options["keep-docker-images"]; + const stdoutListener = (chunk: Buffer | string) => log.info(chunk.toString()); + const stderrListener = (chunk: Buffer | string) => log.error(chunk.toString()); + async function cleanupBefore(): Promise { + const dockerContextDirectoryChildren = await fs.readdir(dockerContextDirectory); + await cleanup( + // If the directory is empty, we will not delete it. + dockerContextDirectoryChildren.length === 0 ? undefined : dockerContextDirectory, + useExistingDockerContainer ? undefined : dockerContainerNames, + // Do not delete the image + undefined + ); + } + async function cleanupAfter(): Promise { + await cleanup( + keepDockerContextDirectory ? undefined : dockerContextDirectory, + keepDockerContainers ? undefined : dockerContainerNames, + keepDockerImages ? undefined : dockerImageNames + ); + } + function createDockerContextDirectoryThunk(): void { + createDockerContextDirectory( + dockerContextDirectory, + containerWorkspace, + samplesPath, + envFilePath, + options["artifact-path"], + containerLogFilePath + ); + } + async function runContainers(): Promise { + const containerRuns = dockerImageNames.map((imageName, containerIndex) => () => + runDockerContainer( + dockerContextDirectory, + imageName, + dockerContainerNames[containerIndex], + containerWorkspace, + stdoutListener, + stderrListener + ) + ); + for (const run of containerRuns) { + await run(); + } + } + await cleanupBefore(); + createDockerContextDirectoryThunk(); + await runContainers(); + await cleanupAfter(); + + return true; +}); diff --git a/common/tools/dev-tool/src/commands/samples/index.ts b/common/tools/dev-tool/src/commands/samples/index.ts index f93af14611c..5da429afee7 100644 --- a/common/tools/dev-tool/src/commands/samples/index.ts +++ b/common/tools/dev-tool/src/commands/samples/index.ts @@ -9,5 +9,6 @@ export default subCommand(commandInfo, { dev: () => import("./dev"), prep: () => import("./prep"), run: () => import("./run"), - "ts-to-js": () => import("./tsToJs") + "ts-to-js": () => import("./tsToJs"), + "check-node-versions": () => import("./checkNodeVersions") });