From c8e893cefc328792a4a9df87c84e3f207f0cfd82 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 27 Sep 2018 14:55:23 +0200 Subject: [PATCH] feat(aws-ec2): add support for CloudFormation::Init Make a new construct to manager User Data, which should make it easier to apply the features correctly. Fixes #623 and 777. --- .../aws-autoscaling/lib/auto-scaling-group.ts | 21 +- .../aws-ec2/lib/cloudformation-init.ts | 225 ++++++++++++++++++ packages/@aws-cdk/aws-ec2/lib/index.ts | 1 + .../@aws-cdk/aws-ec2/lib/machine-image.ts | 33 ++- packages/@aws-cdk/aws-ec2/lib/user-data.ts | 143 +++++++++++ 5 files changed, 413 insertions(+), 10 deletions(-) create mode 100644 packages/@aws-cdk/aws-ec2/lib/cloudformation-init.ts create mode 100644 packages/@aws-cdk/aws-ec2/lib/user-data.ts diff --git a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts index 8d55ff8a5582f..ea87c9d738cc5 100644 --- a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts +++ b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts @@ -169,7 +169,11 @@ export class AutoScalingGroup extends cdk.Construct implements cdk.ITaggable, el */ public readonly tags: cdk.TagManager; - private readonly userDataLines = new Array(); + /** + * The user data associated with this AutoScalingGroup + */ + public readonly userData: ec2.UserData; + private readonly autoScalingGroup: cloudformation.AutoScalingGroupResource; private readonly securityGroup: ec2.SecurityGroupRef; private readonly securityGroups: ec2.SecurityGroupRef[] = []; @@ -199,16 +203,14 @@ export class AutoScalingGroup extends cdk.Construct implements cdk.ITaggable, el // use delayed evaluation const machineImage = props.machineImage.getImage(this); - const userDataToken = new cdk.Token(() => new cdk.FnBase64((machineImage.os.createUserData(this.userDataLines)))); - const securityGroupsToken = new cdk.Token(() => this.securityGroups.map(sg => sg.securityGroupId)); const launchConfig = new cloudformation.LaunchConfigurationResource(this, 'LaunchConfig', { imageId: machineImage.imageId, keyName: props.keyName, instanceType: props.instanceType.toString(), - securityGroups: securityGroupsToken, + securityGroups: new cdk.Token(() => this.securityGroups.map(sg => sg.securityGroupId)), iamInstanceProfile: iamProfile.ref, - userData: userDataToken + userData: new cdk.Token(() => new cdk.FnBase64(this.userData.render())) }); launchConfig.addDependency(this.role); @@ -250,6 +252,11 @@ export class AutoScalingGroup extends cdk.Construct implements cdk.ITaggable, el this.autoScalingGroup = new cloudformation.AutoScalingGroupResource(this, 'ASG', asgProps); this.osType = machineImage.os.type; + this.userData = new ec2.UserData(this, 'UserData', { + os: machineImage.os, + defaultSignalResource: this.autoScalingGroup + }); + this.applyUpdatePolicies(props); } @@ -290,9 +297,11 @@ export class AutoScalingGroup extends cdk.Construct implements cdk.ITaggable, el /** * Add command to the startup script of fleet instances. * The command must be in the scripting language supported by the fleet's OS (i.e. Linux/Windows). + * + * @deprecated Use userdata.addCommands() instead. */ public addUserData(...scriptLines: string[]) { - scriptLines.forEach(scriptLine => this.userDataLines.push(scriptLine)); + this.userData.addCommand(...scriptLines); } public autoScalingGroupName() { diff --git a/packages/@aws-cdk/aws-ec2/lib/cloudformation-init.ts b/packages/@aws-cdk/aws-ec2/lib/cloudformation-init.ts new file mode 100644 index 0000000000000..b3462dcd87e32 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/cloudformation-init.ts @@ -0,0 +1,225 @@ +import cdk = require('@aws-cdk/cdk'); + +/** + * Describe startup configuration for EC2 instances + */ +export class CloudFormationInit extends cdk.Construct { + /** + * Logical ID of the Init resource + */ + public readonly initResourceId: string; + + private readonly configs: {[name: string]: InitConfig} = {}; + private readonly configSets: {[name: string]: string[]} = {}; + + constructor(parent: cdk.Construct, id: string) { + super(parent, id); + + const resource = new cdk.Resource(this, 'Resource', { + // I know this thing says it lives in the AWS::CloudFormation namespace, + // but it really doesn't make sense there. + type: 'AWS::CloudFormation::Init', + properties: new cdk.Token(() => this.render), + }); + + this.initResourceId = resource.logicalId; + } + + public addConfig(configName: string, ...configSets: string[]): InitConfig { + const config = new InitConfig(); + this.configs[configName] = config; + + this.addToConfigSets(configSets, configName); + + return config; + } + + private addToConfigSets(configSets: string[], configName: string) { + if (configSets.length === 0) { configSets = ['default']; } + for (const configSet of configSets) { + if (!(configSet in this.configSets)) { this.configSets[configSet] = []; } + + this.configSets[configSet].push(configName); + } + } + + private render() { + return {}; + } +} + +/** + * A single config + */ +export class InitConfig { + private readonly commands: {[key: string]: InitCommand} = {}; + private readonly files: {[fileName: string]: InitFile} = {}; + private readonly groups: {[groupName: string]: InitGroup} = {}; + private readonly users: {[userName: string]: InitUser} = {}; + private readonly packages: {[pkgType: string]: {[name: string]: PackageVersion}} = {}; + + public addCommand(name: string, command: InitCommand) { + this.commands[name] = command; + } + + public addFile(fileName: string, file: InitFile) { + this.files[fileName] = file; + } + + public addGroup(groupName: string, group: InitGroup = {}) { + this.groups[groupName] = group; + } + + public addUser(userName: string, user: InitUser = {}) { + this.users[userName] = user; + } + + public addPackage(type: PackageType, name: string, version: PackageVersion = {}) { + if (!(type in this.packages)) { this.packages[type] = {}; } + this.packages[type][name] = version; + } +} + +export interface InitCommand { + /** + * The command to run as a shell command. + * + * Exactly one of 'shellCommand' and 'command' must be specified. + */ + shellCommand?: string; + + /** + * The command to run as the binary path and arguments. + * + * If you use this form, you do not have to quote command-line arguments. + * + * Exactly one of 'shellCommand' and 'command' must be specified. + */ + command?: string[]; + + /** + * Completely replace the environment to run this command in + * + * @default Inherit environment + */ + env?: {[key: string]: string}; + + /** + * Working directory + * + * @default Current working directory + */ + cwd?: string; + + /** + * Run this command first, and only run command if this command exits with 0 + * + * @default No Test + */ + test?: string; + + /** + * Continue even if the command fails + * + * @default false + */ + ignoreErrors?: boolean; + + /** + * Wait after the command (in case it causes a reboot) + * + * Windows only. Can be a number of seconds, or the word "forever" in + * which case the computer MUST reboot for the script to continue. + * + * @default 60 + */ + waitAfterCompletionSeconds?: string; +} + +export interface InitFile { + /** + * URL to load the contents from + */ + sourceUrl?: string; + + /** + * File contents or symlink source location + */ + content?: string; + + /** + * JSON object which should be the content of the file + * + * May contain CloudFormation intrinsics. + */ + contentObject?: object; + + /** + * Encoding format. + * + * Only used if content is given. + */ + encoding?: Encoding; + + /** + * Name of the group owner of the file + * + * Not on Windows. + */ + group?: string; + + /** + * Name of the user owner of the file + * + * Not on Windows. + */ + user?: string; + + /** + * Octal representation of file permissions + * + * Not on Windows. + */ + mode?: string; +} + +export interface InitGroup { + /** + * GID of the group + * + * @default Automatic + */ + groupId?: number; +} + +export interface InitUser { + /** + * UID of the user + * + * @default Automatic + */ + userId?: number; + + groups?: string[]; + + homeDir?: string; +} + +export enum Encoding { + Plain = 'plain', + Base64 = 'base64' +} + +export enum PackageType { + Rpm = 'rpm', + Yum = 'yum', + Apt = 'apt', + RubyGems = 'rubygems', + Python = 'python', +} + +export interface PackageVersion { + version?: string; + + versions?: string[]; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/lib/index.ts b/packages/@aws-cdk/aws-ec2/lib/index.ts index 9e6e38d27bba0..327b2ad698ef6 100644 --- a/packages/@aws-cdk/aws-ec2/lib/index.ts +++ b/packages/@aws-cdk/aws-ec2/lib/index.ts @@ -3,6 +3,7 @@ export * from './instance-types'; export * from './machine-image'; export * from './security-group'; export * from './security-group-rule'; +export * from './user-data'; export * from './vpc'; export * from './vpc-ref'; diff --git a/packages/@aws-cdk/aws-ec2/lib/machine-image.ts b/packages/@aws-cdk/aws-ec2/lib/machine-image.ts index 72ec91036824f..f9bf1933c18fb 100644 --- a/packages/@aws-cdk/aws-ec2/lib/machine-image.ts +++ b/packages/@aws-cdk/aws-ec2/lib/machine-image.ts @@ -220,11 +220,30 @@ export enum OperatingSystemType { Windows, } +/** + * Options for rendering UserData + */ +export interface UserDataOptions { + /** + * Whether to log commands as they're being executed + * + * @default true + */ + verbose?: boolean; + + /** + * Whether to stop executing as soon as an error is encountered + * + * @default false + */ + strict?: boolean; +} + /** * Abstraction of OS features we need to be aware of */ export abstract class OperatingSystem { - public abstract createUserData(scripts: string[]): string; + public abstract createUserData(scripts: string[], options: UserDataOptions): string; abstract get type(): OperatingSystemType; } @@ -232,7 +251,7 @@ export abstract class OperatingSystem { * OS features specialized for Windows */ export class WindowsOS extends OperatingSystem { - public createUserData(scripts: string[]): string { + public createUserData(scripts: string[], _options: UserDataOptions): string { return `${scripts.join('\n')}`; } @@ -245,8 +264,14 @@ export class WindowsOS extends OperatingSystem { * OS features specialized for Linux */ export class LinuxOS extends OperatingSystem { - public createUserData(scripts: string[]): string { - return '#!/bin/bash\n' + scripts.join('\n'); + public createUserData(scripts: string[], options: UserDataOptions): string { + const bashFlags = []; + if (options.verbose !== false) { bashFlags.push('x'); } + if (options.strict) { bashFlags.push('eu'); } + + const bashCommand = '#!/bin/bash' + (bashFlags.length > 0) ? ` -${bashFlags.join('')}` : ''; + + return [bashCommand].concat(scripts).join('\n'); } get type(): OperatingSystemType { diff --git a/packages/@aws-cdk/aws-ec2/lib/user-data.ts b/packages/@aws-cdk/aws-ec2/lib/user-data.ts new file mode 100644 index 0000000000000..b27b87e398edb --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/user-data.ts @@ -0,0 +1,143 @@ +import cdk = require('@aws-cdk/cdk'); +import { CloudFormationInit } from './cloudformation-init'; +import { OperatingSystem, UserDataOptions } from './machine-image'; + +/** + * Properties for creating a UserData + */ +export interface UserDataProps extends UserDataOptions { + /** + * Operating system to generate UserData for + */ + os: OperatingSystem; + + /** + * Default resource to generate signals for + */ + defaultResource?: cdk.Resource; +} + +/** + * Class that represents user data + */ +export class UserData extends cdk.Construct { + private readonly lines = new Array(); + private readonly os: OperatingSystem; + + constructor(parent: cdk.Construct, id: string, private readonly props: UserDataProps ) { + super(parent, id); + this.os = props.os; + } + + /** + * Add a literal command to the userdata + */ + public addCommand(...lines: string[]) { + this.lines.push(...lines); + } + + /** + * Add a command to signal a resource + */ + public addResourceSignalCommand(options: ResourceSignalOptions = {}) { + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-signal.html + const resource = options.resource || this.props.defaultResource; + if (!resource) { + throw new Error('UserData does not have a default resource; please provide an explicit resource to signal'); + } + + const parts = ['cfn-signal', + `--region ${new cdk.AwsRegion()}`, + `--stack ${new cdk.AwsStackId()}`, + `--resource ${resource.logicalId}` + ]; + + if (options.success === undefined) { + // Use most recent command's exit code + parts.push('--exit-code $?'); + } else { + // Rely on stringification of booleans here + parts.push(`--success ${options.success}`); + } + + if (options.data) { + parts.push(`--data "${options.data}"`); + } + if (options.reason) { + parts.push(`--reason "${options.reason}"`); + } + + this.addCommand(parts.join(' ')); + } + + /** + * Add a command to apply CloudFormation Init + */ + public addInitCommand(options: InitCommandOptions) { + const configSets = options.configSets || ['default']; + + const parts = ['cfn-init', + '-v', + `--region ${new cdk.AwsRegion()}`, + `--stack ${new cdk.AwsStackId()}`, + `--resource ${options.init.initResourceId}`, + `--configsets ${configSets.join(',')}` + ]; + + this.addCommand(parts.join(' ')); + this.addResourceSignalCommand(); + } + + /** + * Return the UserData for the instance or AutoScalingGroup + */ + public render() { + return this.os.createUserData(this.lines, this.props); + } +} + +/** + * What resource to signal + */ +export interface ResourceSignalOptions { + /** + * Whether to signal success + * + * @default Use exit code of most recent command + */ + success?: boolean; + + /** + * Resource to send signal for + * + * @default Current Instance or AutoScalingGroup + */ + resource?: cdk.Resource; + + /** + * Data to include in the signal + */ + data?: string; + + /** + * Reason for the signal + */ + reason?: string; +} + +/** + * What Init configuration to apply + */ +export interface InitCommandOptions { + /** + * The CloudFormation Init object to read configurations from + */ + init: CloudFormationInit; + + /** + * Names of config sets to run + * + * @default ['default'] + */ + configSets?: string[]; +} \ No newline at end of file