UNPKG

@magda/scripts

Version:

Scripts for building, running, and deploying MAGDA

452 lines (393 loc) 15.3 kB
#!/usr/bin/env node import childProcess from "child_process"; import fse from "fs-extra"; import path from "path"; import process from "process"; import yargs from "yargs"; import _ from "lodash"; import isSubDir from "is-subdir"; import { getVersions, getTags, getName, getRepository } from "./docker-util.js"; import { __dirname as getCurDirPath, require } from "@magda/esm-utils"; const __dirname = getCurDirPath(); // --- cache dependencies data from package.json const packageDependencyDataCache = {}; const argv = yargs .options({ build: { description: "Pipe the Docker context straight to Docker.", type: "boolean", default: false }, tag: { description: 'The tag to pass to "docker build". This parameter is only used if --build is specified. If the value of this parameter is `auto`, a tag name is automatically created from NPM configuration.', type: "string", default: "auto" }, repository: { description: "The repository to use in auto tag generation. Will default to '', i.e. dockerhub unless --local is set. Requires --tag=auto", type: "string", default: process.env.MAGDA_DOCKER_REPOSITORY }, name: { description: "The package name to use in auto tag generation. Will default to ''. Used to override the docker nanme config in package.json during the auto tagging. Requires --tag=auto", type: "string", default: process.env.MAGDA_DOCKER_NAME }, version: { description: "The version(s) to use in auto tag generation. Will default to the current version in package.json. Requires --tag=auto", type: "string", array: true, default: process.env.MAGDA_DOCKER_VERSION }, output: { description: "The output path and filename for the Docker context .tar file.", type: "string" }, local: { description: "Build for a local Kubernetes container registry. This parameter is only used if --build is specified.", type: "boolean", default: false }, push: { description: "Push the build image to the docker registry. This parameter is only used if --build is specified.", type: "boolean", default: false }, platform: { description: "A list of platform that the docker image build should target. Specify this value will enable multi-arch image build.", type: "string" }, noCache: { description: "Disable the cache during the docker image build.", type: "boolean", default: false }, cacheFromVersion: { description: "Version to cache from when building, using the --cache-from field in docker. Will use the same repository and name. Using this options causes the image to be pulled before build.", type: "string" } }) // Because 'version is a default yargs thing we need to specifically override its normal parsing. .version(false) .array("version") .help().argv; if (!argv.build && !argv.output) { console.log("Either --build or --output <filename> must be specified."); process.exit(1); } if (argv.platform && !argv.push) { console.log( "When --platform is specified, --push must be specified as well as multi-arch image can only be pushed to remote registry." ); process.exit(1); } if (argv.noCache && argv.cacheFromVersion) { console.log("When --noCache=true, --cacheFromVersion can't be specified."); process.exit(1); } const componentSrcDir = path.resolve(process.cwd()); const dockerContextDir = fse.mkdtempSync( path.resolve(__dirname, "..", "docker-context-") ); const componentDestDir = path.resolve(dockerContextDir, "component"); fse.emptyDirSync(dockerContextDir); fse.ensureDirSync(componentDestDir); preparePackage(componentSrcDir, componentDestDir); const tar = process.platform === "darwin" ? "gtar" : "tar"; // Docker and ConEmu (an otherwise excellent console for Windows) don't get along. // See: https://github.com/Maximus5/ConEmu/issues/958 and https://github.com/moby/moby/issues/28814 // So if we're running under ConEmu, we need to add an extra -cur_console:i parameter to disable // ConEmu's hooks and also set ConEmuANSI to OFF so Docker doesn't do anything drastic. const env = Object.assign({}, process.env); const extraParameters = []; if (env.ConEmuANSI === "ON") { env.ConEmuANSI = "OFF"; extraParameters.push("-cur_console:i"); } updateDockerFile(componentSrcDir, componentDestDir); if (argv.build) { const cacheFromImage = argv.cacheFromVersion && getRepository(argv.local, argv.repository) + getName(argv.name) + ":" + argv.cacheFromVersion; if (cacheFromImage) { // Pull this image into the docker daemon - if it fails we don't care, we'll just go from scratch. const dockerPullProcess = childProcess.spawnSync( "docker", [...extraParameters, "pull", cacheFromImage], { stdio: "inherit", env: env } ); wrapConsoleOutput(dockerPullProcess); } const tarProcess = childProcess.spawn( tar, [...extraParameters, "--dereference", "-czf", "-", "*"], { cwd: dockerContextDir, stdio: ["inherit", "pipe", "inherit"], env: env, shell: true } ); const tags = getTags( argv.tag, argv.local, argv.repository, argv.version, argv.name ); const tagArgs = tags .map((tag) => ["-t", tag]) .reduce((soFar, tagArgs) => soFar.concat(tagArgs), []); const cacheFromArgs = cacheFromImage ? ["--cache-from", cacheFromImage] : []; const dockerProcess = childProcess.spawn( "docker", [ ...extraParameters, ...(argv.platform ? ["buildx"] : []), "build", ...tagArgs, ...cacheFromArgs, ...(argv.noCache ? ["--no-cache"] : []), ...(argv.platform ? ["--platform", argv.platform, "--push"] : []), "-f", `./component/Dockerfile`, "-" ], { stdio: ["pipe", "inherit", "inherit"], env: env } ); wrapConsoleOutput(dockerProcess); dockerProcess.on("close", (code) => { fse.removeSync(dockerContextDir); if (code === 0 && argv.push && !argv.platform) { if (tags.length === 0) { console.error("Can not push an image without a tag."); process.exit(1); } // Stop if there's a code !== 0 tags.every((tag) => { const process = childProcess.spawnSync( "docker", ["push", tag], { stdio: "inherit" } ); code = process.status; return code === 0; }); } process.exit(code); }); tarProcess.on("close", (code) => { dockerProcess.stdin.end(); }); tarProcess.stdout.on("data", (data) => { dockerProcess.stdin.write(data); }); } else if (argv.output) { const outputPath = path.resolve(process.cwd(), argv.output); const outputTar = fse.openSync(outputPath, "w", 0o644); const tarProcess = childProcess.spawn( tar, ["--dereference", "-czf", "-", "*"], { cwd: dockerContextDir, stdio: ["inherit", outputTar, "inherit"], env: env, shell: true } ); tarProcess.on("close", (code) => { fse.closeSync(outputTar); console.log(tarProcess.status); fse.removeSync(dockerContextDir); }); } function updateDockerFile(sourceDir, destDir) { const tags = getVersions(argv.local, argv.version); const repository = getRepository(argv.local, argv.repository); const dockerFileContents = fse.readFileSync( path.resolve(sourceDir, "Dockerfile"), "utf-8" ); const replacedDockerFileContents = dockerFileContents // Add a repository if this is a magda image .replace( /FROM .*(magda-[^:\s\/]+)(:[^\s]+)/, "FROM " + repository + "$1" + (tags[0] ? ":" + tags[0] : "$2") ); fse.writeFileSync( path.resolve(destDir, "Dockerfile"), replacedDockerFileContents, "utf-8" ); } function preparePackage(packageDir, destDir) { const packageJson = require(path.join(packageDir, "package.json")); const dockerIncludesFromPackageJson = packageJson.config && packageJson.config.docker && packageJson.config.docker.include; let dockerIncludes; if (!dockerIncludesFromPackageJson) { console.log( `WARNING: Package ${packageDir} does not have a config.docker.include key in package.json, so all of its files will be included in the docker image.` ); dockerIncludes = fse.readdirSync(packageDir); } else if (dockerIncludesFromPackageJson.trim() === "*") { dockerIncludes = fse.readdirSync(packageDir); } else { if (dockerIncludesFromPackageJson.indexOf("*") >= 0) { throw new Error( "Sorry, wildcards are not currently supported in config.docker.include." ); } dockerIncludes = dockerIncludesFromPackageJson .split(" ") .filter((include) => include.length > 0); } dockerIncludes .filter((include) => include !== "Dockerfile") // Filter out the dockerfile because we'll manually copy over a modified version. .forEach(function (include) { const src = path.resolve(packageDir, include); const dest = path.resolve(destDir, include); if (include === "node_modules") { fse.ensureDirSync(dest); const env = Object.create(process.env); env.NODE_ENV = "production"; const productionPackages = _.uniqBy( getPackageList(packageDir, path.resolve(packageDir, "..")), (pkg) => pkg.path ); prepareNodeModules(src, dest, productionPackages); return; } try { // On Windows we can't create symlinks to files without special permissions. // So just copy the file instead. Usually creating directory junctions is // fine without special permissions, but fall back on copying in the unlikely // event that fails, too. const type = fse.statSync(src).isFile() ? "file" : "junction"; fse.ensureSymlinkSync(src, dest, type); } catch (e) { fse.copySync(src, dest); } }); } function prepareNodeModules(packageDir, destDir, productionPackages) { productionPackages.forEach((src) => { const relativePath = path.relative(packageDir, src.path); const dest = path.resolve(destDir, relativePath); const srcPath = path.resolve(packageDir, relativePath); // console.log("src " + srcPath + " to " + dest); try { const stat = fse.lstatSync(srcPath); const type = stat.isFile() ? "file" : "junction"; fse.ensureSymlinkSync(srcPath, dest, type); } catch (e) { // see all error codes here: https://man7.org/linux/man-pages/man2/symlink.2.html#ERRORS if (e?.code === "EEXIST") { //console.log(`symlink ${srcPath} already exists, skip.`); // the link already exists, we choose to not attempt to overwrite it. // this means that the local dependency (in nearest node_modules) will higher priority than hoisted dependency (in root node_modules). return; } throw e; } }); } function getPackageList(packagePath, packageSearchRoot, resolvedSoFar = {}) { const dependencies = getPackageDependencies(packagePath); const result = []; if (!dependencies || !dependencies.length) { return result; } dependencies.forEach(function (dependencyName) { const dependencyNamePath = dependencyName.replace(/\//g, path.sep); let currentBaseDir = packagePath; let dependencyDir; do { dependencyDir = path.resolve( currentBaseDir, "node_modules", dependencyNamePath ); if ( currentBaseDir === packageSearchRoot || isSubDir(currentBaseDir, packageSearchRoot) ) { // --- will not look for packages outside project root directory break; } // Does this directory exist? If not, imitate node's module resolution by walking // up the directory tree. currentBaseDir = path.resolve(currentBaseDir, ".."); } while (!fse.existsSync(dependencyDir)); if (!fse.existsSync(dependencyDir)) { throw new Error( "Could not find path for " + dependencyName + " @ " + packagePath ); } // If we haven't already seen this if (!resolvedSoFar[dependencyDir]) { result.push({ name: dependencyName, path: dependencyDir }); // Now that we've added this package to the list to resolve, add all its children. const childPackageResult = getPackageList( dependencyDir, packageSearchRoot, { ...resolvedSoFar, [dependencyDir]: true } ); Array.prototype.push.apply(result, childPackageResult); } }); return result; } function getPackageDependencies(packagePath) { const packageJsonPath = path.resolve(packagePath, "package.json"); if (packageDependencyDataCache[packageJsonPath]) { return packageDependencyDataCache[packageJsonPath]; } const pkgData = fse.readJSONSync(packageJsonPath); const depData = pkgData["dependencies"]; if (!depData) { packageDependencyDataCache[packageJsonPath] = []; } else { packageDependencyDataCache[packageJsonPath] = Object.keys(depData); } return packageDependencyDataCache[packageJsonPath]; } function wrapConsoleOutput(process) { if (process.stdout) { process.stdout.on("data", (data) => { console.log(data.toString()); }); } if (process.stderr) { process.stderr.on("data", (data) => { console.error(data.toString()); }); } }