Skip to content

Commit

Permalink
feat(aws-ec2): add support for CloudFormation::Init
Browse files Browse the repository at this point in the history
Make a new construct to manager User Data, which should make it easier
to apply the features correctly.

Fixes #623 and 777.
  • Loading branch information
Rico Huijbers committed Sep 27, 2018
1 parent 3d48eb2 commit c8e893c
Show file tree
Hide file tree
Showing 5 changed files with 413 additions and 10 deletions.
21 changes: 15 additions & 6 deletions packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,11 @@ export class AutoScalingGroup extends cdk.Construct implements cdk.ITaggable, el
*/
public readonly tags: cdk.TagManager;

private readonly userDataLines = new Array<string>();
/**
* 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[] = [];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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() {
Expand Down
225 changes: 225 additions & 0 deletions packages/@aws-cdk/aws-ec2/lib/cloudformation-init.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-ec2/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
33 changes: 29 additions & 4 deletions packages/@aws-cdk/aws-ec2/lib/machine-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,19 +220,38 @@ 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;
}

/**
* OS features specialized for Windows
*/
export class WindowsOS extends OperatingSystem {
public createUserData(scripts: string[]): string {
public createUserData(scripts: string[], _options: UserDataOptions): string {
return `<powershell>${scripts.join('\n')}</powershell>`;
}

Expand All @@ -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 {
Expand Down
Loading

0 comments on commit c8e893c

Please sign in to comment.