diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index b7641d15e..ad7351620 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -84,11 +84,30 @@ export function contextFromSettings( /** * Convert settings to context/environment variables + * + * This function takes a `Settings` object and produces the corresponding context and environment variables. + * + * This is what the flow looks like: + * + * ``` + * ┌───────────────┐ ┌───────────┐ + * writeContextToEnv │ Env vars + │ │ CDK App │ + * ┌─────────────────┐ ┌────────▶│ context │──spawn───▶│Subprocess │ + * │ │ settingsFromSynthOptions │ │ overflow file │ │ │ + * │ AppSynthOptions │────────────────┐ │ └───────────────┘ └───────────┘ + * │ │ ▼ ┌─────────┐ │ + * └─────────────────┘ ┌──────────┐ ┌─────────▶│ Context │────────┤ + * │ │ synth └─────────┘ │ + * │ Settings │─────Parameters │ + * │ │ FromSettings ┌─────────┐ │ + * ┌──────────┐ └──────────┘ └─────────▶│Env vars │────────┤ + * │ │ load ▲ └─────────┘ │ ┌────────────┐ + * │ cdk.json │───────────────────────┘ │ pass directly │CDK Assembly│ + * │ │ └──────────────────────────────────▶│ Builder Fn │ + * └──────────┘ └────────────┘ + * ``` */ -export function synthParametersFromSettings(settings: Settings): { - context: Context; - env: Env; -} { +export function synthParametersFromSettings(settings: Settings): SynthParameters { // These are the environment variables that will switch on debugging in the // CDK framework. // @@ -118,6 +137,11 @@ export function synthParametersFromSettings(settings: Settings): { }; } +export interface SynthParameters { + context: Context; + env: Env; +} + export function spaceAvailableForContext(env: Env, limit: number) { const size = (value?: string) => value != null ? Buffer.byteLength(value) : 0; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts index fed347701..7d97c7638 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts @@ -53,15 +53,22 @@ export class ExecutionEnvironment implements AsyncDisposable { */ public static async create(services: ToolkitServices, options: ExecutionEnvironmentOptions) { let outDirIsTemporary = false; - let dir = options.outdir; - if (!dir) { + let outdir = options.outdir; + if (!outdir) { outDirIsTemporary = true; - dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'cdk.out')); + outdir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'cdk.out')); + } else { + try { + fs.mkdirpSync(outdir); + } catch (e: any) { + throw new ToolkitError('OutputDirectoryCreateFailed', `Could not create output directory at '${outdir}' (${e.message}).`); + } } - const lock = await new RWLock(dir).acquireWrite(); + + const lock = await new RWLock(outdir).acquireWrite(); const opts = { - outdir: dir, + outdir, resolveDefaultAppEnv: options.resolveDefaultAppEnv, }; @@ -325,14 +332,23 @@ export async function assemblyFromDirectory(assemblyDir: string, ioHelper: IoHel } } -export function settingsFromSynthOptions(synthOpts: AppSynthOptions = {}): Settings { +/** + * Convert a set of synthesis options into a `Settings` object. + * + * The `Settings` object mimics the structure of the `cdk.json` file, and will be + * converted into appropriate context and environment variables for the CDK app + * elsewhere. + */ +export function settingsFromSynthOptions(synthOpts: AppSynthOptions | undefined = {}, how: 'withDefaults' | 'onlyThese'): Settings { return new Settings({ - debug: false, - pathMetadata: true, - versionReporting: true, - assetMetadata: true, - assetStaging: true, - validation: true, + ...how === 'withDefaults' ? { + debug: false, + pathMetadata: true, + versionReporting: true, + assetMetadata: true, + assetStaging: true, + validation: true, + } : {}, ...synthOpts, }, true); } @@ -344,5 +360,5 @@ export function settingsFromSynthOptions(synthOpts: AppSynthOptions = {}): Setti * from the outside of the app. */ export function parametersFromSynthOptions(synthOptions?: AppSynthOptions) { - return synthParametersFromSettings(settingsFromSynthOptions(synthOptions ?? {})); + return synthParametersFromSettings(settingsFromSynthOptions(synthOptions ?? {}, 'withDefaults')); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts index c028854c6..905144263 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts @@ -6,17 +6,19 @@ import * as fs from 'fs-extra'; import { CdkAppMultiContext, MemoryContext, type IContextStore } from './context-store'; import { RWLock } from '../rwlock'; import { CachedCloudAssembly } from './cached-source'; +import { Settings } from '../settings'; +import { synthParametersFromSettings, type SynthParameters } from './environment'; import type { ContextAwareCloudAssemblyProps } from './private/context-aware-source'; import { ContextAwareCloudAssemblySource } from './private/context-aware-source'; import { execInChildProcess } from './private/exec'; import { missingContextKeys, temporarilyWriteEnv } from './private/helpers'; -import { ExecutionEnvironment, assemblyFromDirectory, parametersFromSynthOptions, writeContextToEnv } from './private/prepare-source'; +import { ExecutionEnvironment, assemblyFromDirectory, parametersFromSynthOptions, settingsFromSynthOptions, writeContextToEnv } from './private/prepare-source'; import { ReadableCloudAssembly } from './private/readable-assembly'; import type { ICloudAssemblySource } from './types'; import type { ToolkitServices } from '../../toolkit/private'; import { ToolkitError, AssemblyError } from '../../toolkit/toolkit-error'; import { noUndefined } from '../../util'; -import { IO } from '../io/private'; +import { IO, type IoHelper } from '../io/private'; /** * Properties the builder function receives. @@ -217,6 +219,59 @@ export interface FromCdkAppOptions extends AssemblySourceProps { readonly env?: Record; } +/** + * Options for the `fromCdkAppDirectory` Assembly Source constructor + */ +export interface FromCdkAppDirectoryOptions { + /** + * Perform context lookups. + * + * Synthesis fails if this is disabled and context lookups need to be performed. + * + * @default true + */ + readonly lookups?: boolean; + + /** + * Options that are passed through the context to a CDK app on synth + * + * These default to whatever is configured in `cdk.json`, and can be overridden here. + * + * @default - settinsgs read from `cdk.json` + */ + readonly synthOptions?: AppSynthOptions; + + /** + * Options to configure loading of the assembly after it has been synthesized + * + * @default - default assembly loading options + */ + readonly loadAssemblyOptions?: LoadAssemblyOptions; + + /** + * Resolve the current default environment an provide as environment variables to the app. + * + * This will make a (cached) call to STS to resolve the current account using + * base credentials. The behavior is not always desirable and can add + * unnecessary delays, e.g. when an app specifies an environment explicitly + * or when local actions are be performed without internet access. + * + * @default true + */ + readonly resolveDefaultEnvironment?: boolean; + /** + * Additional environment variables + * + * These environment variables will be set in addition to the environment + * variables currently set in the process. A value of `undefined` will + * unset a particular environment variable. + * + * @default - no additional environment variables are set (beyond those + * already set in the process). + */ + readonly env?: Record; +} + /** * Settings that are passed to a CDK app via the context */ @@ -353,75 +408,48 @@ export abstract class CloudAssemblySourceBuilder { builder: AssemblyBuilder, props: FromAssemblyBuilderOptions = {}, ): Promise { - const services = await this.sourceBuilderServices(); - const contextStore = props.contextStore ?? new MemoryContext(); - const contextAssemblyProps: ContextAwareCloudAssemblyProps = { - services, - contextStore, - lookups: props.lookups, - }; - - const outdir = props.outdir ? path.resolve(props.outdir) : undefined; - - return new ContextAwareCloudAssemblySource( - { - produce: async (produceOptions) => { - await using execution = await ExecutionEnvironment.create(services, { - outdir, - resolveDefaultAppEnv: props.resolveDefaultEnvironment ?? true, - }); - - const synthParams = parametersFromSynthOptions(props.synthOptions); - - const fullContext = { - ...await contextStore.read(), - ...synthParams.context, - }; - - await services.ioHelper.defaults.debug(format('context:', fullContext)); + const synthParams = parametersFromSynthOptions(props.synthOptions); - const env = noUndefined({ - // Versioning, outdir, default account and region - ...await execution.defaultEnvVars(), - // Environment variables derived from settings - ...synthParams.env, + return this.makeContextAwareCloudAssemblySource({ + contextStore: props.contextStore ?? new MemoryContext(), + lookups: props.lookups, + outdir: props.outdir ? path.resolve(props.outdir) : undefined, + resolveDefaultEnvironment: props.resolveDefaultEnvironment, + synthParameters: synthParams, + baseEnv: {}, + disposeOutdir: props.disposeOutdir, + performSynth: async ({ execution, fullContext, env, abort, ioHelper }) => { + using _cleanupEnv = (props.clobberEnv ?? true) ? temporarilyWriteEnv(env) : undefined; + let assembly; + try { + assembly = await builder({ + outdir: execution.outdir, + context: fullContext, + env, + abort, }); - const cleanupContextTemp = writeContextToEnv(env, fullContext, 'env-is-complete'); - using _cleanupEnv = (props.clobberEnv ?? true) ? temporarilyWriteEnv(env) : undefined; - let assembly; - try { - assembly = await builder({ - outdir: execution.outdir, - context: fullContext, - env, - abort: produceOptions?.abort, - }); - - produceOptions?.abort?.throwIfAborted(); - } catch (error: unknown) { - // re-throw toolkit errors unchanged - if (ToolkitError.isToolkitError(error)) { - throw error; - } - // otherwise, wrap into an assembly error - throw AssemblyError.withCause('Assembly builder failed', error); - } finally { - await cleanupContextTemp(); + abort?.throwIfAborted(); + } catch (error: unknown) { + // Re-throw abort error unchanged + if (error instanceof DOMException && error.name === 'AbortError') { + throw error; } - // Convert what we got to the definitely correct type we're expecting, a cxapi.CloudAssembly - const asm = cxapi.CloudAssembly.isCloudAssembly(assembly) - ? assembly - : await assemblyFromDirectory(assembly.directory, services.ioHelper, props.loadAssemblyOptions); + // re-throw toolkit errors unchanged + if (ToolkitError.isToolkitError(error)) { + throw error; + } + // otherwise, wrap into an assembly error + throw AssemblyError.withCause('Assembly builder failed', error); + } - const success = await execution.markSuccessful(); - const deleteOnDispose = props.disposeOutdir ?? execution.shouldDisposeOutDir; - return new ReadableCloudAssembly(asm, success.readLock, { deleteOnDispose }); - }, + // Convert what we got to the definitely correct type we're expecting, a cxapi.CloudAssembly + return cxapi.CloudAssembly.isCloudAssembly(assembly) + ? assembly + : await assemblyFromDirectory(assembly.directory, ioHelper, props.loadAssemblyOptions); }, - contextAssemblyProps, - ); + }); } /** @@ -506,80 +534,135 @@ export abstract class CloudAssemblySourceBuilder { * @returns the CloudAssembly source */ public async fromCdkApp(app: string, props: FromCdkAppOptions = {}): Promise { - const services: ToolkitServices = await this.sourceBuilderServices(); const workingDirectory = props.workingDirectory ?? process.cwd(); - const outdir = props.outdir ? path.resolve(props.outdir) : path.resolve(workingDirectory, 'cdk.out'); + const synthParams = parametersFromSynthOptions(props.synthOptions); - const contextStore = props.contextStore ?? new CdkAppMultiContext(workingDirectory); + return this.makeContextAwareCloudAssemblySource({ + outdir: props.outdir ? path.resolve(props.outdir) : path.resolve(workingDirectory, 'cdk.out'), + contextStore: props.contextStore ?? new CdkAppMultiContext(workingDirectory), + lookups: props.lookups, + resolveDefaultEnvironment: props.resolveDefaultEnvironment, + synthParameters: synthParams, + baseEnv: { + // Need to start with full env of `writeContextToEnv` will not be able to do the size + // calculation correctly. + ...process.env, + // User gave us something + ...props.env, + }, + disposeOutdir: props.disposeOutdir, + performSynth: performSynthByCallingSubprocess(app, workingDirectory, props.loadAssemblyOptions), + }); + } + + /** + * Use an AWS CDK app directory as the source for the Cloud Assembly, same as the CDK CLI + * + * This requires and reads the `cdk.json` file the application directory to find the + * `app` and `outdir` configurations, and loads context from `cdk.json`+`cdk.context.json`, + * writing context updates to `cdk.context.json`. + * + * @param props - additional configuration properties + * @returns the CloudAssembly source + */ + public async fromCdkAppDirectory(appDirectory: string, props: FromCdkAppDirectoryOptions = {}): Promise { + const cdkJsonPath = path.join(appDirectory, 'cdk.json'); + + let cdkJsonSettings: Settings; + try { + const cdkJson = JSON.parse(await fs.promises.readFile(cdkJsonPath, 'utf-8')); + cdkJsonSettings = new Settings(cdkJson); + } catch (e: any) { + if (e.code === 'ENOENT') { + throw ToolkitError.withCause('CdkJsonMissing', `fromCdkAppDirectory: cdk.json not found at ${cdkJsonPath}`, e); + } + throw ToolkitError.withCause('CdkJsonInvalid', `fromCdkAppDirectory: error reading ${cdkJsonPath}`, e); + } + + const app = cdkJsonSettings.get(['app']); + if (!app) { + throw new ToolkitError('CdkJsonIncomplete', `fromCdkAppDirectory: missing "app" field in ${cdkJsonPath}`); + } + const output = cdkJsonSettings.get(['output']); + const outdir = path.resolve(appDirectory, output ?? 'cdk.out'); + + // Combine settings from cdk.json and the ones we received from the synth options (mimicking CLI command-line overrides) + const mergedSettings = cdkJsonSettings.merge(settingsFromSynthOptions(props.synthOptions, 'onlyThese')); + const synthParameters = synthParametersFromSettings(mergedSettings); + + return this.makeContextAwareCloudAssemblySource({ + outdir, + contextStore: new CdkAppMultiContext(appDirectory), + lookups: props.lookups, + resolveDefaultEnvironment: props.resolveDefaultEnvironment, + synthParameters, + baseEnv: { + // Need to start with full env of `writeContextToEnv` will not be able to do the size + // calculation correctly. + ...process.env, + // User gave us something + ...props.env, + }, + disposeOutdir: false, + performSynth: performSynthByCallingSubprocess(app, appDirectory, props.loadAssemblyOptions), + }); + } + /** + * Pull together the shared logic for synthesizing a Cloud Assembly from various settings. + * + * This method consolidates the common that otherwise go into `fromAssemblyBuilder`, `fromCdkApp`, and `fromCdkAppDirectory`, + * parameterized by their tiny differences. + */ + private async makeContextAwareCloudAssemblySource(options: ContextAwareCloudAssemblySourceOptions): Promise { + const services = await this.sourceBuilderServices(); + const outdir = options.outdir; + const contextStore = options.contextStore; const contextAssemblyProps: ContextAwareCloudAssemblyProps = { services, contextStore, - lookups: props.lookups, + lookups: options.lookups, }; return new ContextAwareCloudAssemblySource( { produce: async (produceOptions) => { - try { - fs.mkdirpSync(outdir); - } catch (e: any) { - throw new ToolkitError('OutputDirectoryCreateFailed', `Could not create output directory at '${outdir}' (${e.message}).`); - } - await using execution = await ExecutionEnvironment.create(services, { outdir, - resolveDefaultAppEnv: props.resolveDefaultEnvironment ?? true, + resolveDefaultAppEnv: options.resolveDefaultEnvironment ?? true, }); - const commandLine = await execution.guessExecutable(app); - - const synthParams = parametersFromSynthOptions(props.synthOptions); - const fullContext = { ...await contextStore.read(), - ...synthParams.context, + ...options.synthParameters.context, }; await services.ioHelper.defaults.debug(format('context:', fullContext)); const env = noUndefined({ - // Need to start with full env of `writeContextToEnv` will not be able to do the size - // calculation correctly. - ...process.env, - // User gave us something - ...props.env, + ...options.baseEnv, // Versioning, outdir, default account and region ...await execution.defaultEnvVars(), // Environment variables derived from settings - ...synthParams.env, + ...options.synthParameters.env, }); - const cleanupTemp = writeContextToEnv(env, fullContext, 'env-is-complete'); + + const cleanupContextOverflow = writeContextToEnv(env, fullContext, 'env-is-complete'); try { - await execInChildProcess(commandLine, { - abort: produceOptions?.abort, - eventPublisher: async (type, line) => { - switch (type) { - case 'data_stdout': - await services.ioHelper.notify(IO.CDK_ASSEMBLY_I1001.msg(line)); - break; - case 'data_stderr': - await services.ioHelper.notify(IO.CDK_ASSEMBLY_E1002.msg(line)); - break; - } - }, + const asm = await options.performSynth({ + execution, + fullContext, env, - cwd: workingDirectory, + abort: produceOptions?.abort, + ioHelper: services.ioHelper, }); + + const success = await execution.markSuccessful(); + const deleteOnDispose = options.disposeOutdir ?? execution.shouldDisposeOutDir; + return new ReadableCloudAssembly(asm, success.readLock, { deleteOnDispose }); } finally { - await cleanupTemp(); + await cleanupContextOverflow(); } - - const asm = await assemblyFromDirectory(outdir, services.ioHelper, props.loadAssemblyOptions); - - const success = await execution.markSuccessful(); - const deleteOnDispose = props.disposeOutdir ?? execution.shouldDisposeOutDir; - return new ReadableCloudAssembly(asm, success.readLock, { deleteOnDispose }); }, }, contextAssemblyProps, @@ -587,3 +670,45 @@ export abstract class CloudAssemblySourceBuilder { } } +interface ContextAwareCloudAssemblySourceOptions { + readonly outdir: string | undefined; + readonly contextStore: IContextStore; + readonly lookups: boolean | undefined; + readonly resolveDefaultEnvironment: boolean | undefined; + readonly synthParameters: SynthParameters; + readonly baseEnv: Record; + readonly performSynth: (options: InnerSynthOptions) => Promise; + readonly disposeOutdir: boolean | undefined; +} + +interface InnerSynthOptions { + readonly execution: ExecutionEnvironment; + readonly fullContext: SynthParameters['context']; + readonly env: Record; + readonly abort: AbortSignal | undefined; + readonly ioHelper: IoHelper; +} + +function performSynthByCallingSubprocess(app: string, workingDirectory: string, loadAssemblyOptions?: LoadAssemblyOptions): ContextAwareCloudAssemblySourceOptions['performSynth'] { + return async ({ execution, env, abort, ioHelper }) => { + const commandLine = await execution.guessExecutable(app); + + await execInChildProcess(commandLine, { + abort, + eventPublisher: async (type, line) => { + switch (type) { + case 'data_stdout': + await ioHelper.notify(IO.CDK_ASSEMBLY_I1001.msg(line)); + break; + case 'data_stderr': + await ioHelper.notify(IO.CDK_ASSEMBLY_E1002.msg(line)); + break; + } + }, + env, + cwd: workingDirectory, + }); + return assemblyFromDirectory(execution.outdir, ioHelper, loadAssemblyOptions); + }; +} + diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/source-builder.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/source-builder.test.ts index 51ba8915b..81fd6ab8d 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/source-builder.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/source-builder.test.ts @@ -1,3 +1,4 @@ +import * as os from 'os'; import * as path from 'path'; import { App } from 'aws-cdk-lib/core'; import * as fs from 'fs-extra'; @@ -248,6 +249,41 @@ describe('fromAssemblyBuilder', () => { }); describe('fromCdkApp', () => { + test('applies the default synth settings', async () => { + // The settings a CDK app receives when the caller says nothing: `fromCdkApp` + // fills in the defaults, unlike `fromCdkAppDirectory` where `cdk.json` decides. + using dir = cdkAppDirectory({}); + const cx = await toolkit.fromCdkApp('node app.js', { + workingDirectory: dir.dir, + outdir: path.join(dir.dir, 'cdk.out'), + contextStore: new MemoryContext(), + }); + + await using result = await cx.produce(); + + expect(synthInput(result.cloudAssembly.directory).context).toMatchObject({ + 'aws:cdk:enable-path-metadata': true, + 'aws:cdk:enable-asset-metadata': true, + 'aws:cdk:version-reporting': true, + 'aws:cdk:bundling-stacks': ['**'], + }); + }); + + test('creates an output directory that does not exist yet', async () => { + // The mkdir lives in `ExecutionEnvironment.create`; a missing parent must not fail synth. + using dir = cdkAppDirectory({}); + const outdir = path.join(dir.dir, 'does', 'not', 'exist', 'yet'); + const cx = await toolkit.fromCdkApp('node app.js', { + workingDirectory: dir.dir, + outdir, + contextStore: new MemoryContext(), + }); + + await using result = await cx.produce(); + + expect(result.cloudAssembly.directory).toEqual(outdir); + }); + test('defaults', async () => { // WHEN const cx = await appFixture(toolkit, 'two-empty-stacks'); @@ -557,3 +593,194 @@ describe('fromAssemblyDirectory', () => { expect(assembly.cloudAssembly.stacksRecursively.map(s => s.hierarchicalId)).toEqual(['Stack1']); }); }); + +/** + * A CDK app directory, the way a user's project looks: a `cdk.json` plus an app. + * + * The app is a plain Node script (no aws-cdk-lib) that records what synthesis + * handed it -- the context and the environment variables derived from settings -- + * next to a minimal cloud assembly, so a test can assert on the app's own view of + * its configuration instead of inferring it from a synthesized template. + */ +function cdkAppDirectory(cdkJson: Record, extraFiles: Record = {}) { + const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'cdk-app-')); + fs.writeJSONSync(path.join(dir, 'cdk.json'), { app: 'node app.js', ...cdkJson }); + fs.writeFileSync(path.join(dir, 'app.js'), [ + 'const fs = require("fs");', + 'const path = require("path");', + 'const outdir = process.env.CDK_OUTDIR;', + 'fs.mkdirSync(outdir, { recursive: true });', + 'fs.writeFileSync(path.join(outdir, "manifest.json"), JSON.stringify({ version: "0.0.0", artifacts: {} }));', + 'fs.writeFileSync(path.join(outdir, "synth-input.json"), JSON.stringify({', + ' context: JSON.parse(process.env.CDK_CONTEXT_JSON || "{}"),', + ' cdkValidation: process.env.CDK_VALIDATION ?? null,', + ' cwd: fs.realpathSync(process.cwd()),', + '}));', + ].join('\n')); + for (const [name, content] of Object.entries(extraFiles)) { + fs.writeJSONSync(path.join(dir, name), content); + } + + return { + dir, + [Symbol.dispose]: () => fs.rmSync(dir, { force: true, recursive: true }), + }; +} + +/** What the app recorded, read back out of the assembly it produced. */ +function synthInput(assemblyDir: string): { context: Record; cdkValidation: string | null; cwd: string } { + return fs.readJSONSync(path.join(assemblyDir, 'synth-input.json')); +} + +describe('fromCdkAppDirectory', () => { + test('runs the app from cdk.json, in the app directory, into cdk.out', async () => { + using app = cdkAppDirectory({}); + + const cx = await toolkit.fromCdkAppDirectory(app.dir); + await using result = await cx.produce(); + + expect(result.cloudAssembly.directory).toEqual(path.join(app.dir, 'cdk.out')); + expect(synthInput(result.cloudAssembly.directory).cwd).toEqual(app.dir); + }); + + test('honors the output directory from cdk.json', async () => { + using app = cdkAppDirectory({ output: 'other.out' }); + + const cx = await toolkit.fromCdkAppDirectory(app.dir); + await using result = await cx.produce(); + + expect(result.cloudAssembly.directory).toEqual(path.join(app.dir, 'other.out')); + }); + + test('fails with an actionable error when there is no cdk.json', async () => { + using dir = autoCleanOutDir(); + + await expect(toolkit.fromCdkAppDirectory(dir.dir)).rejects.toThrow(/cdk.json not found at/); + }); + + test('fails when cdk.json does not say how to run the app', async () => { + using app = cdkAppDirectory({}); + fs.writeJSONSync(path.join(app.dir, 'cdk.json'), { output: 'cdk.out' }); + + await expect(toolkit.fromCdkAppDirectory(app.dir)).rejects.toThrow(/missing "app" field/); + }); + + describe('context', () => { + test('is read from cdk.json', async () => { + using app = cdkAppDirectory({ context: { 'from-cdk-json': 'yes' } }); + + const cx = await toolkit.fromCdkAppDirectory(app.dir); + await using result = await cx.produce(); + + expect(synthInput(result.cloudAssembly.directory).context).toMatchObject({ 'from-cdk-json': 'yes' }); + }); + + test('is read from cdk.context.json, with cdk.json winning a conflict', async () => { + // Same precedence as the CLI: a value the user wrote in cdk.json beats a + // cached lookup in cdk.context.json. + using app = cdkAppDirectory( + { context: { shared: 'from-cdk-json' } }, + { 'cdk.context.json': { 'shared': 'from-context-json', 'only-in-context-json': 'yes' } }, + ); + + const cx = await toolkit.fromCdkAppDirectory(app.dir); + await using result = await cx.produce(); + + expect(synthInput(result.cloudAssembly.directory).context).toMatchObject({ + 'shared': 'from-cdk-json', + 'only-in-context-json': 'yes', + }); + }); + }); + + describe('settings', () => { + test('are read from cdk.json and turned into app context', async () => { + using app = cdkAppDirectory({ versionReporting: false, pathMetadata: false, staging: false }); + + const cx = await toolkit.fromCdkAppDirectory(app.dir); + await using result = await cx.produce(); + + const context = synthInput(result.cloudAssembly.directory).context; + expect(context).toMatchObject({ + 'aws:cdk:disable-version-reporting': true, + 'aws:cdk:disable-asset-staging': true, + }); + // Disabled settings are expressed by the *absence* of the enabling key. + expect(context).not.toHaveProperty('aws:cdk:enable-path-metadata'); + expect(context).not.toHaveProperty('aws:cdk:version-reporting'); + }); + + test('default to on when cdk.json says nothing', async () => { + using app = cdkAppDirectory({}); + + const cx = await toolkit.fromCdkAppDirectory(app.dir); + await using result = await cx.produce(); + + expect(synthInput(result.cloudAssembly.directory).context).toMatchObject({ + 'aws:cdk:enable-path-metadata': true, + 'aws:cdk:enable-asset-metadata': true, + 'aws:cdk:version-reporting': true, + 'aws:cdk:bundling-stacks': ['**'], + }); + }); + + test('reach the app as an environment variable where that is how the framework reads them', async () => { + using app = cdkAppDirectory({ validation: false }); + + const cx = await toolkit.fromCdkAppDirectory(app.dir, { + // The test harness sets CDK_VALIDATION itself; unset it so the only thing + // that can put it back is the setting under test. + env: { CDK_VALIDATION: undefined }, + }); + await using result = await cx.produce(); + + expect(synthInput(result.cloudAssembly.directory).cdkValidation).toEqual('false'); + }); + + test('are overridden by synthOptions, like the CLI command line overrides cdk.json', async () => { + using app = cdkAppDirectory({ versionReporting: false, validation: false }); + + const cx = await toolkit.fromCdkAppDirectory(app.dir, { + synthOptions: { versionReporting: true, validation: true }, + env: { CDK_VALIDATION: undefined }, + }); + await using result = await cx.produce(); + + const input = synthInput(result.cloudAssembly.directory); + expect(input.context).toMatchObject({ 'aws:cdk:version-reporting': true }); + expect(input.context).not.toHaveProperty('aws:cdk:disable-version-reporting'); + // Validation is back on, so nothing switches it off for the app anymore. + expect(input.cdkValidation).toBeNull(); + }); + + test('are left alone by synthOptions that do not mention them', async () => { + // `fromCdkAppDirectory` must not apply the API's own defaults on top of + // cdk.json: overriding one setting may not silently reset the others. + using app = cdkAppDirectory({ versionReporting: false, pathMetadata: false }); + + const cx = await toolkit.fromCdkAppDirectory(app.dir, { + synthOptions: { versionReporting: true }, + }); + await using result = await cx.produce(); + + const context = synthInput(result.cloudAssembly.directory).context; + expect(context).toMatchObject({ 'aws:cdk:version-reporting': true }); + expect(context).not.toHaveProperty('aws:cdk:enable-path-metadata'); + }); + + test('are not clobbered by a synth option that is explicitly undefined', async () => { + // Callers forwarding their own optional props end up passing `undefined`; + // that must read as "not specified", not as "unset what cdk.json said". + using app = cdkAppDirectory({ versionReporting: false }); + + const cx = await toolkit.fromCdkAppDirectory(app.dir, { + synthOptions: { versionReporting: undefined }, + }); + await using result = await cx.produce(); + + expect(synthInput(result.cloudAssembly.directory).context).toMatchObject({ + 'aws:cdk:disable-version-reporting': true, + }); + }); + }); +});