packages/utilities/mdaa-config/lib/config.ts (329 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import { CfnParameter, CfnParameterProps, Stack } from 'aws-cdk-lib';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';
// nosemgrep
import path = require('path');
// nosemgrep
import XRegExp = require('xregexp');
export type ConfigurationElement = { [key: string]: unknown };
export type TagElement = { [key: string]: string };
export type Workspace = {
name: string;
location: string;
};
type TransformResult = string | number;
export interface IMdaaConfigValueTransformer {
transformValue(value: string, contextPath?: string): TransformResult;
}
export interface IMdaaConfigTransformer {
transformConfig(config: ConfigurationElement): ConfigurationElement;
}
/**
* A utility class which executs transformer functions against MDAA Configs.
*/
export class MdaaConfigTransformer implements IMdaaConfigTransformer {
private readonly valueTransformer: IMdaaConfigValueTransformer;
private readonly keyTransformer?: IMdaaConfigValueTransformer;
constructor(valueTransformer: IMdaaConfigValueTransformer, keyTransformer?: IMdaaConfigValueTransformer) {
this.valueTransformer = valueTransformer;
this.keyTransformer = keyTransformer;
}
public transformConfig(config: ConfigurationElement): ConfigurationElement {
return this.transformConfigObject('/', config);
}
/**
* A recursive function which applies a transformation function to all config values.
* @param contextPath
* @param resolvedConfig The config object being transformed
* @returns A config object with the transformation function applied to all config values.
*/
public transformConfigObject(contextPath: string, resolvedConfig: ConfigurationElement): ConfigurationElement {
const transformedConfig: ConfigurationElement = {};
for (const key in resolvedConfig) {
const value = resolvedConfig[key];
const transformedKey = this.keyTransformer
? this.keyTransformer.transformValue(key, contextPath + '/' + key)
: key;
if (typeof value === 'string' || value instanceof String)
transformedConfig[transformedKey] = this.valueTransformer.transformValue(
value.toString(),
contextPath + '/' + key,
);
else if (value instanceof Array)
transformedConfig[transformedKey] = this.transformConfigArray(contextPath + '/' + key, value);
else if (value instanceof Object) {
transformedConfig[transformedKey] = this.transformConfigObject(
contextPath + '/' + key,
value as ConfigurationElement,
);
} else transformedConfig[transformedKey] = value;
}
return transformedConfig;
}
/**
* A helper function for transformConfigObject for use with Arrays.
* @param contextPath
* @param resolvedConfig (Required) - The config object being transformed
* @returns A config object with the transformation function applied to all config values.
*/
public transformConfigArray(contextPath: string, resolvedConfig: unknown[]): unknown[] {
const transformedConfig: ConfigurationElement | unknown[] = [];
resolvedConfig.forEach(value => {
if (typeof value === 'string' || value instanceof String)
transformedConfig.push(this.valueTransformer.transformValue(value.toString(), contextPath));
else if (value instanceof Array)
transformedConfig.push(this.transformConfigArray(contextPath, value as unknown[]));
else if (value instanceof Object)
transformedConfig.push(this.transformConfigObject(contextPath, value as ConfigurationElement));
else transformedConfig.push(value);
});
return transformedConfig;
}
}
export class ConfigConfigPathValueTransformer implements IMdaaConfigValueTransformer {
private baseDir: string;
constructor(baseDir: string) {
this.baseDir = baseDir;
}
public transformValue(value: string): string {
if (value.startsWith('../')) {
// Resolve to baseDir's parent path
// nosemgrep
return path.resolve(this.baseDir, value);
} else if (value.startsWith('./')) {
// Resolve relative to baseDir
// nosemgrep
return path.resolve(value.replace(/^\./, this.baseDir));
} else {
return value;
}
}
}
export class MdaaConfigSSMValueTransformer implements IMdaaConfigValueTransformer {
public transformValue(value: string, contextPath: string): string {
const ignorePaths = ['policyDocument/Statement/Action'];
if (
value.startsWith('ssm:') &&
ignorePaths.every(ignorePath => !contextPath.toLowerCase().endsWith(ignorePath.toLowerCase()))
) {
const paramName = value.replace(/^ssm:\s*/, '');
return `{{resolve:ssm:${paramName}}}`;
} else {
return value;
}
}
}
export interface MdaaConfigRefValueTransformerProps {
readonly org: string;
readonly domain: string;
readonly env: string;
readonly module_name: string;
readonly scope?: Construct;
readonly context?: ConfigurationElement;
}
export class MdaaConfigRefValueTransformer implements IMdaaConfigValueTransformer {
protected props: MdaaConfigRefValueTransformerProps;
constructor(props: MdaaConfigRefValueTransformerProps) {
this.props = props;
}
public transformValue(value: string): TransformResult {
const refMatch = XRegExp.matchRecursive(value, '{{', '}}', 'g', {
unbalanced: 'skip',
});
if (refMatch.length > 0) {
return this.parseRef(value, refMatch);
} else {
return value;
}
}
protected parseRef(value: string, refMatch: string[]): string | number {
const refMap: { [refInner: string]: string | undefined } = {
org: this.props.org,
env: this.props.env,
domain: this.props.domain,
module_name: this.props.module_name,
partition: this.props.scope ? Stack.of(this.props.scope).partition : undefined,
region: this.props.scope ? Stack.of(this.props.scope).region : undefined,
account: this.props.scope ? Stack.of(this.props.scope).account : undefined,
};
// In all other cases, return a recursively substituted string
let toReturn: string = value;
refMatch.forEach(ref => {
let resolvedValue: string | undefined;
const refInner = this.transformValue(ref).toString();
if (refMap[refInner]) {
resolvedValue = refMap[refInner];
} else if (refInner.startsWith('context:')) {
resolvedValue = this.parseContext(refInner) as string;
} else if (refInner.startsWith('env_var:')) {
const envVar = refInner.replace(/^env_var:/, '');
resolvedValue = process.env[envVar];
} else if (refInner.startsWith('resolve:ssm:')) {
const ssmPath = refInner.replace(/^resolve:ssm:/, '');
if (!this.props.scope) {
throw new Error('Unable to resolve ssm param outside of a Construct');
}
resolvedValue = this.props.scope?.node.tryGetContext('@mdaaLookupSSMValues')
? StringParameter.valueFromLookup(Stack.of(this.props.scope), ssmPath)
: StringParameter.valueForStringParameter(Stack.of(this.props.scope), ssmPath);
}
toReturn = resolvedValue ? toReturn.replace(`{{${ref}}}`, resolvedValue) : toReturn;
});
return toReturn;
}
private parseContext(refInner: string): unknown {
const refInnerContext = refInner.replace(/^context:/, '');
const scopeContextValue: unknown = this.props.scope?.node.tryGetContext(refInnerContext);
const scopeInnerContextValue = this.props.context ? this.props.context[refInnerContext] : undefined;
const contextValue = scopeContextValue ? scopeContextValue : scopeInnerContextValue;
if (!contextValue) {
throw new Error(`Failed to resolve context: ${refInnerContext}`);
}
if (typeof contextValue === 'string') {
if (contextValue.startsWith('obj:')) {
return JSON.parse(JSON.parse(contextValue.replace(/^obj:/, ''))) as ConfigurationElement;
} else if (contextValue.startsWith('list:')) {
return JSON.parse(JSON.parse(contextValue.replace(/^list:/, ''))) as string[];
}
}
return contextValue;
}
}
export interface MdaaConfigParamRefValueTransformerProps extends MdaaConfigRefValueTransformerProps {
readonly serviceCatalogConfig?: MdaaServiceCatalogProductConfig;
}
export class MdaaConfigParamRefValueTransformer extends MdaaConfigRefValueTransformer {
private readonly serviceCatalogConfig?: MdaaServiceCatalogProductConfig;
constructor(props: MdaaConfigParamRefValueTransformerProps) {
super(props);
this.serviceCatalogConfig = props.serviceCatalogConfig;
}
protected override parseRef(value: string, refMatch: string[]): string | number {
/**
* Handle base case where we resolve and return a single naked parameter value,
* Important as we need to avoid building a string if we have a standalone numerical value
*/
const standaloneParam = this.resolveStandaloneParam(value, refMatch);
if (standaloneParam) {
return standaloneParam;
}
// In all other cases, return a recursively substituted string
let toReturn: string = value;
refMatch.forEach(ref => {
let resolvedValue: string | undefined;
const refInner = this.transformValue(ref).toString();
if (refInner.startsWith('param:') && this.props.scope instanceof Stack) {
resolvedValue = this.createParam(refInner).toString();
}
toReturn = resolvedValue ? toReturn.replace(`{{${ref}}}`, resolvedValue) : toReturn;
});
return toReturn;
}
/**
* Resolve standalone parameters with no other content in the value.
* "Standalone" means it is not embedded in a string value.
* Important as this can be a case where a number should be returned instead of a string.
*
* @param value
* @param refMatch
* @returns
*/
private resolveStandaloneParam(value: string, refMatch: string[]): string | number | undefined {
if (refMatch.length === 1) {
const strippedValue = value.replace(`{{${refMatch[0]}}}`, '').trim();
if (strippedValue.length === 0) {
const refInner = this.transformValue(refMatch[0]);
if (typeof refInner === 'string') {
if (refInner.startsWith('param:') && this.props.scope instanceof Stack) {
return this.createParam(refInner);
}
}
}
}
return undefined;
}
/**
* Create new or resolve existing parameter for a given parameter reference
* @param refInner
* @returns Value of CfnParameter
*/
private createParam(refInner: string): string | number {
if (!this.props.scope) {
throw new Error('Unable to create parameters outside of a Construct');
}
const stack = Stack.of(this.props.scope);
const paramBase = refInner.replace(/^param:/, '');
const paramName = paramBase
.replace(/^string:/, '')
.replace(/^number:/, '')
.replace(/^list:/, '');
const paramProps = this.getParamProps(paramName);
const exists = stack.node.tryFindChild(paramName) as CfnParameter;
// Return values for existing parameter if it already exists
if (exists?.type) {
if (this.isStringType(exists.type)) return exists.valueAsString;
else if (this.isNumberType(exists.type)) return exists.valueAsNumber;
else if (this.isListType(exists.type)) return exists.valueAsList.join(',');
}
// If parameter exists, but we weren't able to infer a type, return it as a string
if (exists) {
return exists.valueAsString;
}
// If parameter properties are present, use them to infer the parameter type if possible
if (paramProps?.type) {
return this.createParamUsingProps(paramName, paramProps.type, paramProps);
}
// If no paramProps type was found, create a new parameter based on type labels if present
return this.createParamUsingTypeLabels(paramBase, paramName, paramProps);
}
private createParamUsingProps(paramName: string, paramType: string, paramProps: CfnParameterProps) {
if (!this.props.scope) {
throw new Error('Unable to create params outside of a Construct');
}
if (this.isNumberType(paramType)) {
return new CfnParameter(this.props.scope, paramName, paramProps).valueAsNumber;
} else if (this.isStringType(paramType)) {
return new CfnParameter(this.props.scope, paramName, paramProps).valueAsString;
} else if (this.isListType(paramType)) {
return new CfnParameter(this.props.scope, paramName, paramProps).valueAsList.join(',');
} else {
throw new Error(
`Invalid parameter type passed to paramProps: "${paramType}". Type must be one of ['String', 'Number', 'CommaDelimitedList']`,
);
}
}
protected createParamUsingTypeLabels(
paramBase: string,
paramName: string,
paramProps: CfnParameterProps | undefined,
) {
if (!this.props.scope) {
throw new Error('Unable to create params outside of a Construct');
}
if (paramBase.startsWith('string:')) {
const typedProps = { ...paramProps, type: 'String' };
return new CfnParameter(this.props.scope, paramName, typedProps).valueAsString;
} else if (paramBase.startsWith('number')) {
const typedProps = { ...paramProps, type: 'Number' };
return new CfnParameter(this.props.scope, paramName, typedProps).valueAsNumber;
} else if (paramBase.startsWith('list')) {
const typedProps = { ...paramProps, type: 'CommaDelimitedList' };
return new CfnParameter(this.props.scope, paramName, typedProps).valueAsList.join(',');
}
// If no type is specified in paramProps, then assume that the parameter is a string
return new CfnParameter(this.props.scope, paramName, paramProps).valueAsString;
}
/**
* Whether the given parameter type is a list type
* Follows conventions of CfnParameter internal functions
*/
private isListType(type: string) {
return type.indexOf('List<') >= 0 || type.indexOf('CommaDelimitedList') >= 0;
}
/**
* Whether the given parameter type is a number type
* Follows conventions of CfnParameter internal functions
*/
private isNumberType(type: string) {
return type === 'Number';
}
/**
* Whether the given parameter type is a string type
* Follows conventions of CfnParameter internal functions
*/
private isStringType(type: string) {
return !this.isListType(type) && !this.isNumberType(type);
}
private getParamProps(paramName: string): CfnParameterProps | undefined {
if (this.serviceCatalogConfig?.parameters?.[paramName]?.props) {
return this.serviceCatalogConfig.parameters[paramName].props;
}
return undefined;
}
}
export interface MdaaCustomAspect {
readonly aspect_module: string;
readonly aspect_class: string;
readonly aspect_props?: ConfigurationElement;
}
export interface MdaaCustomNaming {
readonly naming_module: string;
readonly naming_class: string;
readonly naming_props?: ConfigurationElement;
}
export interface MdaaNagSuppressionConfigs {
readonly by_path: MdaaNagSuppressionByPath[];
}
export interface MdaaNagSuppressionByPath {
readonly path: string;
readonly suppressions: {
readonly id: string;
readonly reason: string;
}[];
}
export interface MdaaServiceCatalogConstraintRuleAssertionConfig {
readonly assert: string;
readonly description: string;
}
// It seems we need this empty interface in the schema even though no one uses it
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface MdaaServiceCatalogConstraintRuleCondititionConfig {}
export interface MdaaServiceCatalogConstraintRuleConfig {
readonly condition: MdaaServiceCatalogConstraintRuleCondititionConfig;
readonly assertions: MdaaServiceCatalogConstraintRuleAssertionConfig[];
}
export interface MdaaServiceCatalogConstraintConfig {
readonly description: string;
readonly rules: { [key: string]: MdaaServiceCatalogConstraintRuleConfig };
}
export interface MdaaServiceCatalogParameterConfig {
props: CfnParameterProps;
constraints?: MdaaServiceCatalogConstraintConfig;
}
export interface MdaaServiceCatalogProductConfig {
portfolio_arn: string;
owner: string;
name: string;
launch_role_name?: string;
parameters?: { [key: string]: MdaaServiceCatalogParameterConfig };
}