mirror of https://github.com/pulumi/pulumi.git
440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
// Copyright 2016-2018, 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.
|
|
|
|
import * as fs from "fs";
|
|
import { fdir } from "fdir";
|
|
// Picomatch is not used in this file, but the dependency is required for fdir
|
|
// to support globbing. The import here serves as a marker so that we don't
|
|
// accidentally remove the dependency from the package.json.
|
|
import * as picomatch from "picomatch";
|
|
import normalize from "normalize-package-data";
|
|
import * as arborist from "@npmcli/arborist";
|
|
import * as upath from "upath";
|
|
import { log } from "../..";
|
|
import * as asset from "../../asset";
|
|
import { ResourceError } from "../../errors";
|
|
import { Resource } from "../../resource";
|
|
|
|
/**
|
|
* Options for controlling what gets returned by {@link computeCodePaths}.
|
|
*/
|
|
export interface CodePathOptions {
|
|
/**
|
|
* Local file/directory paths that we always want to include when producing
|
|
* the assets to be included for a serialized closure.
|
|
*/
|
|
extraIncludePaths?: string[];
|
|
|
|
/**
|
|
* Extra packages to include when producing the assets for a serialized
|
|
* closure. This can be useful if the packages are acquired in a way that
|
|
* the serialization code does not understand. For example, if there was
|
|
* some sort of module that was pulled in based off of a computed string.
|
|
*/
|
|
extraIncludePackages?: string[];
|
|
|
|
/**
|
|
* Packages to explicitly exclude from the assets for a serialized closure.
|
|
* This can be used when clients want to trim down the size of a closure,
|
|
* and they know that some package won't ever actually be needed at runtime,
|
|
* but is still a dependency of some package that is being used at runtime.
|
|
*/
|
|
extraExcludePackages?: string[];
|
|
|
|
/**
|
|
* The resource to log any errors we encounter against.
|
|
*/
|
|
logResource?: Resource;
|
|
}
|
|
|
|
/**
|
|
* Computes the local `node_module` paths to include in an uploaded cloud
|
|
* "lambda". Specifically, it will examine the `package.json` for the caller's
|
|
* code and transitively walk its `dependencies` section to determine what
|
|
* packages should be included.
|
|
*
|
|
* During this walk, if a package is encountered that contains a `"pulumi": {
|
|
* ... }` section then the normal `"dependencies": { ... }` section of that
|
|
* package will not be included. These are "pulumi" packages, and those
|
|
* dependencies are only intended for use at deployment time. However, a
|
|
* "pulumi" package can also specify package that should be available at
|
|
* cloud-runtime. These packages are found in a `"runtimeDependencies": { ...
|
|
* }` section in the `package.json` file with the same format as the normal
|
|
* `dependencies` section.
|
|
*
|
|
* See {@link CodePathOptions} for information on ways to control and configure
|
|
* the final set of paths included in the resultant asset/archive map.
|
|
*
|
|
* Note: this functionality is specifically intended for use by downstream
|
|
* library code that is determining what is needed for a cloud-lambda. i.e. the
|
|
* `aws.serverless.Function` or `azure.serverless.FunctionApp` libraries. In
|
|
* general, other clients should not need to use this helper.
|
|
*/
|
|
export async function computeCodePaths(options?: CodePathOptions): Promise<Map<string, asset.Asset | asset.Archive>>;
|
|
|
|
/**
|
|
* @deprecated
|
|
* Use the {@link computeCodePaths} overload that takes a
|
|
* {@link CodePathOptions} instead.
|
|
*/
|
|
export async function computeCodePaths(
|
|
extraIncludePaths?: string[],
|
|
extraIncludePackages?: string[],
|
|
extraExcludePackages?: string[],
|
|
): Promise<Map<string, asset.Asset | asset.Archive>>;
|
|
|
|
export async function computeCodePaths(
|
|
optionsOrExtraIncludePaths?: string[] | CodePathOptions,
|
|
extraIncludePackages?: string[],
|
|
extraExcludePackages?: string[],
|
|
): Promise<Map<string, asset.Asset | asset.Archive>> {
|
|
let options: CodePathOptions;
|
|
if (Array.isArray(optionsOrExtraIncludePaths)) {
|
|
log.warn(
|
|
"'function computeCodePaths(string[])' is deprecated. Use the [computeCodePaths] overload that takes a [CodePathOptions] instead.",
|
|
);
|
|
options = {
|
|
extraIncludePaths: optionsOrExtraIncludePaths,
|
|
extraIncludePackages,
|
|
extraExcludePackages,
|
|
};
|
|
} else {
|
|
options = optionsOrExtraIncludePaths || {};
|
|
}
|
|
|
|
return computeCodePathsWorker(options);
|
|
}
|
|
|
|
async function computeCodePathsWorker(options: CodePathOptions): Promise<Map<string, asset.Asset | asset.Archive>> {
|
|
// Construct the set of paths to include in the archive for upload.
|
|
|
|
// Find folders for all packages requested by the user. Note: all paths in this should
|
|
// be normalized.
|
|
const normalizedPathSet = await allFoldersForPackages(
|
|
new Set<string>(options.extraIncludePackages || []),
|
|
new Set<string>(options.extraExcludePackages || []),
|
|
options.logResource,
|
|
);
|
|
|
|
// Add all paths explicitly requested by the user
|
|
const extraIncludePaths = options.extraIncludePaths || [];
|
|
for (const path of extraIncludePaths) {
|
|
normalizedPathSet.add(upath.normalize(path));
|
|
}
|
|
|
|
const codePaths: Map<string, asset.Asset | asset.Archive> = new Map();
|
|
|
|
// For each of the required paths, add the corresponding FileArchive or FileAsset to the
|
|
// AssetMap.
|
|
for (const normalizedPath of normalizedPathSet) {
|
|
// Don't include a path if there is another path higher up that will include this one.
|
|
if (isSubsumedByHigherPath(normalizedPath, normalizedPathSet)) {
|
|
continue;
|
|
}
|
|
|
|
// The Asset model does not support a consistent way to embed a file-or-directory into an
|
|
// `AssetArchive`, so we stat the path to figure out which it is and use the appropriate
|
|
// Asset constructor.
|
|
const stats = fs.statSync(normalizedPath);
|
|
if (stats.isDirectory()) {
|
|
codePaths.set(normalizedPath, new asset.FileArchive(normalizedPath));
|
|
} else {
|
|
codePaths.set(normalizedPath, new asset.FileAsset(normalizedPath));
|
|
}
|
|
}
|
|
|
|
return codePaths;
|
|
}
|
|
|
|
function isSubsumedByHigherPath(normalizedPath: string, normalizedPathSet: Set<string>): boolean {
|
|
for (const otherNormalizedPath of normalizedPathSet) {
|
|
if (normalizedPath.length > otherNormalizedPath.length && normalizedPath.startsWith(otherNormalizedPath)) {
|
|
// Have to make sure we're actually a sub-directory of that other path. For example,
|
|
// if we have: node_modules/mime-types, that's not subsumed by node_modules/mime
|
|
const nextChar = normalizedPath.charAt(otherNormalizedPath.length);
|
|
return nextChar === "/";
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Searches for and returns the first directory path starting from a given
|
|
* directory that contains the given file to find. Recursively searches up the
|
|
* directory tree until it finds the file or returns `null` when it can't find
|
|
* anything.
|
|
* */
|
|
function searchUp(currentDir: string, fileToFind: string): string | null {
|
|
if (fs.existsSync(upath.join(currentDir, fileToFind))) {
|
|
return currentDir;
|
|
}
|
|
const parentDir = upath.resolve(currentDir, "..");
|
|
if (currentDir === parentDir) {
|
|
return null;
|
|
}
|
|
return searchUp(parentDir, fileToFind);
|
|
}
|
|
|
|
/**
|
|
* Detects if we are in a Yarn/NPM workspace setup, and returns the root of the
|
|
* workspace. If we are not in a workspace setup, it returns `null`.
|
|
*
|
|
* @internal
|
|
*/
|
|
export async function findWorkspaceRoot(startingPath: string): Promise<string | null> {
|
|
const stat = fs.statSync(startingPath);
|
|
if (!stat.isDirectory()) {
|
|
startingPath = upath.dirname(startingPath);
|
|
}
|
|
const packageJSONDir = searchUp(startingPath, "package.json");
|
|
if (packageJSONDir === null) {
|
|
return null;
|
|
}
|
|
// We start at the location of the first `package.json` we find.
|
|
let currentDir = packageJSONDir;
|
|
let nextDir = upath.dirname(currentDir);
|
|
while (currentDir !== nextDir) {
|
|
const p = upath.join(currentDir, "package.json");
|
|
if (!fs.existsSync(p)) {
|
|
currentDir = nextDir;
|
|
nextDir = upath.dirname(currentDir);
|
|
continue;
|
|
}
|
|
const workspaces = parseWorkspaces(p);
|
|
for (const workspace of workspaces) {
|
|
const globber = new fdir().withBasePath().glob(upath.join(currentDir, workspace, "package.json"));
|
|
const files = await globber.crawl(currentDir).withPromise();
|
|
const normalized = upath.normalizeTrim(upath.join(packageJSONDir, "package.json"));
|
|
if (files.map((f) => upath.normalizeTrim(f)).includes(normalized)) {
|
|
return currentDir;
|
|
}
|
|
}
|
|
currentDir = nextDir;
|
|
nextDir = upath.dirname(currentDir);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseWorkspaces(packageJsonPath: string): string[] {
|
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
if (packageJson.workspaces && Array.isArray(packageJson.workspaces)) {
|
|
return packageJson.workspaces;
|
|
}
|
|
if (packageJson.workspaces?.packages && Array.isArray(packageJson.workspaces.packages)) {
|
|
return packageJson.workspaces.packages;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Computes the set of package folders that are transitively required by the root
|
|
* `dependencies` node in the client's `package.json` file.
|
|
*/
|
|
async function allFoldersForPackages(
|
|
includedPackages: Set<string>,
|
|
excludedPackages: Set<string>,
|
|
logResource: Resource | undefined,
|
|
): Promise<Set<string>> {
|
|
// the working directory is the directory containing the package.json file
|
|
let workingDir = searchUp(".", "package.json");
|
|
if (workingDir === null) {
|
|
// we couldn't find a directory containing package.json
|
|
// searching up from the current directory
|
|
throw new ResourceError("Failed to find package.json.", logResource);
|
|
}
|
|
workingDir = upath.resolve(workingDir);
|
|
|
|
// This is the core starting point of the algorithm. We read the
|
|
// package.json information for this project, and then we start by walking
|
|
// the .dependencies node in that package. Importantly, we do not look at
|
|
// things like .devDependencies or or .peerDependencies. These are not
|
|
// what are considered part of the final runtime configuration of the app
|
|
// and should not be uploaded.
|
|
const referencedPackages = new Set<string>(includedPackages);
|
|
const packageJSON = computeDependenciesDirectlyFromPackageFile(upath.join(workingDir, "package.json"), logResource);
|
|
if (packageJSON.dependencies) {
|
|
for (const depName of Object.keys(packageJSON.dependencies)) {
|
|
referencedPackages.add(depName);
|
|
}
|
|
}
|
|
|
|
// Find the workspace root, fallback to current working directory if we are not in a workspaces setup.
|
|
let workspaceRoot = (await findWorkspaceRoot(workingDir)) || workingDir;
|
|
// Ensure workingDir is a relative path so we get relative paths in the
|
|
// output. If we have absolute paths, AWS lambda might not find the
|
|
// dependencies.
|
|
workspaceRoot = upath.relative(upath.resolve("."), workspaceRoot);
|
|
|
|
// Read package tree from the workspace root to ensure we can find all
|
|
// packages in the workspace. We then call addPackageAndDependenciesToSet
|
|
// to recursively add all the dependencies of the referenced packages.
|
|
const arb = new arborist.Arborist({ path: workspaceRoot });
|
|
const root = await arb.loadActual();
|
|
|
|
// package.json files can contain circularities. For example es6-iterator depends
|
|
// on es5-ext, which depends on es6-iterator, which depends on es5-ext:
|
|
// https://github.com/medikoo/es6-iterator/blob/0eac672d3f4bb3ccc986bbd5b7ffc718a0822b74/package.json#L20
|
|
// https://github.com/medikoo/es5-ext/blob/792c9051e5ad9d7671dd4e3957eee075107e9e43/package.json#L29
|
|
//
|
|
// So keep track of the paths we've looked and don't recurse if we hit something again.
|
|
const seenPaths = new Set<string>();
|
|
|
|
const normalizedPackagePaths = new Set<string>();
|
|
for (const pkg of referencedPackages) {
|
|
addPackageAndDependenciesToSet(root, pkg, seenPaths, normalizedPackagePaths, excludedPackages);
|
|
}
|
|
|
|
return normalizedPackagePaths;
|
|
}
|
|
|
|
function computeDependenciesDirectlyFromPackageFile(path: string, logResource: Resource | undefined): any {
|
|
// read the package.json file in directly. if any of these fail an error will be thrown
|
|
// and bubbled back out to user.
|
|
const contents = readFile();
|
|
const data = parse();
|
|
|
|
// 'normalize-package-data' can throw if 'version' isn't a valid string. We don't care about
|
|
// 'version' so just delete it.
|
|
// https://github.com/npm/normalize-package-data/blob/df8ea05b8cd38531e8b70ac7906f420344f55bab/lib/fixer.js#L191
|
|
data.version = undefined;
|
|
|
|
// 'normalize-package-data' can throw if 'name' isn't a valid string. We don't care about
|
|
// 'name' so just delete it.
|
|
// https://github.com/npm/normalize-package-data/blob/df8ea05b8cd38531e8b70ac7906f420344f55bab/lib/fixer.js#L211
|
|
data.name = undefined;
|
|
|
|
normalize(data);
|
|
|
|
return data;
|
|
|
|
function readFile() {
|
|
try {
|
|
return fs.readFileSync(path);
|
|
} catch (err) {
|
|
throw new ResourceError(
|
|
`Error reading file '${path}' when computing package dependencies. ${err}`,
|
|
logResource,
|
|
);
|
|
}
|
|
}
|
|
|
|
function parse() {
|
|
try {
|
|
return JSON.parse(contents.toString());
|
|
} catch (err) {
|
|
throw new ResourceError(
|
|
`Error parsing file '${path}' when computing package dependencies. ${err}`,
|
|
logResource,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds all required dependencies for the requested package name from the given
|
|
* root package into the set. It will recurse into all dependencies of the
|
|
* package.
|
|
*/
|
|
function addPackageAndDependenciesToSet(
|
|
root: arborist.Node,
|
|
pkg: string,
|
|
seenPaths: Set<string>,
|
|
normalizedPackagePaths: Set<string>,
|
|
excludedPackages: Set<string>,
|
|
) {
|
|
// Don't process this packages if it was in the set the user wants to exclude.
|
|
if (excludedPackages.has(pkg)) {
|
|
return;
|
|
}
|
|
|
|
const child = findDependency(root, pkg);
|
|
if (!child) {
|
|
console.warn(`Could not include required dependency '${pkg}' in '${upath.resolve(root.path)}'.`);
|
|
return;
|
|
}
|
|
|
|
// Don't process a child path if we've already encountered it.
|
|
const normalizedPath = upath.relative(upath.resolve("."), upath.resolve(child.path));
|
|
if (seenPaths.has(normalizedPath)) {
|
|
return;
|
|
}
|
|
seenPaths.add(normalizedPath);
|
|
|
|
if (child.package.pulumi) {
|
|
// This was a pulumi deployment-time package. Check if it had a:
|
|
//
|
|
// `pulumi: { runtimeDependencies: ... }`
|
|
//
|
|
// section. In this case, we don't want to add this specific package, but we do want to
|
|
// include all the runtime dependencies it says are necessary.
|
|
recurse(child.package.pulumi.runtimeDependencies);
|
|
} else if (pkg.startsWith("@pulumi")) {
|
|
// exclude it if it's an @pulumi package. These packages are intended for deployment
|
|
// time only and will only bloat up the serialized lambda package. Note: this code can
|
|
// be removed once all pulumi packages add a "pulumi" section to their package.json.
|
|
return;
|
|
} else {
|
|
// Normal package. Add the normalized path to it, and all transitively add all of its
|
|
// dependencies.
|
|
normalizedPackagePaths.add(normalizedPath);
|
|
recurse(child.package.dependencies);
|
|
}
|
|
|
|
return;
|
|
|
|
function recurse(dependencies: any) {
|
|
if (dependencies) {
|
|
for (const dep of Object.keys(dependencies)) {
|
|
let next: arborist.Node;
|
|
if (child?.isLink) {
|
|
next = child.target;
|
|
} else {
|
|
next = child!;
|
|
}
|
|
addPackageAndDependenciesToSet(next!, dep, seenPaths, normalizedPackagePaths, excludedPackages);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Searches the package tree starting at a root node (possibly a child) for a
|
|
* match for the given name. It is assumed that the tree was correctly
|
|
* constructed such that dependencies are resolved to compatible versions in the
|
|
* closest available match starting at the provided root and walking up to the
|
|
* head of the tree.
|
|
*/
|
|
function findDependency(
|
|
root: arborist.Node | undefined | null,
|
|
name: string,
|
|
): arborist.Node | arborist.Link | undefined {
|
|
while (root) {
|
|
for (const [childName, child] of root.children) {
|
|
if (childName === name) {
|
|
return child;
|
|
}
|
|
}
|
|
if (root.parent) {
|
|
root = root.parent;
|
|
} else if (root.root !== root) {
|
|
root = root.root; // jump up to the workspace root
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|