Skip to content

Commit

Permalink
chore(toolkit): cloud assemblies
Browse files Browse the repository at this point in the history
  • Loading branch information
mrgrain committed Jan 15, 2025
1 parent ccccd48 commit 3d1ba18
Show file tree
Hide file tree
Showing 13 changed files with 524 additions and 102 deletions.
1 change: 0 additions & 1 deletion packages/@aws-cdk/toolkit/lib/api/cloud-assembly/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export * from './cached-source';
export * from './context-aware-source';
export * from './identity-source';
export * from './stack-assembly';
export * from './stack-selector';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import type { MissingContext } from '@aws-cdk/cloud-assembly-schema';
import * as cxapi from '@aws-cdk/cx-api';
import { SdkProvider } from 'aws-cdk/lib/api/aws-auth';
import * as contextproviders from 'aws-cdk/lib/context-providers';
import { debug } from 'aws-cdk/lib/logging';
import { Context, PROJECT_CONTEXT } from 'aws-cdk/lib/settings';
import { ICloudAssemblySource } from './types';
import { ToolkitError } from '../errors';
import { ToolkitError } from '../../errors';
import { ActionAwareIoHost, debug } from '../../io/private';
import { ToolkitServices } from '../../toolkit/private';
import { ICloudAssemblySource } from '../types';

export interface CloudExecutableProps {
export interface ContextAwareCloudAssemblyProps {
/**
* AWS object (used by contextprovider)
* @deprecated context should be moved to the toolkit itself
*/
readonly sdkProvider: SdkProvider;
readonly services: ToolkitServices;

/**
* Application context
Expand Down Expand Up @@ -42,11 +43,13 @@ export class ContextAwareCloudAssembly implements ICloudAssemblySource {
private canLookup: boolean;
private context: Context;
private contextFile: string;
private ioHost: ActionAwareIoHost;

constructor(private readonly source: ICloudAssemblySource, private readonly props: CloudExecutableProps) {
constructor(private readonly source: ICloudAssemblySource, private readonly props: ContextAwareCloudAssemblyProps) {
this.canLookup = props.lookups ?? true;
this.context = props.context;
this.contextFile = props.contextFile ?? PROJECT_CONTEXT; // @todo new feature not needed right now
this.ioHost = props.services.ioHost;
}

/**
Expand All @@ -73,19 +76,18 @@ export class ContextAwareCloudAssembly implements ICloudAssemblySource {

let tryLookup = true;
if (previouslyMissingKeys && equalSets(missingKeys, previouslyMissingKeys)) {
debug('Not making progress trying to resolve environmental context. Giving up.');
await this.ioHost.notify(debug('Not making progress trying to resolve environmental context. Giving up.'));
tryLookup = false;
}

previouslyMissingKeys = missingKeys;

if (tryLookup) {
debug('Some context information is missing. Fetching...');

await this.ioHost.notify(debug('Some context information is missing. Fetching...'));
await contextproviders.provideContextValues(
assembly.manifest.missing,
this.context,
this.props.sdkProvider,
this.props.services.sdkProvider,
);

// Cache the new context to disk
Expand Down
45 changes: 45 additions & 0 deletions packages/@aws-cdk/toolkit/lib/api/cloud-assembly/private/exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as child_process from 'node:child_process';
import { ToolkitError } from '../../errors';

interface ExecOptions {
extraEnv?: { [key: string]: string | undefined };
cwd?: string;
}

/**
* Execute a command and args in a child process
*/
export async function execInChildProcess(commandAndArgs: string, options: ExecOptions = {}) {
return new Promise<void>((ok, fail) => {
// We use a slightly lower-level interface to:
//
// - Pass arguments in an array instead of a string, to get around a
// number of quoting issues introduced by the intermediate shell layer
// (which would be different between Linux and Windows).
//
// - Inherit stderr from controlling terminal. We don't use the captured value
// anyway, and if the subprocess is printing to it for debugging purposes the
// user gets to see it sooner. Plus, capturing doesn't interact nicely with some
// processes like Maven.
const proc = child_process.spawn(commandAndArgs, {
stdio: ['ignore', 'inherit', 'inherit'],
detached: false,
shell: true,
cwd: options.cwd,
env: {
...process.env,
...(options.extraEnv ?? {}),
},
});

proc.on('error', fail);

proc.on('exit', code => {
if (code === 0) {
return ok();
} else {
return fail(new ToolkitError(`Subprocess exited with error ${code}`));
}
});
});
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import * as os from 'node:os';
import * as path from 'node:path';
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
import * as cxapi from '@aws-cdk/cx-api';
import { prepareDefaultEnvironment as oldPrepare, prepareContext, spaceAvailableForContext } from 'aws-cdk/lib/api/cxapp/exec';
import { Settings } from 'aws-cdk/lib/settings';
import { loadTree, some } from 'aws-cdk/lib/tree';
import { splitBySize } from 'aws-cdk/lib/util';
import { versionNumber } from 'aws-cdk/lib/version';
import * as fs from 'fs-extra';
import { lte } from 'semver';
import type { AppSynthOptions } from './source-builder';
import { ToolkitError } from '../../errors';
import { ActionAwareIoHost, asLogger, error, warn } from '../../io/private';
import { ToolkitServices } from '../../toolkit/private';

export { guessExecutable } from 'aws-cdk/lib/api/cxapp/exec';

type Env = { [key: string]: string };
type Context = { [key: string]: any };

/**
* 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.
*
* @param context The context key/value bash.
*/
export async function prepareDefaultEnvironment(services: ToolkitServices, props: { outdir?: string } = {}): Promise<Env> {
const logFn = asLogger(services.ioHost, 'ASSEMBLY').debug;
const env = await oldPrepare(services.sdkProvider, logFn);

if (props.outdir) {
env[cxapi.OUTDIR_ENV] = props.outdir;
await logFn('outdir:', props.outdir);
}

// CLI version information
env[cxapi.CLI_ASM_VERSION_ENV] = cxschema.Manifest.version();
env[cxapi.CLI_VERSION_ENV] = versionNumber();

await logFn('env:', env);
return env;
}

/**
* Run code from a different working directory
*/
export async function 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
*/
export async function 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
*/
export async function withContext<T>(
inputContext: Context,
env: Env,
synthOpts: AppSynthOptions = {},
block: (env: Env, context: Context) => Promise<T>,
) {
const context = await prepareContext(synthOptsDefaults(synthOpts), inputContext, env);
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
*/
export async function checkContextOverflowSupport(assembly: cxapi.CloudAssembly, ioHost: ActionAwareIoHost): Promise<void> {
const logFn = asLogger(ioHost, 'ASSEMBLY').warn;
const tree = loadTree(assembly);
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 logFn('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, ioHost: ActionAwareIoHost) {
try {
const assembly = new cxapi.CloudAssembly(assemblyDir, {
// We sort as we deploy
topoSort: false,
});
await checkContextOverflowSupport(assembly, ioHost);
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 ioHost.notify(error(message, 'CDK_ASSEMBLY_E1111', { error: err.message }));
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);
}

Loading

0 comments on commit 3d1ba18

Please sign in to comment.