diff --git a/CHANGELOG.md b/CHANGELOG.md index 23d81ad5..627770e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog All notable changes to aws organization formation will be documented in this file. +**version 0.9.13** +- Added a new command: `print-tasks`, which will generate all cloudformation templates and write to disk. +- Added `zip-before-put` support to `copy-to-s3` task. +- Added support for `!ReadFile` and `!JsonString` inside CloudFormation templates. +- Added functions `!MD5Dir` and `!MD5File`, which can be used in both task files and cloudformation. +- Added psuedo parameter `ORG::StateBucketName`. +- Optimized build time by locally skipping resource providers if task did not change. +- Updated codebuild image used to create new pipelines with to standard:4.0. +Note: If you are running a pipeline generated by org-formation, you might want to update the build image for faster provisioning time! + + **version 0.9.12** - Allow failure tolerance to be set to 0 on validate-tasks command (allows CI/CD processes to fail on validation) - Added support for `Mappings` section / `!FindInMap` / `!Select` for task files. diff --git a/cli-program.ts b/cli-program.ts index 373ca1a2..07493691 100644 --- a/cli-program.ts +++ b/cli-program.ts @@ -14,6 +14,7 @@ import { ValidateTasksCommand, RemoveCommand, } from '~commands/index'; +import { PrintTasksCommand } from '~commands/print-tasks'; export class CliProgram { @@ -48,6 +49,7 @@ export class CliProgram { new InitPipelineCommand(this.program); new InitOrganizationCommand(this.program); new PerformTasksCommand(this.program); + new PrintTasksCommand(this.program); new PrintStacksCommand(this.program); new UpdateOrganizationCommand(this.program); new UpdateStacksCommand(this.program); diff --git a/examples/lambda-using-read-file/lambda-using-read-file.yml b/examples/lambda-using-read-file/lambda-using-read-file.yml new file mode 100644 index 00000000..e91ac0d0 --- /dev/null +++ b/examples/lambda-using-read-file/lambda-using-read-file.yml @@ -0,0 +1,29 @@ + +AWSTemplateFormatVersion: 2010-09-09-OC +Description: Org formation example + +Resources: + MyRole: + Type: AWS::IAM::Role + Properties: + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - 'sts:AssumeRole' + + MyLambda: + Type: AWS::Lambda::Function + Properties: + FunctionName: 'org-formation-example-lambda-using-read-file' + Code: + ZipFile: !ReadFile './src/index.js' + Handler: index.handler + Role: !GetAtt MyRole.Arn + Runtime: nodejs12.x diff --git a/examples/lambda-using-read-file/organization-tasks.yml b/examples/lambda-using-read-file/organization-tasks.yml new file mode 100644 index 00000000..661df37e --- /dev/null +++ b/examples/lambda-using-read-file/organization-tasks.yml @@ -0,0 +1,15 @@ +# this example uses features that are part of the 0.9.13 release + +OrganizationUpdate: + Type: update-organization + Skip: true + Template: ./organization.yml + +DeployCodeAndLambda: + Type: update-stacks + Template: ./lambda-using-read-file.yml + StackName: org-formation-example-lambda-using-read-file + DefaultOrganizationBinding: + Account: !Ref AccountA + Region: eu-central-1 + diff --git a/examples/lambda-using-read-file/organization.yml b/examples/lambda-using-read-file/organization.yml new file mode 100644 index 00000000..0c99977e --- /dev/null +++ b/examples/lambda-using-read-file/organization.yml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: '2010-09-09-OC' + +Organization: + MasterAccount: + Type: OC::ORG::MasterAccount + Properties: + AccountId: '102625093955' + RootEmail: org-master@olafconijn.awsapps.com + AccountName: Organization Master Account + + AccountA: + Type: OC::ORG::Account + Properties: + RootEmail: account+a@olafconijn.awsapps.com + AccountName: Account A diff --git a/examples/lambda-using-read-file/src/index.js b/examples/lambda-using-read-file/src/index.js new file mode 100644 index 00000000..09edf102 --- /dev/null +++ b/examples/lambda-using-read-file/src/index.js @@ -0,0 +1,5 @@ + +exports.handler = function (event, context) { + console.log(event); + context.succeed('hello ' + event.name); +}; diff --git a/examples/lambda-using-uploaded-zip/lambda-template-using-zip.yml b/examples/lambda-using-uploaded-zip/lambda-template-using-zip.yml new file mode 100644 index 00000000..6a14a51d --- /dev/null +++ b/examples/lambda-using-uploaded-zip/lambda-template-using-zip.yml @@ -0,0 +1,40 @@ + +AWSTemplateFormatVersion: 2010-09-09-OC +Description: Org formation example + +Parameters: + + deploymentBucketName: + Type: String + Description: Name of the bucket that contains the lambda source code + + lambdaS3Key: + Type: String + Description: S3 Key that contains the location of lambda source code + +Resources: + MyRole: + Type: AWS::IAM::Role + Properties: + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - 'sts:AssumeRole' + + MyLambda: + Type: AWS::Lambda::Function + Properties: + FunctionName: 'org-formation-example-lambda-using-uploaded-zip' + Code: + S3Bucket: !Ref deploymentBucketName + S3Key: !Ref lambdaS3Key + Handler: index.handler + Role: !GetAtt MyRole.Arn + Runtime: nodejs12.x diff --git a/examples/lambda-using-uploaded-zip/org-formation-deployment-bucket.yml b/examples/lambda-using-uploaded-zip/org-formation-deployment-bucket.yml new file mode 100644 index 00000000..dc545fc0 --- /dev/null +++ b/examples/lambda-using-uploaded-zip/org-formation-deployment-bucket.yml @@ -0,0 +1,49 @@ + +AWSTemplateFormatVersion: 2010-09-09-OC + +Parameters: + deploymentBucketName: + Type: String + + organizationPrincipalId: + Type: String + +Resources: + + OrgFormationDeploymentBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref deploymentBucketName + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + + + OrgFormationDeploymentBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref OrgFormationDeploymentBucket + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: 'OwnerAllowEverything' + Effect: 'Allow' + Principal: + AWS: !Ref AWS::AccountId + Action: 's3:*' + Resource: + - !Sub '${OrgFormationDeploymentBucket.Arn}' + - !Sub '${OrgFormationDeploymentBucket.Arn}/*' + - Sid: 'OrgAllowGetObject' + Effect: 'Allow' + Principal: '*' + Action: + - 's3:GetObject' + - 's3:GetObjectVersion' + Resource: + - !Sub '${OrgFormationDeploymentBucket.Arn}/*' + Condition: + StringEquals: + 'aws:PrincipalOrgID': + - !Ref organizationPrincipalId \ No newline at end of file diff --git a/examples/lambda-using-uploaded-zip/organization-tasks.yml b/examples/lambda-using-uploaded-zip/organization-tasks.yml new file mode 100644 index 00000000..d3726df5 --- /dev/null +++ b/examples/lambda-using-uploaded-zip/organization-tasks.yml @@ -0,0 +1,49 @@ +# this example uses features that are part of the 0.9.13 release + + +Parameters: + deploymentBucketName: + Type: String + Default: !Sub '${ORG::StateBucketName}-deployments' + +OrganizationUpdate: + Type: update-organization + Skip: true + Template: ./organization.yml + +OrgFormationUploadBucket: + Type: update-stacks + Template: ./org-formation-deployment-bucket.yml + StackName: org-formation-deployment-bucket + StackDescription: Creates a bucket that can be used by org-formation to upload artifacts and use this bucket to deploy resources across the organization + DefaultOrganizationBinding: + IncludeMasterAccount: true + Region: eu-central-1 + Parameters: + deploymentBucketName: !Ref deploymentBucketName + organizationPrincipalId: !Ref ORG::PrincipalOrgID + +DeployLambdaSourceCode: + Type: copy-to-s3 + RemotePath: !Sub + - s3://${bucket}/lambdas/my-lambda-source-${hashOfDir}.zip + - { bucket: !Ref deploymentBucketName, hashOfDir: !MD5Dir ./src } + LocalPath: ./src + ZipBeforePut: true + OrganizationBinding: + IncludeMasterAccount: true + Region: eu-central-1 + +DeployLambda: + Type: update-stacks + DependsOn: DeployLambdaSourceCode + Template: ./lambda-template-using-zip.yml + StackName: org-formation-example-lambda-using-uploaded-zip + DefaultOrganizationBinding: + Account: !Ref AccountA + Region: eu-central-1 + Parameters: + deploymentBucketName: !Ref deploymentBucketName + lambdaS3Key: !Sub + - lambdas/my-lambda-source-${hashOfDir}.zip + - { bucket: !Ref ORG::StateBucketName, hashOfDir: !MD5Dir ./src } diff --git a/examples/lambda-using-uploaded-zip/organization.yml b/examples/lambda-using-uploaded-zip/organization.yml new file mode 100644 index 00000000..0c99977e --- /dev/null +++ b/examples/lambda-using-uploaded-zip/organization.yml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: '2010-09-09-OC' + +Organization: + MasterAccount: + Type: OC::ORG::MasterAccount + Properties: + AccountId: '102625093955' + RootEmail: org-master@olafconijn.awsapps.com + AccountName: Organization Master Account + + AccountA: + Type: OC::ORG::Account + Properties: + RootEmail: account+a@olafconijn.awsapps.com + AccountName: Account A diff --git a/examples/lambda-using-uploaded-zip/src/index.js b/examples/lambda-using-uploaded-zip/src/index.js new file mode 100644 index 00000000..7fa153a7 --- /dev/null +++ b/examples/lambda-using-uploaded-zip/src/index.js @@ -0,0 +1,6 @@ +const logger = require('./logger') + +exports.handler = function (event, context) { + logger.log(event); + context.succeed('hello ' + event.name); +}; diff --git a/examples/lambda-using-uploaded-zip/src/logger.js b/examples/lambda-using-uploaded-zip/src/logger.js new file mode 100644 index 00000000..dcc0ffe2 --- /dev/null +++ b/examples/lambda-using-uploaded-zip/src/logger.js @@ -0,0 +1,4 @@ + +exports.log = function (x) { + console.log(x); +}; diff --git a/examples/lambda-using-uploaded-zip/src/package.json b/examples/lambda-using-uploaded-zip/src/package.json new file mode 100644 index 00000000..54db4d79 --- /dev/null +++ b/examples/lambda-using-uploaded-zip/src/package.json @@ -0,0 +1,11 @@ +{ + "name": "org-formation-example-lambda", + "version": "1.0.0", + "description": "", + "main": "run-local.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC" +} diff --git a/examples/lambda-using-uploaded-zip/src/run-local.js b/examples/lambda-using-uploaded-zip/src/run-local.js new file mode 100644 index 00000000..2a22635d --- /dev/null +++ b/examples/lambda-using-uploaded-zip/src/run-local.js @@ -0,0 +1,7 @@ +const lambda = require('./index'); + +const event = {name: 'me'}; +const context = { + succeed: (x) => console.log(`succeeded: ${x}`) +} +lambda.handler(event, context) \ No newline at end of file diff --git a/package.json b/package.json index a430c214..da61acd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aws-organization-formation", - "version": "0.9.13-beta.2", + "version": "0.9.13", "description": "Infrastructure as code solution for AWS Organizations", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/resources/orgformation-codepipeline.yml b/resources/orgformation-codepipeline.yml index a8d726b1..e23d70ba 100644 --- a/resources/orgformation-codepipeline.yml +++ b/resources/orgformation-codepipeline.yml @@ -264,7 +264,7 @@ Resources: Artifacts: { Type: NO_ARTIFACTS } Environment: Type: LINUX_CONTAINER - Image: aws/codebuild/standard:1.0 + Image: aws/codebuild/standard:4.0 ComputeType: BUILD_GENERAL1_SMALL ImagePullCredentialsType: CODEBUILD QueuedTimeoutInMinutes: 480 @@ -278,4 +278,4 @@ Resources: GroupName: !Ref OrgBuildLogGroup Status: ENABLED SourceVersion: refs/heads/master - TimeoutInMinutes: 60 + TimeoutInMinutes: 180 diff --git a/src/build-tasks/build-configuration.ts b/src/build-tasks/build-configuration.ts index d5c493c3..aa3a6ea2 100644 --- a/src/build-tasks/build-configuration.ts +++ b/src/build-tasks/build-configuration.ts @@ -36,6 +36,19 @@ export class BuildConfiguration { return result; } + public enumPrintTasks(command: IPerformTasksCommandArgs): IBuildTask[] { + this.fixateOrganizationFile(command); + const result: IBuildTask[] = []; + for (const taskConfig of this.tasks) { + const task = BuildTaskProvider.createPrintTask(taskConfig, command); + if (task !== undefined) { + result.push(task); + } + } + + return result; + } + public enumBuildTasks(command: IPerformTasksCommandArgs): IBuildTask[] { this.fixateOrganizationFile(command); const result: IBuildTask[] = []; diff --git a/src/build-tasks/build-runner.ts b/src/build-tasks/build-runner.ts index 7fccf418..dbff08e7 100644 --- a/src/build-tasks/build-runner.ts +++ b/src/build-tasks/build-runner.ts @@ -12,6 +12,16 @@ export class BuildRunner { }; await GenericTaskRunner.RunTasks(tasks, delegate); } + public static async RunPrintTasks(tasks: IBuildTask[], logVerbose: boolean, maxConcurrentTasks = 1, failedTasksTolerance = 0): Promise { + const delegate: ITaskRunnerDelegates = { + getName: task => `Task ${task.name}`, + getVerb: () => 'print', + maxConcurrentTasks, + failedTasksTolerance, + logVerbose, + }; + await GenericTaskRunner.RunTasks(tasks, delegate); + } public static async RunValidationTasks(tasks: IBuildTask[], logVerbose: boolean, maxConcurrentTasks = 1, failedTasksTolerance = 0): Promise { const delegate: ITaskRunnerDelegates = { getName: task => `Task ${task.name}`, diff --git a/src/build-tasks/build-task-provider.ts b/src/build-tasks/build-task-provider.ts index cf563ebc..60a7fa91 100644 --- a/src/build-tasks/build-task-provider.ts +++ b/src/build-tasks/build-task-provider.ts @@ -43,6 +43,14 @@ export class BuildTaskProvider { return validationTask; } + static createPrintTask(configuration: IBuildTaskConfiguration, command: IPerformTasksCommandArgs): IBuildTask { + const taskProvider = this.GetBuildTaskProvider(); + const provider = taskProvider.providers[configuration.Type]; + if (provider === undefined) {throw new OrgFormationError(`unable to load file ${configuration.FilePath}, unknown configuration type ${configuration.Type}`);} + const validationTask = provider.createTaskForPrint(configuration, command); + return validationTask; + } + public static createBuildTask(configuration: IBuildTaskConfiguration, command: IPerformTasksCommandArgs): IBuildTask { const taskProvider = this.GetBuildTaskProvider(); const provider = taskProvider.providers[configuration.Type]; @@ -104,5 +112,6 @@ export interface IBuildTaskProvider { type: string; createTask(config: TConfig, command: IPerformTasksCommandArgs): IBuildTask; createTaskForValidation(config: TConfig, command: IPerformTasksCommandArgs): IBuildTask | undefined; + createTaskForPrint(config: TConfig, command: IPerformTasksCommandArgs): IBuildTask | undefined; createTaskForCleanup(logicalId: string, physicalId: string, command: IPerformTasksCommandArgs): IBuildTask | undefined; } diff --git a/src/build-tasks/tasks/include-task.ts b/src/build-tasks/tasks/include-task.ts index 67a56127..9e7cb03c 100644 --- a/src/build-tasks/tasks/include-task.ts +++ b/src/build-tasks/tasks/include-task.ts @@ -5,9 +5,11 @@ import { IBuildTask, BuildConfiguration, IBuildTaskConfiguration } from '~build- import { IPerformTasksCommandArgs } from '~commands/index'; import { BuildRunner } from '~build-tasks/build-runner'; import { IBuildTaskProvider, BuildTaskProvider } from '~build-tasks/build-task-provider'; +import { IPrintTasksCommandArgs } from '~commands/print-tasks'; export class IncludeTaskProvider implements IBuildTaskProvider { + public type = 'include'; createTask(config: IIncludeTaskConfiguration, command: IPerformTasksCommandArgs): IBuildTask { @@ -72,6 +74,33 @@ export class IncludeTaskProvider implements IBuildTaskProvider = {...command.parsedParameters, ...(config.Parameters ?? {})}; + const buildConfig = new BuildConfiguration(taskFilePath, parameters); + + const commandForInclude: IPrintTasksCommandArgs = { + ...command, + verbose: typeof config.LogVerbose === 'boolean' ? config.LogVerbose : command.verbose, + }; + + const childTasks = buildConfig.enumPrintTasks(commandForInclude); + + return { + type: config.Type, + name: config.LogicalName, + skip: typeof config.Skip === 'boolean' ? config.Skip : undefined, + childTasks, + isDependency: (): boolean => false, + perform: async (): Promise => await BuildRunner.RunPrintTasks(childTasks, commandForInclude.verbose === true, config.MaxConcurrentTasks, config.FailedTaskTolerance), + }; + } + createTaskForCleanup(): IBuildTask | undefined { return undefined; } @@ -80,7 +109,6 @@ export class IncludeTaskProvider implements IBuildTaskProvider => { + const updateStacksCommand = UpdateStacksBuildTaskProvider.createUpdateStacksCommandArgs(config, command); + await PrintStacksCommand.Perform({...updateStacksCommand, stackName: config.StackName }); + }, + }; + } + createTaskForCleanup(logicalId: string, physicalId: string, command: IPerformTasksCommandArgs): IBuildTask { return { type: 'delete-stacks', diff --git a/src/cfn-binder/cfn-template.ts b/src/cfn-binder/cfn-template.ts index cea0d124..c1a3f8d8 100644 --- a/src/cfn-binder/cfn-template.ts +++ b/src/cfn-binder/cfn-template.ts @@ -1,3 +1,4 @@ +import { dump as yamlDump } from 'js-yaml'; import { ConsoleUtil } from '../util/console-util'; import { OrgFormationError } from '../org-formation-error'; import { ResourceUtil } from '../util/resource-util'; @@ -11,6 +12,7 @@ import { TemplateRoot } from '~parser/parser'; import { PersistedState } from '~state/persisted-state'; import { ICfnExpression } from '~core/cfn-expression'; import { CfnExpressionResolver } from '~core/cfn-expression-resolver'; +import { CfnFunctions, ICfnFunctionContext } from '~core/cfn-functions/cfn-functions'; export class CfnTemplate { @@ -91,6 +93,7 @@ export class CfnTemplate { private resourceIdsNotInTarget: string[]; private otherAccountsLogicalIds: string[]; private accountResource: AccountResource; + private resolverContext: ICfnFunctionContext; private masterAccountLogicalId: string; constructor(target: IResourceTarget, private templateRoot: TemplateRoot, private state: PersistedState) { @@ -113,6 +116,9 @@ export class CfnTemplate { Outputs: this.outputs, }; + this.resolverContext = { finalPass: false, filePath: this.templateRoot.filepath, mappings: {} }; + + for (const resource of target.resources) { const clonedResource = JSON.parse(JSON.stringify(resource.resourceForTemplate)); ResourceUtil.FixVersions(clonedResource); @@ -125,10 +131,10 @@ export class CfnTemplate { const binding = this.state.getAccountBinding(accountName); if (!binding) { throw new OrgFormationError(`unable to find account ${accountName} in state. Is your organization up to date?`); } - this.resources[resource.logicalId + binding.physicalId] = this._resolveOrganizationFunctions(keywordReplaced, this.accountResource); + this.resources[resource.logicalId + binding.physicalId] = this._resolveOrganizationFunctionsAndStructuralFunctions(keywordReplaced, this.accountResource); } } else { - this.resources[resource.logicalId] = this._resolveOrganizationFunctions(clonedResource, this.accountResource); + this.resources[resource.logicalId] = this._resolveOrganizationFunctionsAndStructuralFunctions(clonedResource, this.accountResource); } } @@ -138,14 +144,14 @@ export class CfnTemplate { const hasExpressionsToResourcesOutsideTarget = ResourceUtil.HasExpressions(outputs, outputName, this.resourceIdsNotInTarget); if (!hasExpressionsToResourcesOutsideTarget) { const clonedOutput = JSON.parse(JSON.stringify(outputs[outputName])); - this.outputs[outputName] = this._resolveOrganizationFunctions(clonedOutput, this.accountResource); + this.outputs[outputName] = this._resolveOrganizationFunctionsAndStructuralFunctions(clonedOutput, this.accountResource); } } for (const paramName in this.templateRoot.contents.Parameters) { const param = this.templateRoot.contents.Parameters[paramName]; const clonedParam = JSON.parse(JSON.stringify(param)); - const parameter = this._resolveOrganizationFunctions(clonedParam, this.accountResource) as ICfnParameter; + const parameter = this._resolveOrganizationFunctionsAndStructuralFunctions(clonedParam, this.accountResource) as ICfnParameter; if (parameter.ExportAccountId) { const val = (parameter.ExportAccountId as any).Ref; if (val !== undefined) { @@ -157,19 +163,18 @@ export class CfnTemplate { if (this.templateRoot.contents.Metadata) { const clonedMetadata = JSON.parse(JSON.stringify(this.templateRoot.contents.Metadata)); - this.resultingTemplate.Metadata = this._resolveOrganizationFunctions(clonedMetadata, this.accountResource); + this.resultingTemplate.Metadata = this._resolveOrganizationFunctionsAndStructuralFunctions(clonedMetadata, this.accountResource); } if (this.templateRoot.contents.Conditions) { const clonedConditions = JSON.parse(JSON.stringify(this.templateRoot.contents.Conditions)); - this.resultingTemplate.Conditions = this._resolveOrganizationFunctions(clonedConditions, this.accountResource); + this.resultingTemplate.Conditions = this._resolveOrganizationFunctionsAndStructuralFunctions(clonedConditions, this.accountResource); } if (this.templateRoot.contents.Mappings) { const clonedMappings = JSON.parse(JSON.stringify(this.templateRoot.contents.Mappings)); - this.resultingTemplate.Mappings = this._resolveOrganizationFunctions(clonedMappings, this.accountResource); + this.resultingTemplate.Mappings = this._resolveOrganizationFunctionsAndStructuralFunctions(clonedMappings, this.accountResource); } - for (const prop in this.resultingTemplate) { if (!this.resultingTemplate[prop]) { delete this.resultingTemplate[prop]; @@ -228,8 +233,20 @@ export class CfnTemplate { return parameters; } - public createTemplateBody(): string { - return JSON.stringify(this.resultingTemplate, null, 2); + public createTemplateBody(options: ITemplateGenerationOptions = {output: 'json', outputCrossAccountExports: true}): string { + const replacer = (k: string, val: any): any => { + if (k === 'ExportAccountId' || k === 'ExportName' || k === 'ExportRegion') { + return undefined; + } + return val; + }; + + const template = JSON.parse(JSON.stringify(this.resultingTemplate, options.outputCrossAccountExports ? undefined : replacer)); + if (options.output === 'json') { + return JSON.stringify(template, null, 2); + } else { + return yamlDump(template); + } } public async createTemplateBodyAndResolve(expressionResolver: CfnExpressionResolver): Promise { @@ -322,6 +339,13 @@ export class CfnTemplate { return resource; } + + private _resolveOrganizationFunctionsAndStructuralFunctions(resource: any, account: AccountResource): any { + const resolved = this._resolveOrganizationFunctions(resource, account); + + return CfnFunctions.resolveTreeStructural(this.resolverContext, false, resolved); + } + private _resolveOrganizationFunctions(resource: any, account: AccountResource): any { const expressionsToSelf = ResourceUtil.EnumExpressionsForResource(resource, [account.logicalId, 'AWSAccount']); for (const expression of expressionsToSelf) { @@ -569,3 +593,7 @@ interface ICfnCrossAccountReference { uniqueNameForImport: string; conditionForExport?: string; } +interface ITemplateGenerationOptions { + output: 'json' | 'yaml'; + outputCrossAccountExports: boolean; +} diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 9c449f47..7ab27c41 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -6,6 +6,7 @@ import { AwsUtil } from '../util/aws-util'; import { ConsoleUtil } from '../util/console-util'; import { OrgFormationError } from '../org-formation-error'; import { IPerformTasksCommandArgs } from './perform-tasks'; +import { IPrintTasksCommandArgs } from './print-tasks'; import { AwsOrganization } from '~aws-provider/aws-organization'; import { AwsOrganizationReader } from '~aws-provider/aws-organization-reader'; import { AwsOrganizationWriter } from '~aws-provider/aws-organization-writer'; @@ -26,6 +27,7 @@ export abstract class BaseCliCommand { protected firstArg: any; static CliCommandArgs: ICommandArgs; + static StateBucketName: string; static async CreateAdditionalArgsForInvocation(): Promise { let additionalArgs = ''; @@ -93,6 +95,7 @@ export abstract class BaseCliCommand { return command.state; } const storageProvider = await this.getStateBucket(command); + BaseCliCommand.StateBucketName = storageProvider.bucketName; const accountId = await AwsUtil.GetMasterAccountId(); try { @@ -232,6 +235,15 @@ export abstract class BaseCliCommand { } } + if (rc.printStacksOutputPath && rc.config) { + const dir = path.dirname(rc.config); + const absolutePath = path.join(dir, rc.printStacksOutputPath); + if (absolutePath !== rc.printStacksOutputPath) { + ConsoleUtil.LogDebug(`print-stacks output path from runtime configuration resolved to absolute file path: ${absolutePath} (${rc.config} + ${rc.printStacksOutputPath})`); + rc.printStacksOutputPath = absolutePath; + } + } + ConsoleUtil.LogDebug(`runtime configuration: \n${JSON.stringify(rc)}`); Validator.validateRC(rc); @@ -250,6 +262,10 @@ export abstract class BaseCliCommand { if (process.argv.indexOf('--organization-file') === -1 && rc.organizationFile !== undefined) { (command as IPerformTasksCommandArgs).organizationFile = rc.organizationFile; } + + if (process.argv.indexOf('--output-path') === -1 && rc.printStacksOutputPath !== undefined) { + (command as IPrintTasksCommandArgs).outputPath = rc.printStacksOutputPath; + } } } @@ -267,6 +283,7 @@ export interface ICommandArgs { } export interface IRCObject { + printStacksOutputPath?: string; organizationFile?: string; stateBucketName?: string; stateObject?: string; diff --git a/src/commands/print-stacks.ts b/src/commands/print-stacks.ts index a0804859..134e2fb5 100644 --- a/src/commands/print-stacks.ts +++ b/src/commands/print-stacks.ts @@ -1,3 +1,6 @@ +import path from 'path'; +import { writeFileSync } from 'fs'; +import { mkdirSync } from 'fs'; import { Command } from 'commander'; import { ConsoleUtil } from '../util/console-util'; import { OrgFormationError } from '../org-formation-error'; @@ -11,7 +14,13 @@ const commandDescription = 'outputs cloudformation templates generated by org-fo export class PrintStacksCommand extends BaseCliCommand { - constructor(command: Command) { + public static async Perform(command: IPrintStacksCommandArgs): Promise { + const x = new PrintStacksCommand(); + await x.performCommand(command); + } + + + constructor(command?: Command) { super(command, commandName, commandDescription, 'templateFile'); } @@ -19,6 +28,9 @@ export class PrintStacksCommand extends BaseCliCommand command.option('--parameters [parameters]', 'parameter values passed to CloudFormation when executing stacks'); command.option('--stack-name ', 'name of the stack that will be used in CloudFormation', 'print'); command.option('--organization-file [organization-file]', 'organization file used for organization bindings'); + command.option('--output-path [output-path]', 'path, within the root directory, used to store printed templates', undefined); + command.option('--output ', 'the serialization format used when printing stacks. Either json or yaml.', 'yaml'); + command.option('--output-cross-account-exports ', 'when set, output well generate cross account exports as part of cfn parameter', false); super.addOptions(command); } @@ -39,15 +51,43 @@ export class PrintStacksCommand extends BaseCliCommand ConsoleUtil.LogInfo(`stack ${command.stackName} for account ${binding.accountId} and region ${binding.region} will be deleted`); continue; } - ConsoleUtil.Out(`template for account ${binding.accountId} and region ${binding.region}`); - const templateBody = binding.template.createTemplateBody(); - ConsoleUtil.Out(templateBody); + + const templateBody = binding.template.createTemplateBody({ outputCrossAccountExports: command.outputCrossAccountExports, output: command.output }); + + if (command.outputPath !== undefined) { + const outputPath = path.resolve(command.outputPath, command.stackName); + + const fileName = toKebabCase(`${binding.region}-${binding.accountLogicalId}`) + '.' + command.output; + const resolvedPath = path.resolve(outputPath, fileName); + + try{ + mkdirSync(outputPath, { recursive: true }); + writeFileSync(resolvedPath, templateBody, { }); + }catch(err) { + ConsoleUtil.LogError('error writing template to file', err); + throw new OrgFormationError('error writing file'); + } + } + else { + ConsoleUtil.Out(`template for account ${binding.accountId} and region ${binding.region}`); + ConsoleUtil.Out(templateBody); + } } } } +const toKebabCase = (input: string): string => { + if (input !== undefined) { + return input.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g).map(x=>x.toLowerCase()).join('-'); + } + return undefined; +}; + export interface IPrintStacksCommandArgs extends ICommandArgs { templateFile: string; stackName: string; - organizationFile: string; + organizationFile?: string; + outputPath?: string; + outputCrossAccountExports?: boolean; + output?: 'json' | 'yaml'; } diff --git a/src/commands/print-tasks.ts b/src/commands/print-tasks.ts new file mode 100644 index 00000000..b77ef0cb --- /dev/null +++ b/src/commands/print-tasks.ts @@ -0,0 +1,55 @@ +import { Command } from 'commander'; +import { BaseCliCommand } from './base-command'; +import { IPerformTasksCommandArgs } from './perform-tasks'; +import { IPrintStacksCommandArgs } from './print-stacks'; +import { BuildConfiguration } from '~build-tasks/build-configuration'; +import { BuildRunner } from '~build-tasks/build-runner'; +import { Validator } from '~parser/validator'; + +const commandName = 'print-tasks '; +const commandDescription = 'Will print out all cloudformation templates that will be deployed by tasksFile'; + +export class PrintTasksCommand extends BaseCliCommand { + + public static async Perform(command: IPrintTasksCommandArgs): Promise { + const x = new PrintTasksCommand(); + await x.performCommand(command); + } + + constructor(command?: Command) { + super(command, commandName, commandDescription, 'tasksFile'); + } + + public addOptions(command: Command): void { + command.option('--max-concurrent-tasks ', 'maximum number of tasks to be executed concurrently', 1); + command.option('--max-concurrent-stacks ', 'maximum number of stacks (within a task) to be executed concurrently', 1); + command.option('--failed-tasks-tolerance ', 'the number of failed tasks after which execution stops', 99); + command.option('--failed-stacks-tolerance ', 'the number of failed stacks (within a task) after which execution stops', 0); + command.option('--organization-file [organization-file]', 'organization file used for organization bindings'); + command.option('--parameters [parameters]', 'parameters used when creating build tasks from tasks file'); + command.option('--output ', 'the serialization format used when printing stacks. Either json or yaml.', 'yaml'); + command.option('--output-path ', 'path, within the root directory, used to store printed templates', './.printed-stacks/'); + command.option('--output-cross-account-exports ', 'when set, output well generate cross account exports as part of cfn parameter', false); + + super.addOptions(command); + } + + public async performCommand(command: IPerformTasksCommandArgs): Promise { + const tasksFile = command.tasksFile; + + Validator.validatePositiveInteger(command.maxConcurrentStacks, 'maxConcurrentStacks'); + Validator.validatePositiveInteger(command.failedStacksTolerance, 'failedStacksTolerance'); + Validator.validatePositiveInteger(command.maxConcurrentTasks, 'maxConcurrentTasks'); + Validator.validatePositiveInteger(command.failedTasksTolerance, 'failedTasksTolerance'); + + command.parsedParameters = this.parseCfnParameters(command.parameters); + const config = new BuildConfiguration(tasksFile, command.parsedParameters); + + const printTasks = config.enumPrintTasks(command); + await BuildRunner.RunPrintTasks(printTasks, command.verbose === true , command.maxConcurrentTasks, command.failedTasksTolerance); + } +} + +export interface IPrintTasksCommandArgs extends IPerformTasksCommandArgs, IPrintStacksCommandArgs { + +} diff --git a/src/commands/validate-tasks.ts b/src/commands/validate-tasks.ts index b4352893..dbd92f9e 100644 --- a/src/commands/validate-tasks.ts +++ b/src/commands/validate-tasks.ts @@ -5,7 +5,7 @@ import { BuildConfiguration } from '~build-tasks/build-configuration'; import { BuildRunner } from '~build-tasks/build-runner'; import { Validator } from '~parser/validator'; -const commandName = 'validate-tasks '; +const commandName = 'validate-tasks '; const commandDescription = 'Will validate the tasks file, including configured tasks'; export class ValidateTasksCommand extends BaseCliCommand { @@ -20,7 +20,6 @@ export class ValidateTasksCommand extends BaseCliCommand', 'maximum number of tasks to be executed concurrently', 1); command.option('--max-concurrent-stacks ', 'maximum number of stacks (within a task) to be executed concurrently', 1); command.option('--failed-tasks-tolerance ', 'the number of failed tasks after which execution stops', 99); diff --git a/src/core/cfn-expression-resolver.ts b/src/core/cfn-expression-resolver.ts index 9c255a55..1cd13077 100644 --- a/src/core/cfn-expression-resolver.ts +++ b/src/core/cfn-expression-resolver.ts @@ -8,6 +8,7 @@ import { PersistedState } from '~state/persisted-state'; import { TemplateRoot } from '~parser/parser'; import { AwsUtil } from '~util/aws-util'; import { Validator } from '~parser/validator'; +import { BaseCliCommand } from '~commands/base-command'; interface IResolver { @@ -92,7 +93,7 @@ export class CfnExpressionResolver { } } const context: ICfnFunctionContext = { filePath: this.filePath, mappings: this.mapping, finalPass: false }; - const resolved = CfnFunctions.resolveTreeStructural(context, container); + const resolved = CfnFunctions.resolveTreeStructural(context, true, container); return resolved.val; } @@ -152,7 +153,7 @@ export class CfnExpressionResolver { } const context: ICfnFunctionContext = { filePath: this.filePath, mappings: this.mapping, finalPass: true }; - CfnFunctions.resolveTreeStructural(context, container); + CfnFunctions.resolveTreeStructural(context, true, container); return container.val; } @@ -231,6 +232,7 @@ export class CfnExpressionResolver { const resolver = new CfnExpressionResolver(); resolver.addParameter('AWS::AccountId', accountId); resolver.addParameter('AWS::Region', region); + resolver.addParameter('ORG::StateBucketName', BaseCliCommand.StateBucketName); // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const currentAccountResolverFn = (that: CfnExpressionResolver, resource: string, resourcePath: string | undefined) => CfnExpressionResolver.ResolveOrganizationExpressionByLogicalName(logicalAccountName, resourcePath, template, state); diff --git a/src/core/cfn-functions/cfn-functions.ts b/src/core/cfn-functions/cfn-functions.ts index ff562903..474afb6d 100644 --- a/src/core/cfn-functions/cfn-functions.ts +++ b/src/core/cfn-functions/cfn-functions.ts @@ -8,33 +8,38 @@ import { CfnSub } from './cfn-sub'; export class CfnFunctions { - static resolveStructural(context: ICfnFunctionContext, resource: any, resourceParent: any, resourceKey: string, key: string, val: any): void { + static resolveStructuralOrgFormationFunctions(context: ICfnFunctionContext, resource: any, resourceParent: any, resourceKey: string, key: string, val: any): void { CfnReadFile.resolve(context, resource, resourceParent, resourceKey, key, val); CfnMD5.resolve(context, resource, resourceParent, resourceKey, key, val); + CfnJsonString.resolve(context, resource, resourceParent, resourceKey, key, val); + } + + static resolveStructuralCloudFormationFunctions(context: ICfnFunctionContext, resource: any, resourceParent: any, resourceKey: string, key: string, val: any): void { CfnFindInMap.resolve(context, resource, resourceParent, resourceKey, key, val); CfnSelect.resolve(context, resource, resourceParent, resourceKey, key, val); CfnJoin.resolve(context, resource, resourceParent, resourceKey, key, val); - CfnJsonString.resolve(context, resource, resourceParent, resourceKey, key, val); if (context.finalPass) { CfnSub.resolve(context, resource, resourceParent, resourceKey, key, val); } } - static resolveTreeStructural(context: ICfnFunctionContext, resource: T, resourceParent?: any, resourceKey?: string): T { - + static resolveTreeStructural(context: ICfnFunctionContext, polyfillCloudFormation: boolean, resource: T, resourceParent?: any, resourceKey?: string): T { if (resource !== null && typeof resource === 'object') { const entries = Object.entries(resource); for (const [key, val] of entries) { if (val !== null && typeof val === 'object') { - this.resolveTreeStructural(context, val, resource, key); + this.resolveTreeStructural(context, polyfillCloudFormation, val, resource, key); } } if (entries.length === 1 && resourceParent !== undefined && resourceKey !== undefined) { const [key, val]: [string, unknown] = entries[0]; - this.resolveStructural(context, resource, resourceParent, resourceKey, key, val); + this.resolveStructuralOrgFormationFunctions(context, resource, resourceParent, resourceKey, key, val); + if (polyfillCloudFormation) { + this.resolveStructuralCloudFormationFunctions(context, resource, resourceParent, resourceKey, key, val); + } } } diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 0f20cfd3..69ff6311 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -153,6 +153,8 @@ export class TemplateRoot { public readonly contents: ITemplate; public readonly dirname: string; + public readonly filename: string; + public readonly filepath: string; public readonly organizationSection: OrganizationSection; public readonly defaultOrganizationBinding: IOrganizationBinding; public readonly defaultOrganizationBindingRegion: string | string[]; @@ -167,7 +169,9 @@ export class TemplateRoot { Validator.ValidateTemplateRoot(contents); this.contents = contents; - this.dirname = dirname; + this.dirname = dirname ?? './'; + this.filename = filename ?? 'template.yml'; + this.filepath = Path.resolve(this.dirname, this.filename); this.source = JSON.stringify(contents); this.hash = md5(this.source); if (paramValues !== undefined) { diff --git a/src/parser/validator.ts b/src/parser/validator.ts index 322e7318..e658f029 100644 --- a/src/parser/validator.ts +++ b/src/parser/validator.ts @@ -17,7 +17,7 @@ export class Validator { delete clone.config; Validator.ThrowForUnknownAttribute(clone, `runtime configuration file (${rc.configs.join(', ')})`, - 'organizationFile', 'stateBucketName', 'stateObject', 'profile'); + 'organizationFile', 'stateBucketName', 'stateObject', 'profile', 'printStacksOutputPath'); } diff --git a/src/plugin/impl/cdk-build-task-plugin.ts b/src/plugin/impl/cdk-build-task-plugin.ts index ab484312..6f98d9c4 100644 --- a/src/plugin/impl/cdk-build-task-plugin.ts +++ b/src/plugin/impl/cdk-build-task-plugin.ts @@ -91,12 +91,12 @@ export class CdkBuildTaskPlugin implements IBuildTaskPlugin /* , resolver: CfnExpressionResolver */): Promise { - const cfn = await AwsUtil.GetCloudFormation(binding.target.accountId, binding.target.region, binding.task.taskRoleName); - let roleArn = binding.task.executionRole; - const schemaHandlerPackage = binding.task.schemaHandlerPackage; + const {task, target, previousBindingLocalHash } = binding; + if (task.forceDeploy !== true && + task.taskLocalHash !== undefined && + task.taskLocalHash === previousBindingLocalHash) { + + ConsoleUtil.LogInfo(`Workload (register-type) ${task.name} in ${target.accountId}/${target.region} skipped, task itself did not change. Use ForceTask to force deployment.`); + return; + } + + + const cfn = await AwsUtil.GetCloudFormation(target.accountId, target.region, task.taskRoleName); + + let roleArn = task.executionRole; + const schemaHandlerPackage = task.schemaHandlerPackage; if (roleArn === undefined) { roleArn = await this.ensureExecutionRole(cfn, schemaHandlerPackage); } const response = await cfn.registerType({ - TypeName: binding.task.resourceType, + TypeName: task.resourceType, Type: 'RESOURCE', - SchemaHandlerPackage: binding.task.schemaHandlerPackage, + SchemaHandlerPackage: task.schemaHandlerPackage, ExecutionRoleArn: roleArn, }).promise(); @@ -112,7 +124,7 @@ export class RpBuildTaskPlugin implements IBuildTaskPlugin { ConsoleUtil.LogWarning(`Task ${this.task.type} / ${this.task.name} is not bind to any region. Therefore, this task will not be executed.`); } for(const region of regions) { + + const existingTargetBinding = this.state.getGenericTarget(this.task.type, this.organizationLogicalName, this.logicalNamePrefix, this.task.name, accountBinding.physicalId, region); + const binding: IPluginBinding = { action: 'UpdateOrCreate', target: { @@ -41,11 +44,12 @@ export class PluginBinder { logicalNamePrefix: this.logicalNamePrefix, organizationLogicalName: this.organizationLogicalName, lastCommittedHash: this.task.hash, + lastCommittedLocalHash: this.task.taskLocalHash, }, task: this.task, + previousBindingLocalHash: existingTargetBinding?.lastCommittedLocalHash, }; - const existingTargetBinding = this.state.getGenericTarget(this.task.type, this.organizationLogicalName, this.logicalNamePrefix, this.task.name, accountBinding.physicalId, region); if (this.task.forceDeploy) { ConsoleUtil.LogDebug(`Setting build action on ${this.task.type} / ${this.task.name} for ${binding.target.accountId}/${binding.target.region} to ${binding.action} - update was forced.`, this.task.logVerbose); } else if (!existingTargetBinding) { @@ -77,6 +81,7 @@ export class PluginBinder { logicalName: targetToBeDeleted.definition.name, lastCommittedHash: targetToBeDeleted.definition.hash, }, + previousBindingLocalHash: targetToBeDeleted.lastCommittedLocalHash, }); ConsoleUtil.LogDebug(`Setting build action on ${this.task.type} / ${this.task.name} for ${targetToBeDeleted.accountId} to Delete`, this.task.logVerbose); @@ -131,6 +136,7 @@ export class PluginBinder { that.state.removeGenericTarget(task.type, this.organizationLogicalName, this.logicalNamePrefix, task.name, target.accountId, target.region); }; } + public createPerformForUpdateOrCreate(binding: IPluginBinding): () => Promise { const { task, target } = binding; const that = this; @@ -162,12 +168,14 @@ export interface IPluginBinding { action: GenericAction; target: IGenericTarget; task: ITaskDefinition; + previousBindingLocalHash: string; } export interface IPluginTask { name: string; type: string; hash: string; + taskLocalHash?: string; taskRoleName?: string; parameters?: Record; logVerbose: boolean; diff --git a/src/plugin/plugin-command.ts b/src/plugin/plugin-command.ts index 5ab933bd..05a72562 100644 --- a/src/plugin/plugin-command.ts +++ b/src/plugin/plugin-command.ts @@ -15,20 +15,15 @@ export class PluginCliCommand { this.plugin.validateCommandArgs(command); - const usedInHash = this.plugin.getValuesForEquality(command); - const allUsedInHash = { - organizationFileHash: command.organizationFileHash, - taskRoleName: command.taskRoleName, - ...usedInHash, - }; - const hash = md5(JSON.stringify(allUsedInHash)); + + const hash = this.createHash(command, true); const task = this.plugin.convertToTask(command, hash); + task.taskLocalHash = this.createHash(command, false); const state = await this.getState(command); const template = TemplateRoot.create(command.organizationFile, {}, command.organizationFileHash); const binder = new PluginBinder(task, command.logicalName, command.logicalNamePrefix, state, template, command.organizationBinding, this.plugin); const tasks = binder.enumTasks(); - if (tasks.length === 0) { ConsoleUtil.LogInfo(`${this.plugin.type} workload ${command.name} already up to date.`); } else { @@ -39,4 +34,14 @@ export class PluginCliCommand, resolver: CfnExpressionResolver): Promise; performCreateOrUpdate(binding: IPluginBinding, resolver: CfnExpressionResolver): Promise; diff --git a/src/state/persisted-state.ts b/src/state/persisted-state.ts index fdca99a9..a7a0edbd 100644 --- a/src/state/persisted-state.ts +++ b/src/state/persisted-state.ts @@ -538,6 +538,7 @@ export interface IGenericTarget { organizationLogicalName: string; logicalNamePrefix?: string; lastCommittedHash: string; + lastCommittedLocalHash?: string; definition: TTaskDefinition; } diff --git a/src/util/aws-util.ts b/src/util/aws-util.ts index a05ef89d..a2709a90 100644 --- a/src/util/aws-util.ts +++ b/src/util/aws-util.ts @@ -242,7 +242,7 @@ export class CfnUtil { const message = err.message as string; if (-1 !== message.indexOf('ROLLBACK_COMPLETE') || -1 !== message.indexOf('ROLLBACK_FAILED') || -1 !== message.indexOf('DELETE_FAILED')) { await cfn.deleteStack({ StackName: updateStackInput.StackName, RoleARN: updateStackInput.RoleARN }).promise(); - await cfn.waitFor('stackDeleteComplete', { StackName: updateStackInput.StackName, $waiter: { delay: 1 } }).promise(); + await cfn.waitFor('stackDeleteComplete', { StackName: updateStackInput.StackName, $waiter: { delay: 1, maxAttempts: 60 * 30 } }).promise(); await cfn.createStack(updateStackInput).promise(); describeStack = await cfn.waitFor('stackCreateComplete', { StackName: updateStackInput.StackName, $waiter: { delay: 1, maxAttempts: 60 * 30 } }).promise(); } else if (-1 !== message.indexOf('does not exist')) { diff --git a/test/integration-tests/resources/scenario-cfn-parameter-expressions/1-deploy-update-stacks-with-param-expressions.yml b/test/integration-tests/resources/scenario-cfn-parameter-expressions/1-deploy-update-stacks-with-param-expressions.yml index beadf88d..fe8774f8 100644 --- a/test/integration-tests/resources/scenario-cfn-parameter-expressions/1-deploy-update-stacks-with-param-expressions.yml +++ b/test/integration-tests/resources/scenario-cfn-parameter-expressions/1-deploy-update-stacks-with-param-expressions.yml @@ -80,3 +80,5 @@ PolicyTemplate: otherAtt: '2' jsonString2: !JsonString [ !ReadFile ./test.json] refToRoot: !Ref OrganizationRoot + orgPrincipalId: !Ref ORG::PrincipalOrgID + orgStateBucketName: !Ref ORG::StateBucketName \ No newline at end of file diff --git a/test/integration-tests/resources/scenario-cfn-parameter-expressions/bucket-policy.yml b/test/integration-tests/resources/scenario-cfn-parameter-expressions/bucket-policy.yml index 3fabb5d6..b7213d81 100644 --- a/test/integration-tests/resources/scenario-cfn-parameter-expressions/bucket-policy.yml +++ b/test/integration-tests/resources/scenario-cfn-parameter-expressions/bucket-policy.yml @@ -74,6 +74,12 @@ Parameters: refToRoot: Type: String + orgPrincipalId: + Type: String + + orgStateBucketName: + Type: String + Resources: BucketPolicy: Type: AWS::IAM::ManagedPolicy diff --git a/test/integration-tests/resources/scenario-functions-in-cfn/1-deploy-cfn-with-functions.yml b/test/integration-tests/resources/scenario-functions-in-cfn/1-deploy-cfn-with-functions.yml new file mode 100644 index 00000000..82897651 --- /dev/null +++ b/test/integration-tests/resources/scenario-functions-in-cfn/1-deploy-cfn-with-functions.yml @@ -0,0 +1,29 @@ + +OrganizationUpdate: + Type: update-organization + Skip: true + Template: ./organization.yml + +LambdaUsingReadFile: + Type: update-stacks + StackName: lambda-using-read-file + Template: ./lambda-using-read-file.yml + DefaultOrganizationBindingRegion: eu-west-1 + DefaultOrganizationBinding: + IncludeMasterAccount: true + +PermissionSetWithInlinePolicy1: + Type: update-stacks + StackName: permission-set-using-json-string-1 + Template: ./permission-set-using-json-string-1.yml + DefaultOrganizationBindingRegion: eu-west-1 + DefaultOrganizationBinding: + IncludeMasterAccount: true + +PermissionSetWithInlinePolicy2: + Type: update-stacks + StackName: permission-set-using-json-string-2 + Template: ./permission-set-using-json-string-2.yml + DefaultOrganizationBindingRegion: eu-west-1 + DefaultOrganizationBinding: + IncludeMasterAccount: true diff --git a/test/integration-tests/resources/scenario-register-type/2-cleanup.yml b/test/integration-tests/resources/scenario-functions-in-cfn/9-cleanup-cfn-with-functions.yml similarity index 100% rename from test/integration-tests/resources/scenario-register-type/2-cleanup.yml rename to test/integration-tests/resources/scenario-functions-in-cfn/9-cleanup-cfn-with-functions.yml diff --git a/test/integration-tests/resources/scenario-functions-in-cfn/lambda-using-read-file-code.js b/test/integration-tests/resources/scenario-functions-in-cfn/lambda-using-read-file-code.js new file mode 100644 index 00000000..ba8c82b2 --- /dev/null +++ b/test/integration-tests/resources/scenario-functions-in-cfn/lambda-using-read-file-code.js @@ -0,0 +1,3 @@ +exports.handler = async(event, context) => { + console.log('Event: %s', JSON.stringify(event)); +} diff --git a/test/integration-tests/resources/scenario-functions-in-cfn/lambda-using-read-file.yml b/test/integration-tests/resources/scenario-functions-in-cfn/lambda-using-read-file.yml new file mode 100644 index 00000000..dca3ef86 --- /dev/null +++ b/test/integration-tests/resources/scenario-functions-in-cfn/lambda-using-read-file.yml @@ -0,0 +1,29 @@ +AWSTemplateFormatVersion: '2010-09-09-OC' + + +Resources: + + LambdaRole: + Type: AWS::IAM::Role + Properties: + RoleName: my-lambda-role + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Path: / + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + + LambdaUsingReadFile: + Type: AWS::Lambda::Function + Properties: + FunctionName: my-lambda + Runtime: nodejs12.x + Role: !GetAtt LambdaRole.Arn + Handler: index.handler + Code: + ZipFile: !ReadFile './lambda-using-read-file-code.js' \ No newline at end of file diff --git a/test/integration-tests/resources/scenario-functions-in-cfn/organization.yml b/test/integration-tests/resources/scenario-functions-in-cfn/organization.yml new file mode 100644 index 00000000..03526bff --- /dev/null +++ b/test/integration-tests/resources/scenario-functions-in-cfn/organization.yml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: '2010-09-09-OC' +Description: default template generated for organization with master account 102625093955 + +Organization: + MasterAccount: + Type: OC::ORG::MasterAccount + Properties: + AccountName: Organizational Master Account + AccountId: '102625093955' + + AccountA: + Type: OC::ORG::Account + Properties: + RootEmail: account+a@olafconijn.awsapps.com + AccountName: Account A diff --git a/test/integration-tests/resources/scenario-functions-in-cfn/permission-set-using-json-string-1.yml b/test/integration-tests/resources/scenario-functions-in-cfn/permission-set-using-json-string-1.yml new file mode 100644 index 00000000..6581ba58 --- /dev/null +++ b/test/integration-tests/resources/scenario-functions-in-cfn/permission-set-using-json-string-1.yml @@ -0,0 +1,21 @@ +AWSTemplateFormatVersion: '2010-09-09-OC' + +Resources: + + TestPS: + Type: AWS::SSO::PermissionSet + Properties: + Name: TestPS1 + Description: Readonly Access + InstanceArn: arn:aws:sso:::instance/ssoins-6804a82852974434 + ManagedPolicies: + - arn:aws:iam::aws:policy/ReadOnlyAccess + SessionDuration: PT12H + InlinePolicy: !JsonString + - Version: '2012-10-17' + Statement: + - Sid: DenyS3 + Effect: Deny + Action: 's3:*' + Resource: '*' + diff --git a/test/integration-tests/resources/scenario-functions-in-cfn/permission-set-using-json-string-2-inline-policy.json b/test/integration-tests/resources/scenario-functions-in-cfn/permission-set-using-json-string-2-inline-policy.json new file mode 100644 index 00000000..2693d9e5 --- /dev/null +++ b/test/integration-tests/resources/scenario-functions-in-cfn/permission-set-using-json-string-2-inline-policy.json @@ -0,0 +1,11 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyS3", + "Effect": "Deny", + "Action": "s3:*", + "Resource": "*" + } + ] +} \ No newline at end of file diff --git a/test/integration-tests/resources/scenario-functions-in-cfn/permission-set-using-json-string-2.yml b/test/integration-tests/resources/scenario-functions-in-cfn/permission-set-using-json-string-2.yml new file mode 100644 index 00000000..b39acc8c --- /dev/null +++ b/test/integration-tests/resources/scenario-functions-in-cfn/permission-set-using-json-string-2.yml @@ -0,0 +1,14 @@ +AWSTemplateFormatVersion: '2010-09-09-OC' + +Resources: + + TestPS: + Type: AWS::SSO::PermissionSet + Properties: + Name: TestPS2 + Description: Readonly Access + InstanceArn: arn:aws:sso:::instance/ssoins-6804a82852974434 + ManagedPolicies: + - arn:aws:iam::aws:policy/ReadOnlyAccess + SessionDuration: PT12H + InlinePolicy: !JsonString [ !ReadFile 'permission-set-using-json-string-2-inline-policy.json'] diff --git a/test/integration-tests/resources/scenario-register-type/1-register-type.yml b/test/integration-tests/resources/scenario-register-type/1-register-type.yml index 6d0a5520..762d22c1 100644 --- a/test/integration-tests/resources/scenario-register-type/1-register-type.yml +++ b/test/integration-tests/resources/scenario-register-type/1-register-type.yml @@ -12,5 +12,5 @@ RegisterType: FailedTaskTolerance: 5 OrganizationBinding: IncludeMasterAccount: true - Account: !Ref AccountA + Account: '*' Region: eu-west-1 \ No newline at end of file diff --git a/test/integration-tests/resources/scenario-register-type/2-register-type-changed-org.yml b/test/integration-tests/resources/scenario-register-type/2-register-type-changed-org.yml new file mode 100644 index 00000000..28d10c58 --- /dev/null +++ b/test/integration-tests/resources/scenario-register-type/2-register-type-changed-org.yml @@ -0,0 +1,16 @@ + +OrganizationUpdate: + Type: update-organization + Skip: true + Template: ./organization-2.yml + +RegisterType: + Type: register-type + SchemaHandlerPackage: s3://community-resource-provider-catalog/community-servicequotas-s3-0.1.0.zip + ResourceType: 'Community::ServiceQuotas::S3' + MaxConcurrentTasks: 5 + FailedTaskTolerance: 5 + OrganizationBinding: + IncludeMasterAccount: true + Account: '*' + Region: eu-west-1 \ No newline at end of file diff --git a/test/integration-tests/resources/scenario-register-type/3-register-type-added-account.yml b/test/integration-tests/resources/scenario-register-type/3-register-type-added-account.yml new file mode 100644 index 00000000..13fbd956 --- /dev/null +++ b/test/integration-tests/resources/scenario-register-type/3-register-type-added-account.yml @@ -0,0 +1,16 @@ + +OrganizationUpdate: + Type: update-organization + Skip: true + Template: ./organization-3.yml + +RegisterType: + Type: register-type + SchemaHandlerPackage: s3://community-resource-provider-catalog/community-servicequotas-s3-0.1.0.zip + ResourceType: 'Community::ServiceQuotas::S3' + MaxConcurrentTasks: 5 + FailedTaskTolerance: 5 + OrganizationBinding: + IncludeMasterAccount: true + Account: '*' + Region: eu-west-1 \ No newline at end of file diff --git a/test/integration-tests/resources/scenario-register-type/9-cleanup.yml b/test/integration-tests/resources/scenario-register-type/9-cleanup.yml new file mode 100644 index 00000000..52ab4cbc --- /dev/null +++ b/test/integration-tests/resources/scenario-register-type/9-cleanup.yml @@ -0,0 +1,5 @@ + +OrganizationUpdate: + Type: update-organization + Skip: true + Template: ./organization.yml diff --git a/test/integration-tests/resources/scenario-register-type/organization-2.yml b/test/integration-tests/resources/scenario-register-type/organization-2.yml new file mode 100644 index 00000000..be00abda --- /dev/null +++ b/test/integration-tests/resources/scenario-register-type/organization-2.yml @@ -0,0 +1,18 @@ +AWSTemplateFormatVersion: '2010-09-09-OC' +Description: XXXX + +Organization: + MasterAccount: + Type: OC::ORG::MasterAccount + Properties: + AccountName: Organizational Master Account + AccountId: '102625093955' + + AccountA: + Type: OC::ORG::Account + Properties: + RootEmail: account+a@olafconijn.awsapps.com + AccountName: Account A + + +# this is here to have the comparison with other org file to \ No newline at end of file diff --git a/test/integration-tests/resources/scenario-register-type/organization-3.yml b/test/integration-tests/resources/scenario-register-type/organization-3.yml new file mode 100644 index 00000000..da0c0c73 --- /dev/null +++ b/test/integration-tests/resources/scenario-register-type/organization-3.yml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion: '2010-09-09-OC' +Description: XXXX + +Organization: + MasterAccount: + Type: OC::ORG::MasterAccount + Properties: + AccountName: Organizational Master Account + AccountId: '102625093955' + + AccountA: + Type: OC::ORG::Account + Properties: + RootEmail: account+a@olafconijn.awsapps.com + AccountName: Account A + + UsersAccount: + Type: OC::ORG::Account + Properties: + RootEmail: account+users@olafconijn.awsapps.com + AccountName: Users Account + +# this is here to have the comparison with other org file to \ No newline at end of file diff --git a/test/integration-tests/resources/scenario-very-large-stack/1-deploy-very-large-stack.yml b/test/integration-tests/resources/scenario-very-large-stack/1-deploy-very-large-stack.yml index 47c15f2c..c7f44c67 100644 --- a/test/integration-tests/resources/scenario-very-large-stack/1-deploy-very-large-stack.yml +++ b/test/integration-tests/resources/scenario-very-large-stack/1-deploy-very-large-stack.yml @@ -5,7 +5,6 @@ OrganizationUpdate: Template: ./organization.yml TestVeryLargeStack: - DependsOn: MyRoles Type: update-stacks StackName: test-with-very-large-stack Template: ./buckets.yml diff --git a/test/integration-tests/scenario-functions-in-cfn.test.ts b/test/integration-tests/scenario-functions-in-cfn.test.ts new file mode 100644 index 00000000..4fa60cbc --- /dev/null +++ b/test/integration-tests/scenario-functions-in-cfn.test.ts @@ -0,0 +1,52 @@ +import { PerformTasksCommand, ValidateTasksCommand } from '~commands/index'; +import { IIntegrationTestContext, baseBeforeAll, baseAfterAll } from './base-integration-test'; +import { DescribeStacksOutput, GetStackPolicyOutput } from 'aws-sdk/clients/cloudformation'; + +const basePathForScenario = './test/integration-tests/resources/scenario-functions-in-cfn/'; + +describe('when calling org-formation perform tasks', () => { + let context: IIntegrationTestContext; + let lambdaUsingReadFile: DescribeStacksOutput; + let permissionSetWithInlinePolicy1: DescribeStacksOutput; + let permissionSetWithInlinePolicy2: DescribeStacksOutput; + + beforeAll(async () => { + + context = await baseBeforeAll(); + await context.prepareStateBucket(basePathForScenario + '../state.json'); + const command = context.command; + const { cfnClient } = context; + + await ValidateTasksCommand.Perform({...command, tasksFile: basePathForScenario + '1-deploy-cfn-with-functions.yml' }) + await PerformTasksCommand.Perform({...command, tasksFile: basePathForScenario + '1-deploy-cfn-with-functions.yml' }); + + lambdaUsingReadFile = await cfnClient.describeStacks({StackName: 'lambda-using-read-file'}).promise(); + permissionSetWithInlinePolicy1 = await cfnClient.describeStacks({StackName: 'permission-set-using-json-string-1'}).promise(); + permissionSetWithInlinePolicy2 = await cfnClient.describeStacks({StackName: 'permission-set-using-json-string-2'}).promise(); + + await PerformTasksCommand.Perform({...command, tasksFile: basePathForScenario + '9-cleanup-cfn-with-functions.yml', performCleanup: true }); + + }); + + test('lambda using read file was deployed successfully', () => { + expect(lambdaUsingReadFile).toBeDefined(); + expect(lambdaUsingReadFile.Stacks.length).toBe(1); + expect(lambdaUsingReadFile.Stacks[0]).toBeDefined(); + expect(lambdaUsingReadFile.Stacks[0].StackStatus).toBe('CREATE_COMPLETE'); + }); + + test('permission set using JsonString deployed successfully', () => { + expect(permissionSetWithInlinePolicy1).toBeDefined(); + expect(permissionSetWithInlinePolicy1.Stacks.length).toBe(1); + expect(permissionSetWithInlinePolicy1.Stacks[0]).toBeDefined(); + expect(permissionSetWithInlinePolicy1.Stacks[0].StackStatus).toBe('CREATE_COMPLETE'); + }); + + test('permission set using JsonString / ReadFile deployed successfully', () => { + expect(permissionSetWithInlinePolicy2).toBeDefined(); + expect(permissionSetWithInlinePolicy2.Stacks.length).toBe(1); + expect(permissionSetWithInlinePolicy2.Stacks[0]).toBeDefined(); + expect(permissionSetWithInlinePolicy2.Stacks[0].StackStatus).toBe('CREATE_COMPLETE'); + }); + +}); \ No newline at end of file diff --git a/test/integration-tests/scenario-register-type.test.ts b/test/integration-tests/scenario-register-type.test.ts index f7764ea7..1abde6d5 100644 --- a/test/integration-tests/scenario-register-type.test.ts +++ b/test/integration-tests/scenario-register-type.test.ts @@ -8,8 +8,11 @@ const basePathForScenario = './test/integration-tests/resources/scenario-registe describe('when calling org-formation perform tasks', () => { let context: IIntegrationTestContext; let typesAfterRegister : ListTypeVersionsOutput; + let typesAfterSecondRegister : ListTypeVersionsOutput; + let typesAfterThirdRegister : ListTypeVersionsOutput; let typesAfterCleanup : ListTypeVersionsOutput; let stateAfterRegister: GetObjectOutput; + let stateAfterThirdRegister: GetObjectOutput; let stateAfterCleanup: GetObjectOutput; let describeStacksOutput: DescribeStacksOutput; @@ -30,10 +33,22 @@ describe('when calling org-formation perform tasks', () => { typesAfterRegister = await cfnClient.listTypeVersions({Type : 'RESOURCE', TypeName: 'Community::ServiceQuotas::S3'}).promise(); describeStacksOutput = await cfnClient.describeStacks({StackName: 'community-servicequotas-s3-resource-role'}).promise(); - await sleepForTest(5000); + + await sleepForTest(1000); stateAfterRegister = await s3client.getObject({Bucket: stateBucketName, Key: command.stateObject}).promise(); - await PerformTasksCommand.Perform({...command, tasksFile: basePathForScenario + '2-cleanup.yml', performCleanup: true}); + await PerformTasksCommand.Perform({...command, tasksFile: basePathForScenario + '2-register-type-changed-org.yml' }); + typesAfterSecondRegister = await cfnClient.listTypeVersions({Type : 'RESOURCE', TypeName: 'Community::ServiceQuotas::S3'}).promise(); + + await sleepForTest(1000); + + await PerformTasksCommand.Perform({...command, tasksFile: basePathForScenario + '3-register-type-added-account.yml' }); + typesAfterThirdRegister = await cfnClient.listTypeVersions({Type : 'RESOURCE', TypeName: 'Community::ServiceQuotas::S3'}).promise(); + + await sleepForTest(1000); + stateAfterThirdRegister = await s3client.getObject({Bucket: stateBucketName, Key: command.stateObject}).promise(); + + await PerformTasksCommand.Perform({...command, tasksFile: basePathForScenario + '9-cleanup.yml', performCleanup: true}); typesAfterCleanup = await cfnClient.listTypeVersions({Type : 'RESOURCE', TypeName: 'Community::ServiceQuotas::S3'}).promise(); stateAfterCleanup = await s3client.getObject({Bucket: stateBucketName, Key: command.stateObject}).promise(); }); @@ -50,6 +65,7 @@ describe('when calling org-formation perform tasks', () => { expect(state.targets).toBeDefined(); expect(state.targets['register-type']).toBeDefined(); expect(state.targets['register-type']['default']['default']['RegisterType']).toBeDefined(); + expect(Object.keys(state.targets['register-type']['default']['default']['RegisterType']).length).toBe(2) expect(state.targets['register-type']['default']['default']['RegisterType']['102625093955']).toBeDefined(); expect(state.targets['register-type']['default']['default']['RegisterType']['102625093955']['eu-west-1']).toBeDefined(); expect(state.targets['register-type']['default']['default']['RegisterType']['340381375986']).toBeDefined(); @@ -78,11 +94,39 @@ describe('when calling org-formation perform tasks', () => { expect(state.trackedTasks.default[0].physicalIdForCleanup).toBe('undefined/RegisterType'); }) + + + test('state after adding account to organization ', () => { + const stateAsString = stateAfterThirdRegister.Body.toString(); + const state = JSON.parse(stateAsString); + expect(state).toBeDefined(); + expect(state.targets).toBeDefined(); + expect(state.targets['register-type']).toBeDefined(); + expect(state.targets['register-type']['default']['default']['RegisterType']).toBeDefined(); + expect(Object.keys(state.targets['register-type']['default']['default']['RegisterType']).length).toBe(3) + expect(state.targets['register-type']['default']['default']['RegisterType']['102625093955']).toBeDefined(); + expect(state.targets['register-type']['default']['default']['RegisterType']['102625093955']['eu-west-1']).toBeDefined(); + expect(state.targets['register-type']['default']['default']['RegisterType']['340381375986']).toBeDefined(); + expect(state.targets['register-type']['default']['default']['RegisterType']['340381375986']['eu-west-1']).toBeDefined(); + expect(state.targets['register-type']['default']['default']['RegisterType']['549476213961']).toBeDefined(); + expect(state.targets['register-type']['default']['default']['RegisterType']['549476213961']['eu-west-1']).toBeDefined(); + + }) + + test('type doesnt get updated if only org file changed', () => { + const foundType1 = typesAfterRegister.TypeVersionSummaries.find(x=>x.TypeName === 'Community::ServiceQuotas::S3'); + const foundType2 = typesAfterSecondRegister.TypeVersionSummaries.find(x=>x.TypeName === 'Community::ServiceQuotas::S3'); + const foundType3 = typesAfterSecondRegister.TypeVersionSummaries.find(x=>x.TypeName === 'Community::ServiceQuotas::S3'); + expect(foundType1.VersionId).toBe(foundType2.VersionId); + expect(foundType1.VersionId).toBe(foundType3.VersionId); + }); + test('types after cleanup does not contain registered type', () => { const foundType = typesAfterCleanup.TypeVersionSummaries.find(x=>x.TypeName === 'Community::ServiceQuotas::S3'); expect(foundType).toBeUndefined(); }); + test('state after cleanup does not contain any', () => { const stateAsString = stateAfterCleanup.Body.toString(); const state = JSON.parse(stateAsString); diff --git a/test/integration-tests/scenario-update-stacks-with-param-expressions.test.ts b/test/integration-tests/scenario-update-stacks-with-param-expressions.test.ts index 72249c99..2394d2c3 100644 --- a/test/integration-tests/scenario-update-stacks-with-param-expressions.test.ts +++ b/test/integration-tests/scenario-update-stacks-with-param-expressions.test.ts @@ -178,7 +178,7 @@ describe('when importing value from another stack', () => { expect(describeBucketRoleStack).toBeDefined(); const parameter = describeBucketRoleStack.Stacks[0].Parameters.find(x=>x.ParameterKey === 'md5readFile'); - expect(parameter.ParameterValue).toBe('93b885adfe0da089cdf634904fd59f71'); + expect(parameter.ParameterValue).toBe('4d06f8349b277ddc4cd33dc192bfccf3'); }) test('select gets resolved properly', () =>{ @@ -215,6 +215,21 @@ describe('when importing value from another stack', () => { const parameter = describeBucketRoleStack.Stacks[0].Parameters.find(x=>x.ParameterKey === 'jsonString2'); expect(parameter.ParameterValue).toBe('{"key":"val"}'); }) + + test('ref to orgPrincipalId resolves correctly', () =>{ + expect(describeBucketRoleStack).toBeDefined(); + + const parameter = describeBucketRoleStack.Stacks[0].Parameters.find(x=>x.ParameterKey === 'orgPrincipalId'); + expect(parameter.ParameterValue).toBe('o-82c6hlhsvp'); + }) + + test('ref to orgStateBucket resolves correctly', () =>{ + expect(describeBucketRoleStack).toBeDefined(); + + const parameter = describeBucketRoleStack.Stacks[0].Parameters.find(x=>x.ParameterKey === 'orgStateBucketName'); + expect(parameter.ParameterValue).toBe(context.stateBucketName); + }) + test('cleanup removes deployed stacks', () => { expect(stacksAfterCleanup.StackSummaries.find(x=>x.StackName === 'my-scenario-export-bucket')).toBeUndefined(); expect(stacksAfterCleanup.StackSummaries.find(x=>x.StackName === 'my-scenario-export-bucket-role')).toBeUndefined(); diff --git a/test/integration-tests/scenario-very-large-stack.test.ts b/test/integration-tests/scenario-very-large-stack.test.ts index 7d79320a..c6355e73 100644 --- a/test/integration-tests/scenario-very-large-stack.test.ts +++ b/test/integration-tests/scenario-very-large-stack.test.ts @@ -1,13 +1,12 @@ import { PerformTasksCommand, ValidateTasksCommand } from '~commands/index'; -import { IIntegrationTestContext, baseBeforeAll, baseAfterAll } from './base-integration-test'; -import { ListStacksOutput, GetStackPolicyOutput } from 'aws-sdk/clients/cloudformation'; -import { ConsoleUtil } from '~util/console-util'; +import { IIntegrationTestContext, baseBeforeAll } from './base-integration-test'; +import { DescribeStacksOutput } from 'aws-sdk/clients/cloudformation'; const basePathForScenario = './test/integration-tests/resources/scenario-very-large-stack/'; describe('when calling org-formation perform tasks', () => { let context: IIntegrationTestContext; - let veryLargeStack: GetStackPolicyOutput; + let veryLargeStack: DescribeStacksOutput; beforeAll(async () => { @@ -19,15 +18,18 @@ describe('when calling org-formation perform tasks', () => { await ValidateTasksCommand.Perform({...command, tasksFile: basePathForScenario + '1-deploy-very-large-stack.yml' }) await PerformTasksCommand.Perform({...command, tasksFile: basePathForScenario + '1-deploy-very-large-stack.yml' }); - veryLargeStack = await cfnClient.getStackPolicy({StackName: 'test-with-very-large-stack'}).promise(); + veryLargeStack = await cfnClient.describeStacks({StackName: 'test-with-very-large-stack'}).promise(); await PerformTasksCommand.Perform({...command, tasksFile: basePathForScenario + '9-cleanup-very-large-stack.yml', performCleanup: true }); }); - test('stack has Stack Policy', () => { + test('stack was deployed successfully', () => { expect(veryLargeStack).toBeDefined(); + expect(veryLargeStack.Stacks.length).toBe(1); + expect(veryLargeStack.Stacks[0]).toBeDefined(); + expect(veryLargeStack.Stacks[0].StackStatus).toBe('CREATE_COMPLETE'); }); }); \ No newline at end of file diff --git a/test/unit-tests/commands/print-stacks.test.ts b/test/unit-tests/commands/print-stacks.test.ts index e71e4a10..b9fae15e 100644 --- a/test/unit-tests/commands/print-stacks.test.ts +++ b/test/unit-tests/commands/print-stacks.test.ts @@ -40,6 +40,30 @@ describe('when creating print stacks command', () => { expect(stackNameOpt.required).toBeTruthy(); }); + test('command has required output path parameter with default', () => { + const opts: Option[] = subCommanderCommand.options; + const stackNameOpt = opts.find((x) => x.long === '--output-path'); + expect(stackNameOpt).toBeDefined(); + expect(stackNameOpt.required).toBeFalsy(); + expect(subCommanderCommand.outputPath).toBe(undefined); + }); + + test('command has required output parameter with default', () => { + const opts: Option[] = subCommanderCommand.options; + const stackNameOpt = opts.find((x) => x.long === '--output'); + expect(stackNameOpt).toBeDefined(); + expect(stackNameOpt.required).toBeTruthy(); + expect(subCommanderCommand.output).toBe('yaml'); + }); + + test('command has required output parameter with default', () => { + const opts: Option[] = subCommanderCommand.options; + const stackNameOpt = opts.find((x) => x.long === '--output-cross-account-exports'); + expect(stackNameOpt).toBeDefined(); + expect(stackNameOpt.required).toBeTruthy(); + expect(subCommanderCommand.outputCrossAccountExports).toBe(false); + }); + test('command has state bucket parameter with correct default', () => { const opts: Option[] = subCommanderCommand.options; const stateBucketOpt = opts.find((x) => x.long === '--state-bucket-name'); @@ -112,6 +136,7 @@ describe('when executing print-stacks command', () => { ...subCommanderCommand, stackName: 'myStackName', templateFile: 'template.yml', + output: 'json' } as unknown as IPrintStacksCommandArgs; }); diff --git a/test/unit-tests/commands/print-tasks.test.ts b/test/unit-tests/commands/print-tasks.test.ts new file mode 100644 index 00000000..0dd3b87c --- /dev/null +++ b/test/unit-tests/commands/print-tasks.test.ts @@ -0,0 +1,50 @@ +import { Command, Option } from "commander"; +import Sinon = require("sinon"); +import { PrintTasksCommand } from "~commands/print-tasks"; + +describe('when creating print tasks command', () => { + let command: PrintTasksCommand; + let commanderCommand: Command; + let subCommanderCommand: Command; + + beforeEach(() => { + commanderCommand = new Command('root'); + command = new PrintTasksCommand(commanderCommand); + subCommanderCommand = commanderCommand.commands[0]; + }); + + test('print tasks command is created', () => { + expect(command).toBeDefined(); + expect(subCommanderCommand).toBeDefined(); + expect(subCommanderCommand.name()).toBe('print-tasks'); + }); + + test('print tasks command has description', () => { + expect(subCommanderCommand).toBeDefined(); + expect(subCommanderCommand.description()).toBeDefined(); + }); + + test('command has required output path parameter with default', () => { + const opts: Option[] = subCommanderCommand.options; + const stackNameOpt = opts.find((x) => x.long === '--output-path'); + expect(stackNameOpt).toBeDefined(); + expect(stackNameOpt.required).toBeTruthy(); + expect(subCommanderCommand.outputPath).toBe('./.printed-stacks/'); + }); + + test('command has required output parameter with default', () => { + const opts: Option[] = subCommanderCommand.options; + const stackNameOpt = opts.find((x) => x.long === '--output'); + expect(stackNameOpt).toBeDefined(); + expect(stackNameOpt.required).toBeTruthy(); + expect(subCommanderCommand.output).toBe('yaml'); + }); + + test('command has required output parameter with default', () => { + const opts: Option[] = subCommanderCommand.options; + const stackNameOpt = opts.find((x) => x.long === '--output-cross-account-exports'); + expect(stackNameOpt).toBeDefined(); + expect(stackNameOpt.required).toBeTruthy(); + expect(subCommanderCommand.outputCrossAccountExports).toBe(false); + }); +}); \ No newline at end of file diff --git a/test/unit-tests/plugin/impl/cdk-build-task-plugin.test.ts b/test/unit-tests/plugin/impl/cdk-build-task-plugin.test.ts index 97f1cd89..ef49fcc7 100644 --- a/test/unit-tests/plugin/impl/cdk-build-task-plugin.test.ts +++ b/test/unit-tests/plugin/impl/cdk-build-task-plugin.test.ts @@ -106,6 +106,7 @@ describe('when resolving attribute expressions on update', () => { definition: task, }, task, + previousBindingLocalHash: 'abcdef' }; binder = new PluginBinder(task, 'default', undefined, state, template, undefined, plugin); }); @@ -263,6 +264,7 @@ describe('when resolving attribute expressions on remove', () => { definition: task, }, task, + previousBindingLocalHash: 'abcdef' }; binder = new PluginBinder(task, 'default', undefined, state, template, undefined, plugin); diff --git a/test/unit-tests/plugin/impl/s3-copy-build-task-plugin.test.ts b/test/unit-tests/plugin/impl/s3-copy-build-task-plugin.test.ts index 7daaa380..f0f02a43 100644 --- a/test/unit-tests/plugin/impl/s3-copy-build-task-plugin.test.ts +++ b/test/unit-tests/plugin/impl/s3-copy-build-task-plugin.test.ts @@ -94,6 +94,7 @@ describe('when performing copy to s3', () => { definition: task, }, task, + previousBindingLocalHash: 'abcdef' }; binder = new PluginBinder(task, 'default', undefined, state, template, undefined, plugin); }); @@ -169,6 +170,7 @@ describe('when removing copy-to-s3', () => { definition: task, }, task, + previousBindingLocalHash: 'abcdef' }; binder = new PluginBinder(task, 'default', undefined, state, template, undefined, plugin); }); diff --git a/test/unit-tests/plugin/impl/sls-build-task-plugin.test.ts b/test/unit-tests/plugin/impl/sls-build-task-plugin.test.ts index bb76e61d..9185e525 100644 --- a/test/unit-tests/plugin/impl/sls-build-task-plugin.test.ts +++ b/test/unit-tests/plugin/impl/sls-build-task-plugin.test.ts @@ -109,6 +109,7 @@ describe('when resolving attribute expressions on update', () => { definition: task, }, task, + previousBindingLocalHash: 'abcdef' }; binder = new PluginBinder(task, 'default', undefined, state, template, undefined, plugin); }); @@ -292,6 +293,7 @@ describe('when resolving attribute expressions on remove', () => { definition: task, }, task, + previousBindingLocalHash: 'abcdef' }; binder = new PluginBinder(task, 'default', undefined, state, template, undefined, plugin); });