Skip to content

Instantly share code, notes, and snippets.

@brettswift
Last active October 2, 2022 20:36
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save brettswift/6e48a70d808a28614438520682459f0c to your computer and use it in GitHub Desktop.
Save brettswift/6e48a70d808a28614438520682459f0c to your computer and use it in GitHub Desktop.
CDK 1.10.1 - ASG Cloudformation Init example
import autoscaling = require("@aws-cdk/aws-autoscaling")
import scriptAssets = require("./CfnInitScriptAsset")
import iam = require('@aws-cdk/aws-iam')
import cdk = require('@aws-cdk/core')
/**
* Helpful context into what was built.
* Use these to get logical ID's when constructing your userdata.
*/
export interface CfnInitArtifacts {
cfnAsg: autoscaling.CfnAutoScalingGroup,
cfnLaunchConfig: autoscaling.CfnLaunchConfiguration,
metadata: {}
}
/**
* Aids in building the awkward Cfn Init Metadata.
*
* Injects the metadata onto the provided ASG.
*
* Returns a contect object that contains lower level Cfn resources you'll need to create your userdata.
* ie: CfnLaunchConfig and it's ID for cfn-init, and a CfnAutoscalingGroup and it's ID for cfn-signal.
*/
export class CfnInitMetadataBuilder {
asg: autoscaling.AutoScalingGroup
scripts: scriptAssets.CfnInitScriptAsset[]
configSetName: any;
constructor(asg: autoscaling.AutoScalingGroup, configSetName?: string){
this.asg = asg;
this.configSetName = configSetName || 'main'; // TODO: test with 'default' ?
this.scripts = []
}
public withScript(script: scriptAssets.CfnInitScriptAsset): CfnInitMetadataBuilder{
this.scripts.push(script)
this.asg.addToRolePolicy(new iam.PolicyStatement({
actions: ['s3:*'],
resources: [
`${script.bucket.bucketArn}/${script.s3ObjectKey}`
]
}))
return this
}
public build(): CfnInitArtifacts{
const cfnLaunchConfig = this.asg.node.findAll().find((item: cdk.IConstruct) =>
item.node.id === 'LaunchConfig'
) as autoscaling.CfnLaunchConfiguration
const cfnAustoScalingGroup = this.asg.node.findAll().find((item: cdk.IConstruct) =>
item.node.id === 'ASG'
) as autoscaling.CfnAutoScalingGroup
const metadata = this.buildMetadata();
cfnLaunchConfig.addOverride("Metadata", metadata)
return {
cfnAsg: cfnAustoScalingGroup,
cfnLaunchConfig: cfnLaunchConfig,
metadata: metadata,
}as CfnInitArtifacts
}
// // Types here can be an L1 files or commands object when types are available.
private arrayReducer(obj: { [x: string]: any; }, item: { [x: string]: any; }){
Object.keys(obj).push(Object.keys(item)[0])
obj[Object.keys(item)[0]] = Object.values(item)[0]
return obj;
}
private arrayToObject(theArray: { [x: string]: any; }): { [x: string]: any; }{
let theMap: { [x: string]: any; }={}
theArray.forEach((x: { [s: string]: any; } | ArrayLike<unknown>)=> {
Object.keys(theMap).push(Object.keys(x)[0])
theMap[Object.keys(x)[0]] = Object.values(x)[0]
})
return theMap
}
/**
* All scripts should be added. Build metadata json object with them.
*/
private buildMetadata(){
const metadata = {
"AWS::CloudFormation::Authentication": {
"rolebased": {
"type": "S3",
"buckets": this.scripts.map((script) => script.bucket.bucketName),
"roleName": this.asg.role.roleName
}
},
"AWS::CloudFormation::Init": {
"configSets": {
[this.configSetName]: ["configset1"]
},
"configset1": {
"files": this.scripts.map(script => script.getFileForMetadata())
.reduce(this.arrayReducer,{}),
"commands": this.arrayToObject(
this.scripts.filter(script => script.isExecutable)
.map(script => script.getCommandForMetadata())
),
}
}
}
return metadata;
}
}
import cdk = require('@aws-cdk/core');
import s3Assets = require('@aws-cdk/aws-s3-assets')
export interface CfnInitScriptAssetProps extends s3Assets.AssetProps{
friendlyName: string,
destinationFileName: string,
env?: {
[key: string]: string;
}
/**
* defaulted to /tmp/scripts
* Must start with a slash and end without a slash
* */
destinationPath?: string,
shouldExecute?: boolean,
/** default: 000755 */
mode?: string
}
export class CfnInitScriptAsset extends s3Assets.Asset{
private destinationPath: string
private destinationFileName: string
private env: {
[key: string]: string;
}
private friendlyName: string;
private mode: string;
public readonly isExecutable: boolean;
private destinationFullPath: string;
constructor(scope: cdk.Construct, id: string, props: CfnInitScriptAssetProps){
super(scope, id, props)
this.destinationPath = props.destinationPath || '/tmp/scripts'
this.destinationFileName = props.destinationFileName
this.env = props.env || {}
this.friendlyName = props.friendlyName
this.mode = props.mode || '000755'
this.destinationFullPath = `${this.destinationPath}/${this.destinationFileName}`
if(props.shouldExecute == false){
this.isExecutable = false
}else{ //if undefined or true
this.isExecutable = true
}
}
getCommandForMetadata() {
// TODO: this could be replaced with a pseudo L1 command object if one appears.
const commandInfo = {
command: this.destinationFullPath,
cwd: this.destinationPath,
env: this.env,
}
if(!this.isExecutable) return null
return {[this.friendlyName]: commandInfo}
}
getFileForMetadata() {
// TODO: support files that are not to be executed. They'll need different permissions and no 'command' section.
const fileInfo = {
source: this.s3Url,
mode: this.mode,
owner: "root",
group: "root",
}
return {[this.destinationFullPath]: fileInfo}
}
}
const fileWebServer = new initMetadata.CfnInitScriptAsset(this, 'webserverScript', {
friendlyName: 'webserver',
destinationFileName: "webserver.sh",
path: path.join(__dirname, '../scripts/webserver.sh'),
env: {
"SERVICE_VERSION": serviceVersion
}
})
const webDisplayLoad = new initMetadata.CfnInitScriptAsset(this, 'showLoad', {
shouldExecute: false,
friendlyName: 'webContentCPUData',
destinationFileName: 'webContentCPUData.sh',
path: path.join(__dirname, '../scripts/webContentCPUData.sh'),
})
const CONFIG_SET_NAME = 'main'
const builder = new initMetadata.CfnInitMetadataBuilder(asg, CONFIG_SET_NAME)
const metadataContext = builder
.withScript(fileWebServer)
.withScript(webDisplayLoad)
.build()
const importedUserData = fs.readFileSync('userdata.sh', 'utf-8');
const importedUserDataContentsReplaced = cdk.Fn.sub(importedUserData, {
scriptBucketName: scriptBucket.bucketName,
serviceVersion: serviceVersion,
logBucketName: scriptBucket.bucketName,
s3LogPrefix: s3LogPrefix,
devMode: devMode,
s3ArtifactPath: s3ArtifactPath,
configFileExtension: dnsPrefix,
asgLogicalId: metadataContext.cfnAsg.logicalId, //asg.node.uniqueId ? different? same?
launchConfigId: metadataContext.cfnLaunchConfig.logicalId,
stackName: this.stackName,
awsRegion: cdk.Aws.REGION,
configSet: CONFIG_SET_NAME,
});
const ASG_NAME = "TestAsg"
const asg = new autoscaling.AutoScalingGroup(this, ASG_NAME, {
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
keyName: keypairSsm.stringValue, // cdk ssm.StringParameter, optionally replace with the keypair name as a string
vpc: vpc, //defined outside this gist
machineImage: new ec2.AmazonLinuxImage({
generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
}),
cooldown: cdk.Duration.seconds(300),
resourceSignalTimeout: cdk.Duration.seconds(300),
updateType: autoscaling.UpdateType.REPLACING_UPDATE,
}as autoscaling.AutoScalingGroupProps)
const asgResources = asg.node.findAll()
const cfnLaunchConfig = asgResources.find((item: IConstruct) => item.node.id === 'LaunchConfig') as autoscaling.CfnLaunchConfiguration
const cfnAsg = this.node.findAll().find((item: IConstruct) =>
item.node.id === 'ASG'
) as autoscaling.CfnAutoScalingGroup
const fileWebServer = new s3Assets.Asset(this, 'webserverScript', {
path: path.join(__dirname, '../scripts/webserver.sh'), //install nginx, etc.
})
asg.addToRolePolicy(new iam.PolicyStatement({
actions: ['s3:*'],
resources: [
`${fileWebServer.bucket.bucketArn}/${fileWebServer.s3ObjectKey}`
]
}))
const destScriptsPath = '/tmp/scripts'
const destScriptFullPath = `${destScriptsPath}/webserver.sh`
const commandFriendlyName = 'install_and_run_web'
cfnLaunchConfig.addOverride("Metadata", {
"AWS::CloudFormation::Authentication": {
"rolebased" : {
"type": "S3",
"buckets": [
fileWebServer.bucket.bucketName
],
"roleName": asg.role.roleName
}
},
"AWS::CloudFormation::Init" : {
"configSets" : {
"main" : [ "config1" ]
},
"config1" : {
"files": {
[destScriptFullPath]: {
"source": fileWebServer.s3Url,
"mode": "000755",
"owner": "root",
"group": "root",
},
},
"commands" : {
[commandFriendlyName]: {
"command": destScriptFullPath,
"cwd": destScriptsPath,
"env": {
"SERVICE_VERSION" : serviceVersion,
}
}
}
}}}
)
const importedUserData = shawConstructs.readUserData("userdata.sh");
const importedUserDataContentsReplaced = cdk.Fn.sub(importedUserData, {
scriptBucketName: scriptBucket.bucketName,
serviceVersion: serviceVersion,
logBucketName: scriptBucket.bucketName,
s3LogPrefix: s3LogPrefix,
devMode: devMode,
s3ArtifactPath: s3ArtifactPath,
configFileExtension: dnsPrefix,
asgLogicalId: cfnAsg.logicalId,
launchConfigId: cfnLaunchConfig.logicalId,
stackName: this.stackName,
awsRegion: cdk.Aws.REGION,
configSet: 'main',
});
asg.connections.allowFromAnyIpv4(ec2.Port.tcp(22));
asg.addUserData(importedUserDataContentsReplaced);
# Both required for the trap.
set -euo pipefail
set -o errtrace
# if anything fails below, abort abort! Without this, ec2's just time out ..slowly...
trap 'cfn-signal-error $? $LINENO' EXIT
USERDATALOG=/var/log/user-data-server-setup-log.txt
INSTANCEID=$(curl -sSL http://169.254.169.254/latest/meta-data/instance-id)
WORKING_DIR=/tmp/installer
echo "scriptBucketName: ${scriptBucketName}"
echo "serviceVersion: ${serviceVersion}"
echo "logBucketName: ${logBucketName}"
echo "s3LogPrefix: ${s3LogPrefix}"
echo "devMode: ${devMode}"
echo "s3ArtifactPath: ${s3ArtifactPath}"
echo "config file used: ${configFileExtension}"
echo "asgLogicalId: ${asgLogicalId}"
echo "stackName: ${stackName}"
echo "awsRegion: ${awsRegion}"
echo "launchConfigId: ${launchConfigId}"
echo "configSet: ${configSet}"
# Extract local varables from ones injected.
# IMPORTANT: when using these, ensure it uses the ${!VAR) syntax. ie: exclamation mark prevents declaring a template variable. The template will strip it.
# TODO: I think we can make this more generic by setting env variables, as a cdk issue suggests, putting env vars into profile.d, and then cfn-init scripts can each use them.
SERVICE_VERSION=${serviceVersion}
SCRIPT_BUCKET_PATH=${scriptBucketName}
S3_ARTIFACT_PATH=${s3ArtifactPath}
S3_LOG_PREFIX=${s3LogPrefix}
DEV_MODE=${devMode}
LOG_BUCKET=${logBucketName}
CONFIG_NAME=${configFileExtension}
ASG_LOGICALID=${asgLogicalId}
STACK_NAME=${stackName}
AWS_REGION=${awsRegion}
LAUNCH_CONFIG_ID=${launchConfigId}
CONFIG_SET=${configSet}
BASE_LOG_UPLOAD_PATH=s3://${!LOG_BUCKET}/${!S3_LOG_PREFIX}/${!INSTANCEID}/
cfn-signal-error() {
echo "Error code $1 happened at line number $2"
echo "Signaling error code using cfn-signal"
/opt/aws/bin/cfn-signal -e 1 --stack ${!STACK_NAME} --resource ${!ASG_LOGICALID} --region ${!AWS_REGION} --reason='failed cfn-init scripts.. more info tbd...'
aws s3 cp ${!USERDATALOG} ${!BASE_LOG_UPLOAD_PATH}
aws s3 cp /var/log/cfn-* ${!BASE_LOG_UPLOAD_PATH}
exit 1
}
cfn-signal-success() {
echo "Signaling success code using cfn-signal"
/opt/aws/bin/cfn-signal -e 0 --stack ${!STACK_NAME} --resource ${!ASG_LOGICALID} --region ${!AWS_REGION}
trap - EXIT
set +e
aws s3 cp ${!USERDATALOG} ${!BASE_LOG_UPLOAD_PATH}
aws s3 cp /var/log/cfn-* ${!BASE_LOG_UPLOAD_PATH}
exit 1
}
# Begin!
# Use a code block to scope log redirection
{
set -x
echo "Instance ID: ${!INSTANCEID}"
/opt/aws/bin/cfn-init -v -c ${!CONFIG_SET} --stack ${!STACK_NAME} --resource ${!LAUNCH_CONFIG_ID} --region ${!AWS_REGION}
echo "Exit code from cfn-init was: $?"
cfn-signal-success
} >> ${!USERDATALOG} 2>>${!USERDATALOG}
@brettswift
Copy link
Author

brettswift commented Oct 7, 2019

FYI: the userdata script isn't perfect, but so far seems to work well. feedback on the traps are welcome.

Uploading log files to s3 is really just for convenience so I don't have to log into the machine.

Key parts / challenges of this are how the file is pieced together to run on the server.

  1. metadata AWS::CloudFormation::Authentication allows cloudformation to copy the file to the instance
  2. adding the file to the server using the cdk asset object
  3. executing the file using the local working directory
  4. constructing the template in a sequence that allows you to get both the ASG and Launch Config into the userdata.

This is a starting point, where I can have an init construct to handle this for me.

Questions

  1. how to manage multiple files when they need auth, and to retain execution order.
  2. is the trap code robust enough?

Feedback / improvements welcome. (This code comes from a working cdk project but I have only posted the relevant pieces).

@brettswift
Copy link
Author

Update v2.
To reduce duplication I have a couple of classes that help out with building cfn init metadata. There are now two construct examples, which makes this gist a little confusing, but essentially two ways of creating cfn-init - one with a builder class and one just directly building the JSON object.

The builder could be extrapolated to support more than just files and commands, to aid in building this.

The ScriptAsset class could be enhanced to use the previous shelved PR that had modelled the cfn init JSON object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment