Last active
October 2, 2022 20:36
-
-
Save brettswift/6e48a70d808a28614438520682459f0c to your computer and use it in GitHub Desktop.
CDK 1.10.1 - ASG Cloudformation Init example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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} |
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
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.
AWS::CloudFormation::Authentication
allows cloudformation to copy the file to the instanceThis is a starting point, where I can have an init construct to handle this for me.
Questions
Feedback / improvements welcome. (This code comes from a working cdk project but I have only posted the relevant pieces).