packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts (224 lines of code) (raw):
import * as fs from 'fs';
import * as jsonschema from 'jsonschema';
import * as semver from 'semver';
import type * as assets from './assets';
import * as assembly from './cloud-assembly';
import type * as integ from './integ-tests';
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-require-imports */
// this prefix is used by the CLI to identify this specific error.
// in which case we want to instruct the user to upgrade his CLI.
// see exec.ts#createAssembly
export const VERSION_MISMATCH: string = 'Cloud assembly schema version mismatch';
/**
* CLI version is created at build and release time
*
* It needs to be .gitignore'd, otherwise the projen 'no uncommitted
* changes' self-check will fail, which means it needs to be generated
* at build time if it doesn't already exist.
*/
import CLI_VERSION = require('../cli-version.json');
import ASSETS_SCHEMA = require('../schema/assets.schema.json');
import ASSEMBLY_SCHEMA = require('../schema/cloud-assembly.schema.json');
import INTEG_SCHEMA = require('../schema/integ.schema.json');
/**
* Version is shared for both manifests
*/
import SCHEMA_VERSION = require('../schema/version.json');
/**
* Options for the loadManifest operation
*/
export interface LoadManifestOptions {
/**
* Skip the version check
*
* This means you may read a newer cloud assembly than the CX API is designed
* to support, and your application may not be aware of all features that in use
* in the Cloud Assembly.
*
* @default false
*/
readonly skipVersionCheck?: boolean;
/**
* Skip enum checks
*
* This means you may read enum values you don't know about yet. Make sure to always
* check the values of enums you encounter in the manifest.
*
* @default false
*/
readonly skipEnumCheck?: boolean;
/**
* Topologically sort all artifacts
*
* This parameter is only respected by the constructor of `CloudAssembly`. The
* property lives here for backwards compatibility reasons.
*
* @default true
*/
readonly topoSort?: boolean;
}
/**
* Protocol utility class.
*/
export class Manifest {
/**
* Validates and saves the cloud assembly manifest to file.
*
* @param manifest - manifest.
* @param filePath - output file path.
*/
public static saveAssemblyManifest(manifest: assembly.AssemblyManifest, filePath: string) {
Manifest.saveManifest(manifest, filePath, ASSEMBLY_SCHEMA, Manifest.patchStackTagsOnWrite);
}
/**
* Load and validates the cloud assembly manifest from file.
*
* @param filePath - path to the manifest file.
*/
public static loadAssemblyManifest(
filePath: string,
options?: LoadManifestOptions,
): assembly.AssemblyManifest {
return Manifest.loadManifest(filePath, ASSEMBLY_SCHEMA, Manifest.patchStackTagsOnRead, options);
}
/**
* Validates and saves the asset manifest to file.
*
* @param manifest - manifest.
* @param filePath - output file path.
*/
public static saveAssetManifest(manifest: assets.AssetManifest, filePath: string) {
Manifest.saveManifest(manifest, filePath, ASSETS_SCHEMA, Manifest.patchStackTagsOnRead);
}
/**
* Load and validates the asset manifest from file.
*
* @param filePath - path to the manifest file.
*/
public static loadAssetManifest(filePath: string): assets.AssetManifest {
return this.loadManifest(filePath, ASSETS_SCHEMA);
}
/**
* Validates and saves the integ manifest to file.
*
* @param manifest - manifest.
* @param filePath - output file path.
*/
public static saveIntegManifest(manifest: integ.IntegManifest, filePath: string) {
Manifest.saveManifest(manifest, filePath, INTEG_SCHEMA);
}
/**
* Load and validates the integ manifest from file.
*
* @param filePath - path to the manifest file.
*/
public static loadIntegManifest(filePath: string): integ.IntegManifest {
const manifest = this.loadManifest(filePath, INTEG_SCHEMA);
// Adding typing to `validate()` led to `loadManifest()` to properly infer
// its return type, which indicated that the return type of this
// function may be a lie. I could change the schema to make `testCases`
// optional, but that will bump the major version of this package and I
// don't want to do that. So instead, just make sure `testCases` is always there.
return {
...manifest,
testCases: (manifest as any).testCases ?? [],
};
}
/**
* Fetch the current schema version number.
*/
public static version(): string {
return `${SCHEMA_VERSION.revision}.0.0`;
}
/**
* Return the CLI version that supports this Cloud Assembly Schema version
*/
public static cliVersion(): string | undefined {
const version = CLI_VERSION.version;
return version ? version : undefined;
}
/**
* Deprecated
* @deprecated use `saveAssemblyManifest()`
*/
public static save(manifest: assembly.AssemblyManifest, filePath: string) {
return this.saveAssemblyManifest(manifest, filePath);
}
/**
* Deprecated
* @deprecated use `loadAssemblyManifest()`
*/
public static load(filePath: string): assembly.AssemblyManifest {
return this.loadAssemblyManifest(filePath);
}
private static validate(
manifest: any,
schema: jsonschema.Schema,
options?: LoadManifestOptions,
): asserts manifest is assembly.AssemblyManifest {
function parseVersion(version: string) {
const ver = semver.valid(version);
if (!ver) {
throw new Error(`Invalid semver string: "${version}"`);
}
return ver;
}
const maxSupported = semver.major(parseVersion(Manifest.version()));
const actual = parseVersion(manifest.version);
// first validate the version should be accepted. all versions within the same minor version are fine
if (maxSupported < semver.major(actual) && !options?.skipVersionCheck) {
// If we have a more specific error to throw than the generic one below, make sure to add that info.
const cliVersion = (manifest as assembly.AssemblyManifest).minimumCliVersion;
let cliWarning = '';
if (cliVersion) {
cliWarning = `. You need at least CLI version ${cliVersion} to read this manifest.`;
}
// we use a well known error prefix so that the CLI can identify this specific error
// and print some more context to the user.
throw new Error(
`${VERSION_MISMATCH}: Maximum schema version supported is ${maxSupported}.x.x, but found ${actual}${cliWarning}`,
);
}
// now validate the format is good.
const validator = new jsonschema.Validator();
const result = validator.validate(manifest, schema, {
// does exist but is not in the TypeScript definitions
nestedErrors: true,
allowUnknownAttributes: false,
preValidateProperty: Manifest.validateAssumeRoleAdditionalOptions,
});
let errors = result.errors;
if (options?.skipEnumCheck) {
// Enum validations aren't useful when
errors = stripEnumErrors(errors);
}
if (errors.length > 0) {
throw new Error(`Invalid assembly manifest:\n${errors.map((e) => e.stack).join('\n')}`);
}
}
private static saveManifest(
manifest: any,
filePath: string,
schema: jsonschema.Schema,
preprocess?: (obj: any) => any,
) {
let withVersion = {
...manifest,
version: Manifest.version(),
minimumCliVersion: Manifest.cliVersion(),
} satisfies assembly.AssemblyManifest;
Manifest.validate(withVersion, schema);
if (preprocess) {
withVersion = preprocess(withVersion);
}
fs.writeFileSync(filePath, JSON.stringify(withVersion, undefined, 2));
}
private static loadManifest(
filePath: string,
schema: jsonschema.Schema,
preprocess?: (obj: any) => any,
options?: LoadManifestOptions,
) {
const contents = fs.readFileSync(filePath, { encoding: 'utf-8' });
let obj;
try {
obj = JSON.parse(contents);
} catch (e: any) {
throw new Error(`${e.message}, while parsing ${JSON.stringify(contents)}`);
}
if (preprocess) {
obj = preprocess(obj);
}
Manifest.validate(obj, schema, options);
return obj;
}
/**
* This requires some explaining...
*
* We previously used `{ Key, Value }` for the object that represents a stack tag. (Notice the casing)
* @link https://github.com/aws/aws-cdk/blob/v1.27.0/packages/aws-cdk/lib/api/cxapp/stacks.ts#L427.
*
* When that object moved to this package, it had to be JSII compliant, which meant the property
* names must be `camelCased`, and not `PascalCased`. This meant it no longer matches the structure in the `manifest.json` file.
* In order to support current manifest files, we have to translate the `PascalCased` representation to the new `camelCased` one.
*
* Note that the serialization itself still writes `PascalCased` because it relates to how CloudFormation expects it.
*
* Ideally, we would start writing the `camelCased` and translate to how CloudFormation expects it when needed. But this requires nasty
* backwards-compatibility code and it just doesn't seem to be worth the effort.
*/
private static patchStackTagsOnRead(this: void, manifest: assembly.AssemblyManifest) {
return Manifest.replaceStackTags(manifest, (tags) =>
tags.map((diskTag: any) => ({
key: diskTag.Key,
value: diskTag.Value,
})),
);
}
/**
* Validates that `assumeRoleAdditionalOptions` doesn't contain nor `ExternalId` neither `RoleArn`, as they
* should have dedicated properties preceding this (e.g `assumeRoleArn` and `assumeRoleExternalId`).
*/
private static validateAssumeRoleAdditionalOptions(
this: void,
instance: any,
key: string,
_schema: jsonschema.Schema,
_options: jsonschema.Options,
_ctx: jsonschema.SchemaContext,
) {
if (key !== 'assumeRoleAdditionalOptions') {
// note that this means that if we happen to have a property named like this, but that
// does want to allow 'RoleArn' or 'ExternalId', this code will have to change to consider the full schema path.
// I decided to make this less granular for now on purpose because it fits our needs and avoids having messy
// validation logic due to various schema paths.
return;
}
const assumeRoleOptions = instance[key];
if (assumeRoleOptions?.RoleArn) {
throw new Error(`RoleArn is not allowed inside '${key}'`);
}
if (assumeRoleOptions?.ExternalId) {
throw new Error(`ExternalId is not allowed inside '${key}'`);
}
}
/**
* See explanation on `patchStackTagsOnRead`
*
* Translate stack tags metadata if it has the "right" casing.
*/
private static patchStackTagsOnWrite(this: void, manifest: assembly.AssemblyManifest) {
return Manifest.replaceStackTags(manifest, (tags) =>
tags.map(
(memTag) =>
// Might already be uppercased (because stack synthesis generates it in final form yet)
('Key' in memTag ? memTag : { Key: memTag.key, Value: memTag.value }) as any,
),
);
}
/**
* Recursively replace stack tags in the stack metadata
*/
private static replaceStackTags(
manifest: assembly.AssemblyManifest,
fn: Endofunctor<assembly.StackTagsMetadataEntry>,
): assembly.AssemblyManifest {
// Need to add in the `noUndefined`s because otherwise jest snapshot tests are going to freak out
// about the keys with values that are `undefined` (even though they would never be JSON.stringified)
return noUndefined({
...manifest,
artifacts: mapValues(manifest.artifacts, (artifact) => {
if (artifact.type !== assembly.ArtifactType.AWS_CLOUDFORMATION_STACK) {
return artifact;
}
return noUndefined({
...artifact,
metadata: mapValues(artifact.metadata, (metadataEntries) =>
metadataEntries.map((metadataEntry) => {
if (
metadataEntry.type !== assembly.ArtifactMetadataEntryType.STACK_TAGS ||
!metadataEntry.data
) {
return metadataEntry;
}
return {
...metadataEntry,
data: fn(metadataEntry.data as assembly.StackTagsMetadataEntry),
};
}),
),
} as assembly.ArtifactManifest);
}),
});
}
private constructor() {
}
}
type Endofunctor<A> = (x: A) => A;
function mapValues<A, B>(
xs: Record<string, A> | undefined,
fn: (x: A) => B,
): Record<string, B> | undefined {
if (!xs) {
return undefined;
}
const ret: Record<string, B> | undefined = {};
for (const [k, v] of Object.entries(xs)) {
ret[k] = fn(v);
}
return ret;
}
function noUndefined<A extends object>(xs: A): A {
const ret: any = {};
for (const [k, v] of Object.entries(xs)) {
if (v !== undefined) {
ret[k] = v;
}
}
return ret;
}
function stripEnumErrors(errors: jsonschema.ValidationError[]) {
return errors.filter((e) => typeof e.schema === 'string' || !('enum' in e.schema));
}