mirror of https://github.com/pulumi/pulumi.git
499 lines
22 KiB
TypeScript
499 lines
22 KiB
TypeScript
// Copyright 2016-2022, Pulumi Corporation.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// The tsnode import is used for type-checking only. Do not reference it in the emitted code.
|
|
import * as tsnode from "ts-node";
|
|
import * as fs from "fs";
|
|
import * as fspromises from "fs/promises";
|
|
import * as ini from "ini";
|
|
import * as minimist from "minimist";
|
|
import * as path from "path";
|
|
import * as semver from "semver";
|
|
import * as url from "url";
|
|
import * as util from "util";
|
|
import { ResourceError, RunError } from "../../errors";
|
|
import * as log from "../../log";
|
|
import { Inputs } from "../../output";
|
|
import * as settings from "../../runtime/settings";
|
|
import * as stack from "../../runtime/stack";
|
|
import * as tsutils from "../../tsutils";
|
|
import * as tracing from "./tracing";
|
|
|
|
import * as mod from ".";
|
|
|
|
// Workaround for typescript transpiling dynamic import into `Promise.resolve().then(() => require`
|
|
// Follow this issue for progress on when we can remove this:
|
|
// https://github.com/microsoft/TypeScript/issues/43329
|
|
//
|
|
// Workaround inspired by es-module-shims:
|
|
// https://github.com/guybedford/es-module-shims/blob/main/src/common.js#L21
|
|
/** @internal */
|
|
// eslint-disable-next-line no-eval
|
|
const dynamicImport = (0, eval)("u=>import(u)");
|
|
|
|
/**
|
|
* Attempts to provide a detailed error message for module load failure if the
|
|
* module that failed to load is the top-level module.
|
|
* @param program The name of the program given to `run`, i.e. the top level module
|
|
* @param error The error that occured. Must be a module load error.
|
|
*/
|
|
async function reportModuleLoadFailure(program: string, error: Error): Promise<void> {
|
|
await throwOrPrintModuleLoadError(program, error);
|
|
|
|
// Note: from this point on, we've printed something to the user telling them about the
|
|
// problem. So we can let our langhost know it doesn't need to report any further issues.
|
|
return process.exit(mod.nodeJSProcessExitedAfterLoggingUserActionableMessage);
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
* This function searches for the nearest package.json file, scanning up from the
|
|
* program path until it finds one. If it does not find a package.json file, it
|
|
* it returns the folder enclosing the program.
|
|
* @param programPath the path to the Pulumi program; this is the project "main" directory,
|
|
* which defaults to the project "root" directory.
|
|
*/
|
|
async function npmPackageRootFromProgramPath(programPath: string): Promise<string> {
|
|
// pkg-dir is an ESM module which we use to find the location of package.json
|
|
// Because it's an ESM module, we cannot import it directly.
|
|
const { packageDirectory } = await dynamicImport("pkg-dir");
|
|
// Check if programPath is a directory. If not, then we
|
|
// look at it's parent dir for the package root.
|
|
let isDirectory = false;
|
|
try {
|
|
const fileStat = await fspromises.lstat(programPath);
|
|
isDirectory = fileStat.isDirectory();
|
|
} catch {
|
|
// Since an exception was thrown, the program path doesn't exist.
|
|
// Do nothing, because isDirectory is already false.
|
|
}
|
|
const programDirectory = isDirectory ? programPath : path.dirname(programPath);
|
|
const pkgDir = await packageDirectory({
|
|
cwd: programDirectory,
|
|
});
|
|
if (pkgDir === undefined) {
|
|
log.warn(
|
|
"Could not find a package.json file for the program. Using the Pulumi program directory as the project root.",
|
|
);
|
|
return programDirectory;
|
|
}
|
|
return pkgDir;
|
|
}
|
|
|
|
function packageObjectFromProjectRoot(projectRoot: string): Record<string, any> {
|
|
const packageJson = path.join(projectRoot, "package.json");
|
|
try {
|
|
return require(packageJson);
|
|
} catch {
|
|
// This is all best-effort so if we can't load the package.json file, that's
|
|
// fine.
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// Reads and parses the contents of .npmrc file if it exists under the project root
|
|
// This assumes that .npmrc is a sibling to package.json
|
|
function npmRcFromProjectRoot(projectRoot: string): Record<string, any> {
|
|
const rcSpan = tracing.newSpan("language-runtime.reading-npm-rc");
|
|
const emptyConfig = {};
|
|
try {
|
|
const npmRcPath = path.join(projectRoot, ".npmrc");
|
|
if (!fs.existsSync(npmRcPath)) {
|
|
return emptyConfig;
|
|
}
|
|
// file .npmrc exists, read its contents
|
|
const npmRc = fs.readFileSync(npmRcPath, "utf-8");
|
|
// Use ini to parse the contents of the .npmrc file
|
|
// This is what node does as described in the npm docs
|
|
// https://docs.npmjs.com/cli/v8/configuring-npm/npmrc#comments
|
|
const parseResult = ini.parse(npmRc);
|
|
rcSpan.end();
|
|
return parseResult;
|
|
} catch {
|
|
// .npmrc file exists but we couldn't read or parse it
|
|
// user out of luck here
|
|
rcSpan.end();
|
|
return emptyConfig;
|
|
}
|
|
}
|
|
|
|
async function throwOrPrintModuleLoadError(program: string, error: Error): Promise<void> {
|
|
// error is guaranteed to be a Node module load error. Node emits a very
|
|
// specific string in its error message for module load errors, which includes
|
|
// the module it was trying to load.
|
|
const errorRegex = /Cannot find module '(.*)'/;
|
|
|
|
// If there's no match, who knows what this exception is; it's not something
|
|
// we can provide an intelligent diagnostic for.
|
|
const moduleNameMatches = errorRegex.exec(error.message);
|
|
if (moduleNameMatches === null) {
|
|
throw error;
|
|
}
|
|
|
|
// Is the module that failed to load exactly the one that this script considered to
|
|
// be the top-level module for this program?
|
|
//
|
|
// We are only interested in producing good diagnostics for top-level module loads,
|
|
// since anything else are probably user code issues.
|
|
const moduleName = moduleNameMatches[1];
|
|
if (moduleName !== program) {
|
|
throw error;
|
|
}
|
|
|
|
// Note: from this point on, we've printed something to the user telling them about the
|
|
// problem. So we can let our langhost know it doesn't need to report any further issues.
|
|
console.error(`We failed to locate the entry point for your program: ${program}`);
|
|
|
|
// From here on out, we're going to try to inspect the program we're being asked to run
|
|
// a little to see what sort of details we can glean from it, in the hopes of producing
|
|
// a better error message.
|
|
//
|
|
// The first step of this is trying to slurp up a package.json for this program, if
|
|
// one exists.
|
|
const packageRoot = await npmPackageRootFromProgramPath(program);
|
|
const packageObject = packageObjectFromProjectRoot(packageRoot);
|
|
|
|
console.error("Here's what we think went wrong:");
|
|
|
|
// The objective here is to emit the best diagnostic we can, starting from the
|
|
// most specific to the least specific.
|
|
const deps = packageObject["dependencies"] || {};
|
|
const devDeps = packageObject["devDependencies"] || {};
|
|
const scripts = packageObject["scripts"] || {};
|
|
const mainProperty = packageObject["main"] || "index.js";
|
|
|
|
// Is there a build script associated with this program? It's a little confusing that the
|
|
// Pulumi CLI doesn't run build scripts before running the program so call that out
|
|
// explicitly.
|
|
|
|
if ("build" in scripts) {
|
|
const command = scripts["build"];
|
|
console.error(` * Your program looks like it has a build script associated with it ('${command}').\n`);
|
|
console.error(
|
|
"Pulumi does not run build scripts before running your program. " +
|
|
`Please run '${command}', 'yarn build', or 'npm run build' and try again.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Not all typescript programs have build scripts. If we think it's a typescript program,
|
|
// tell the user to run tsc.
|
|
if ("typescript" in deps || "typescript" in devDeps) {
|
|
console.error(" * Your program looks like a TypeScript program. Have you run 'tsc'?");
|
|
return;
|
|
}
|
|
|
|
// Not all projects are typescript. If there's a main property, check that the file exists.
|
|
if (mainProperty !== undefined && typeof mainProperty === "string") {
|
|
const mainFile = path.join(packageRoot, mainProperty);
|
|
if (!fs.existsSync(mainFile)) {
|
|
console.error(` * Your program's 'main' file (${mainFile}) does not exist.`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
console.error(" * Pulumi encountered an unexpected error.");
|
|
console.error(` Raw exception message: ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
function tracingIsEnabled(tracingUrl: string | boolean): boolean {
|
|
if (typeof tracingUrl !== "string") {
|
|
return false;
|
|
}
|
|
const experimental = process.env["PULUMI_EXPERIMENTAL"] ?? "";
|
|
const nonzeroLength = tracingUrl.length > 0;
|
|
const experimentalEnabled = experimental.length > 0;
|
|
return nonzeroLength && experimentalEnabled;
|
|
}
|
|
|
|
/** @internal */
|
|
export async function run(
|
|
argv: minimist.ParsedArgs,
|
|
programStarted: () => void,
|
|
reportLoggedError: (err: Error) => void,
|
|
isErrorReported: (err: Error) => boolean,
|
|
): Promise<Inputs | undefined> {
|
|
const tracingUrl: string | boolean = argv["tracing"];
|
|
// Start tracing. Before exiting, gracefully shutdown tracing, exporting
|
|
// all remaining spans in the batch.
|
|
if (tracingIsEnabled(tracingUrl)) {
|
|
tracing.start(tracingUrl as string); // safe cast, since tracingIsEnable confirmed the type
|
|
process.on("exit", tracing.stop);
|
|
}
|
|
// Start a new span, which we shutdown at the bottom of this method.
|
|
const span = tracing.newSpan("language-runtime.run");
|
|
|
|
// If there is a --pwd directive, switch directories.
|
|
const pwd: string | undefined = argv["pwd"];
|
|
if (pwd) {
|
|
process.chdir(pwd);
|
|
}
|
|
|
|
// If this is a typescript project, we'll want to load node-ts.
|
|
const typeScript: boolean = process.env["PULUMI_NODEJS_TYPESCRIPT"] === "true";
|
|
|
|
// We provide reasonable defaults for many ts options, meaning you don't need to have a tsconfig.json present
|
|
// if you want to use TypeScript with Pulumi. However, ts-node's default behavior is to walk up from the cwd to
|
|
// find a tsconfig.json. For us, it's reasonable to say that the "root" of the project is the cwd,
|
|
// if there's a tsconfig.json file here. Otherwise, just tell ts-node to not load project options at all.
|
|
// This helps with cases like pulumi/pulumi#1772.
|
|
const defaultTsConfigPath = "tsconfig.json";
|
|
const tsConfigPath: string = process.env["PULUMI_NODEJS_TSCONFIG_PATH"] ?? defaultTsConfigPath;
|
|
const skipProject = !fs.existsSync(tsConfigPath);
|
|
|
|
span.setAttribute("typescript-enabled", typeScript);
|
|
if (typeScript) {
|
|
const transpileOnly = (process.env["PULUMI_NODEJS_TRANSPILE_ONLY"] ?? "false") === "true";
|
|
const compilerOptions = tsutils.loadTypeScriptCompilerOptions(tsConfigPath);
|
|
const { tsnodeRequire, typescriptRequire } = tsutils.typeScriptRequireStrings();
|
|
const tsn: typeof tsnode = require(tsnodeRequire);
|
|
tsn.register({
|
|
compiler: typescriptRequire,
|
|
transpileOnly,
|
|
// PULUMI_NODEJS_TSCONFIG_PATH might be set to a config file such as "tsconfig.pulumi.yaml" which
|
|
// would not get picked up by tsnode by default, so we explicitly tell tsnode which config file to
|
|
// use (Which might just be ./tsconfig.yaml)
|
|
project: tsConfigPath,
|
|
skipProject: skipProject,
|
|
compilerOptions: {
|
|
target: "es6",
|
|
module: "commonjs",
|
|
moduleResolution: "node",
|
|
sourceMap: "true",
|
|
...compilerOptions,
|
|
},
|
|
});
|
|
}
|
|
|
|
const hasEntrypoint = argv._[0] !== ".";
|
|
let program: string = argv._[0];
|
|
if (!path.isAbsolute(program)) {
|
|
// If this isn't an absolute path, make it relative to the working directory.
|
|
program = path.join(process.cwd(), program);
|
|
}
|
|
|
|
// Now fake out the process-wide argv, to make the program think it was run normally.
|
|
const programArgs: string[] = argv._.slice(1);
|
|
process.argv = [process.argv[0], process.argv[1], ...programArgs];
|
|
|
|
// Set up the process uncaught exception, unhandled rejection, and program exit handlers.
|
|
const uncaughtHandler = (err: Error) => {
|
|
// In node, if you throw an error in a chained promise, but the exception is not finally
|
|
// handled, then you can end up getting an unhandledRejection for each exception/promise
|
|
// pair. Because the exception is the same through all of these, we keep track of it and
|
|
// only report it once so the user doesn't get N messages for the same thing.
|
|
if (isErrorReported(err)) {
|
|
return;
|
|
}
|
|
|
|
// colorize stack trace if exists
|
|
const stackMessage = err.stack && util.inspect(err, { colors: true });
|
|
|
|
// Default message should be to include the full stack (which includes the message), or
|
|
// fallback to just the message if we can't get the stack.
|
|
//
|
|
// If both the stack and message are empty, then just stringify the err object itself. This
|
|
// is also necessary as users can throw arbitrary things in JS (including non-Errors).
|
|
const defaultMessage = stackMessage || err.message || "" + err;
|
|
|
|
// First, log the error.
|
|
if (RunError.isInstance(err)) {
|
|
// Always hide the stack for RunErrors.
|
|
log.error(err.message);
|
|
} else if (err.name === "TSError" || err.name === SyntaxError.name) {
|
|
// Hide stack frames as TSError/SyntaxError have messages containing
|
|
// where the error is located
|
|
const errOut = err.stack?.toString() || "";
|
|
let errMsg = err.message;
|
|
|
|
const errParts = errOut.split(err.message);
|
|
if (errParts.length === 2) {
|
|
errMsg = errParts[0] + err.message;
|
|
}
|
|
|
|
log.error(
|
|
`Running program '${program}' failed with an unhandled exception:
|
|
${errMsg}`,
|
|
);
|
|
} else if (ResourceError.isInstance(err)) {
|
|
// Hide the stack if requested to by the ResourceError creator.
|
|
const message = err.hideStack ? err.message : defaultMessage;
|
|
log.error(message, err.resource);
|
|
} else {
|
|
log.error(
|
|
`Running program '${program}' failed with an unhandled exception:
|
|
${defaultMessage}`,
|
|
);
|
|
}
|
|
|
|
span.addEvent(`uncaughtError: ${err}`);
|
|
reportLoggedError(err);
|
|
};
|
|
|
|
process.on("uncaughtException", uncaughtHandler);
|
|
// @ts-ignore 'unhandledRejection' will almost always invoke uncaughtHandler with an Error. so
|
|
// just suppress the TS strictness here.
|
|
process.on("unhandledRejection", uncaughtHandler);
|
|
process.on("exit", settings.disconnectSync);
|
|
|
|
// Trigger callback to update a sentinel variable tracking
|
|
// whether the program is running.
|
|
programStarted();
|
|
|
|
// This needs to occur after `programStarted` to ensure execution of the parent process stops.
|
|
if (skipProject && tsConfigPath !== defaultTsConfigPath) {
|
|
span.addEvent("Missing tsconfig file");
|
|
return new Promise(() => {
|
|
const e = new Error(`tsconfig path was set to ${tsConfigPath} but the file was not found`);
|
|
e.stack = undefined;
|
|
throw e;
|
|
});
|
|
}
|
|
|
|
const containsTSAndJSModules = async (programPath: string) => {
|
|
const programStats = await fs.promises.lstat(programPath);
|
|
if (programStats.isDirectory()) {
|
|
const programDirFiles = await fs.promises.readdir(programPath);
|
|
return programDirFiles.includes("index.js") && programDirFiles.includes("index.ts");
|
|
} else {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const runProgram = async () => {
|
|
// We run the program inside this context so that it adopts all resources.
|
|
//
|
|
// IDEA: This will miss any resources created on other turns of the event loop. I think that's a fundamental
|
|
// problem with the current Component design though - not sure what else we could do here.
|
|
//
|
|
// Now go ahead and execute the code. The process will remain alive until the message loop empties.
|
|
log.debug(`Running program '${program}' in pwd '${process.cwd()}' w/ args: ${programArgs}`);
|
|
|
|
// Create a new span for the execution of the user program.
|
|
const runProgramSpan = tracing.newSpan("language-runtime.runProgram");
|
|
|
|
try {
|
|
const packageRoot = await npmPackageRootFromProgramPath(program);
|
|
const packageObject = packageObjectFromProjectRoot(packageRoot);
|
|
let programExport: any;
|
|
|
|
// If there is no entrypoint set in Pulumi.yaml via the main
|
|
// option, look for an entrypoint defined in package.json
|
|
if (!hasEntrypoint && packageObject["main"]) {
|
|
const packageMainPath = path.join(packageRoot, packageObject["main"]);
|
|
if (fs.existsSync(packageMainPath)) {
|
|
program = packageMainPath;
|
|
} else {
|
|
log.warn(
|
|
`Could not find entry point '${packageMainPath}' specified in package.json; ` +
|
|
`using '${program}' instead`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// We use dynamic import instead of require for projects using native ES modules instead of commonjs
|
|
if (packageObject["type"] === "module") {
|
|
// Use the same behavior for loading the main entrypoint as `node <program>`.
|
|
// See https://github.com/nodejs/node/blob/master/lib/internal/modules/run_main.js#L74.
|
|
const mainPath: string =
|
|
require("module").Module._findPath(path.resolve(program), null, true) || program;
|
|
const main = path.isAbsolute(mainPath) ? url.pathToFileURL(mainPath).href : mainPath;
|
|
// Import the module and capture any module outputs it exported. Finally, await the value we get
|
|
// back. That way, if it is async and throws an exception, we properly capture it here
|
|
// and handle it.
|
|
programExport = await dynamicImport(main);
|
|
// If there is a default export, use that instead of the named exports (and error if there are both).
|
|
if (Object.getOwnPropertyDescriptor(programExport, "default") !== undefined) {
|
|
if (Object.keys(programExport).length !== 1) {
|
|
throw new Error(
|
|
"expected entrypoint module to have either a default export or named exports but not both",
|
|
);
|
|
}
|
|
programExport = programExport.default;
|
|
}
|
|
} else {
|
|
// It's a CommonJS module, so require the module and capture any module outputs it exported.
|
|
|
|
// If this is a folder ensure it ends with a "/" so we require the folder, not any adjacent .json file
|
|
const programStats = await fs.promises.lstat(program);
|
|
if (programStats.isDirectory() && !program.endsWith("/")) {
|
|
program = program + "/";
|
|
}
|
|
programExport = require(program);
|
|
}
|
|
|
|
if (await containsTSAndJSModules(program)) {
|
|
log.warn(
|
|
"Found a TypeScript project containing an index.js file and no explicit entrypoint in Pulumi.yaml - Pulumi will use index.js",
|
|
);
|
|
}
|
|
|
|
// Check compatible engines before running the program:
|
|
const npmRc = npmRcFromProjectRoot(packageRoot);
|
|
if (npmRc["engine-strict"] && packageObject.engines && packageObject.engines.node) {
|
|
// found:
|
|
// - { engines: { node: "<version>" } } in package.json
|
|
// - engine-strict=true in .npmrc
|
|
//
|
|
// Check that current node version satistfies the required version
|
|
const requiredNodeVersion = packageObject.engines.node;
|
|
const currentNodeVersion = process.versions.node;
|
|
if (!semver.satisfies(currentNodeVersion, requiredNodeVersion)) {
|
|
const errorMessage = [
|
|
`Your current Node version is incompatible to run ${packageRoot}`,
|
|
`Expected version: ${requiredNodeVersion} as found in package.json > engines > node`,
|
|
`Actual Node version: ${currentNodeVersion}`,
|
|
`To fix issue, install a Node version that is compatible with ${requiredNodeVersion}`,
|
|
];
|
|
|
|
runProgramSpan.addEvent("Incompatible Node version");
|
|
throw new Error(errorMessage.join("\n"));
|
|
}
|
|
}
|
|
|
|
// If the exported value was itself a Function, then just execute it. This allows for
|
|
// exported top level async functions that pulumi programs can live in. Finally, await
|
|
// the value we get back. That way, if it is async and throws an exception, we properly
|
|
// capture it here and handle it.
|
|
const invokeResult = programExport instanceof Function ? programExport() : programExport;
|
|
runProgramSpan.end();
|
|
return await invokeResult;
|
|
} catch (e) {
|
|
// User JavaScript can throw anything, so if it's not an Error it's definitely
|
|
// not something we want to catch up here.
|
|
if (!(e instanceof Error)) {
|
|
throw e;
|
|
}
|
|
|
|
// Give a better error message, if we can.
|
|
const errorCode = (<any>e).code;
|
|
if (errorCode === "MODULE_NOT_FOUND") {
|
|
runProgramSpan.addEvent("Module Load Failure.");
|
|
await reportModuleLoadFailure(program, e);
|
|
}
|
|
|
|
throw e;
|
|
} finally {
|
|
runProgramSpan.end();
|
|
}
|
|
};
|
|
|
|
// Construct a `Stack` resource to represent the outputs of the program.
|
|
const stackOutputs = await stack.runInPulumiStack(runProgram);
|
|
await settings.disconnect();
|
|
span.end();
|
|
return stackOutputs;
|
|
}
|