packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts (184 lines of code) (raw):
import '../../../private/dispose-polyfill';
import * as os from 'node:os';
import * as path from 'node:path';
import { format } from 'node:util';
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
import * as cxapi from '@aws-cdk/cx-api';
import * as fs from 'fs-extra';
import { lte } from 'semver';
import type { SdkProvider, IoHelper } from '../../../api/shared-private';
import { splitBySize, versionNumber } from '../../../private/util';
import type { ToolkitServices } from '../../../toolkit/private';
import { IO } from '../../io/private';
import type { IReadLock, IWriteLock } from '../../rwlock';
import { RWLock } from '../../rwlock';
import { Settings } from '../../settings';
import { ToolkitError } from '../../shared-public';
import { loadTree, some } from '../../tree';
import { prepareDefaultEnvironment as oldPrepare, prepareContext, spaceAvailableForContext, guessExecutable } from '../environment';
import type { AppSynthOptions, LoadAssemblyOptions } from '../source-builder';
type Env = { [key: string]: string };
type Context = { [key: string]: any };
export class ExecutionEnvironment implements AsyncDisposable {
/**
* Create an ExecutionEnvironment
*
* An ExecutionEnvironment holds a writer lock on the given directory which will
* be cleaned up when the object is disposed.
*
* A temporary directory will be created if none is supplied, which will be cleaned
* up when this object is disposed.
*
* If `markSuccessful()` is called, the writer lock is converted to a reader lock
* and temporary directories will not be cleaned up anymore.
*/
public static async create(services: ToolkitServices, props: { outdir?: string } = {}) {
let tempDir = false;
let dir = props.outdir;
if (!dir) {
tempDir = true;
dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'cdk.out'));
}
const lock = await new RWLock(dir).acquireWrite();
return new ExecutionEnvironment(services, dir, tempDir, lock);
}
private readonly ioHelper: IoHelper;
private readonly sdkProvider: SdkProvider;
private readonly debugFn: (msg: string) => Promise<void>;
private lock: IWriteLock | undefined;
private shouldClean: boolean;
private constructor(
services: ToolkitServices,
public readonly outdir: string,
public readonly outDirIsTemporary: boolean,
lock: IWriteLock,
) {
this.ioHelper = services.ioHelper;
this.sdkProvider = services.sdkProvider;
this.debugFn = (msg: string) => this.ioHelper.notify(IO.DEFAULT_ASSEMBLY_DEBUG.msg(msg));
this.lock = lock;
this.shouldClean = outDirIsTemporary;
}
public async [Symbol.asyncDispose]() {
await this.lock?.release();
if (this.shouldClean) {
await fs.rm(this.outdir, { recursive: true, force: true });
}
}
/**
* Mark the execution as successful, which stops the writer lock from being released upon disposal
*/
public async markSuccessful() {
if (!this.lock) {
throw new TypeError('Cannot mark successful more than once');
}
const readLock = await this.lock.convertToReaderLock();
this.lock = undefined;
this.shouldClean = false;
return { readLock };
}
/**
* Begin an execution in this environment
*
* This will acquire a write lock on the given environment. The write lock
* will be released automatically when the return object is disposed, unless it
* is converted to a reader lock.
*/
public async beginExecution(): Promise<{ writeToReadLock(): Promise<IReadLock> } & AsyncDisposable> {
const lock = await new RWLock(this.outdir).acquireWrite();
let converted = false;
return {
async writeToReadLock() {
converted = true;
return lock.convertToReaderLock();
},
[Symbol.asyncDispose]: async () => {
// Release if not converted
if (!converted) {
await lock.release();
}
},
};
}
/**
* Guess the executable from the command-line argument
*
* Only do this if the file is NOT marked as executable. If it is,
* we'll defer to the shebang inside the file itself.
*
* If we're on Windows, we ALWAYS take the handler, since it's hard to
* verify if registry associations have or have not been set up for this
* file type, so we'll assume the worst and take control.
*/
public guessExecutable(app: string) {
return guessExecutable(app, this.debugFn);
}
/**
* If we don't have region/account defined in context, we fall back to the default SDK behavior
* where region is retrieved from ~/.aws/config and account is based on default credentials provider
* chain and then STS is queried.
*
* This is done opportunistically: for example, if we can't access STS for some reason or the region
* is not configured, the context value will be 'null' and there could failures down the line. In
* some cases, synthesis does not require region/account information at all, so that might be perfectly
* fine in certain scenarios.
*/
public async defaultEnvVars(): Promise<Env> {
const debugFn = (msg: string) => this.ioHelper.notify(IO.CDK_ASSEMBLY_I0010.msg(msg));
const env = await oldPrepare(this.sdkProvider, debugFn);
env[cxapi.OUTDIR_ENV] = this.outdir;
await debugFn(format('outdir:', this.outdir));
// CLI version information
env[cxapi.CLI_ASM_VERSION_ENV] = cxschema.Manifest.version();
env[cxapi.CLI_VERSION_ENV] = versionNumber();
await debugFn(format('env:', env));
return env;
}
/**
* Run code from a different working directory
*/
public async changeDir<T>(block: () => Promise<T>, workingDir?: string) {
const originalWorkingDir = process.cwd();
try {
if (workingDir) {
process.chdir(workingDir);
}
return await block();
} finally {
if (workingDir) {
process.chdir(originalWorkingDir);
}
}
}
/**
* Run code with additional environment variables
*/
public async withEnv<T>(env: Env = {}, block: () => Promise<T>) {
const originalEnv = process.env;
try {
process.env = {
...originalEnv,
...env,
};
return await block();
} finally {
process.env = originalEnv;
}
}
/**
* Run code with context setup inside the environment
*/
public async withContext<T>(
inputContext: Context,
env: Env,
synthOpts: AppSynthOptions = {},
block: (env: Env, context: Context) => Promise<T>,
) {
const context = await prepareContext(synthOptsDefaults(synthOpts), inputContext, env, this.debugFn);
let contextOverflowLocation = null;
try {
const envVariableSizeLimit = os.platform() === 'win32' ? 32760 : 131072;
const [smallContext, overflow] = splitBySize(context, spaceAvailableForContext(env, envVariableSizeLimit));
// Store the safe part in the environment variable
env[cxapi.CONTEXT_ENV] = JSON.stringify(smallContext);
// If there was any overflow, write it to a temporary file
if (Object.keys(overflow ?? {}).length > 0) {
const contextDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-context'));
contextOverflowLocation = path.join(contextDir, 'context-overflow.json');
fs.writeJSONSync(contextOverflowLocation, overflow);
env[cxapi.CONTEXT_OVERFLOW_LOCATION_ENV] = contextOverflowLocation;
}
// call the block code with new environment
return await block(env, context);
} finally {
if (contextOverflowLocation) {
fs.removeSync(path.dirname(contextOverflowLocation));
}
}
}
}
/**
* Checks if a given assembly supports context overflow, warn otherwise.
*
* @param assembly the assembly to check
*/
async function checkContextOverflowSupport(assembly: cxapi.CloudAssembly, ioHelper: IoHelper): Promise<void> {
const traceFn = (msg: string) => ioHelper.notify(IO.DEFAULT_ASSEMBLY_TRACE.msg(msg));
const tree = await loadTree(assembly, traceFn);
const frameworkDoesNotSupportContextOverflow = some(tree, node => {
const fqn = node.constructInfo?.fqn;
const version = node.constructInfo?.version;
return (fqn === 'aws-cdk-lib.App' && version != null && lte(version, '2.38.0')) // v2
|| fqn === '@aws-cdk/core.App'; // v1
});
// We're dealing with an old version of the framework here. It is unaware of the temporary
// file, which means that it will ignore the context overflow.
if (frameworkDoesNotSupportContextOverflow) {
await ioHelper.notify(IO.CDK_ASSEMBLY_W0010.msg('Part of the context could not be sent to the application. Please update the AWS CDK library to the latest version.'));
}
}
/**
* Safely create an assembly from a cloud assembly directory
*/
export async function assemblyFromDirectory(assemblyDir: string, ioHelper: IoHelper, loadOptions: LoadAssemblyOptions = {}) {
try {
const assembly = new cxapi.CloudAssembly(assemblyDir, {
skipVersionCheck: !(loadOptions.checkVersion ?? true),
skipEnumCheck: !(loadOptions.checkEnums ?? true),
// We sort as we deploy
topoSort: false,
});
await checkContextOverflowSupport(assembly, ioHelper);
return assembly;
} catch (err: any) {
if (err.message.includes(cxschema.VERSION_MISMATCH)) {
// this means the CLI version is too old.
// we instruct the user to upgrade.
const message = 'This AWS CDK Toolkit is not compatible with the AWS CDK library used by your application. Please upgrade to the latest version.';
await ioHelper.notify(IO.CDK_ASSEMBLY_E1111.msg(message, { error: err }));
throw new ToolkitError(`${message}\n(${err.message}`);
}
throw err;
}
}
function synthOptsDefaults(synthOpts: AppSynthOptions = {}): Settings {
return new Settings({
debug: false,
pathMetadata: true,
versionReporting: true,
assetMetadata: true,
assetStaging: true,
...synthOpts,
}, true);
}