Skip to content

Instantly share code, notes, and snippets.

@brettswift
Last active October 2, 2022 20:36
Show Gist options
  • Select an option

  • Save brettswift/6e48a70d808a28614438520682459f0c to your computer and use it in GitHub Desktop.

Select an option

Save brettswift/6e48a70d808a28614438520682459f0c to your computer and use it in GitHub Desktop.

Revisions

  1. brettswift revised this gist Oct 15, 2019. 3 changed files with 226 additions and 0 deletions.
    115 changes: 115 additions & 0 deletions CfnInitMetadataBuilder.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,115 @@
    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;
    }
    }
    71 changes: 71 additions & 0 deletions CfnInitScriptAsset.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,71 @@
    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}
    }

    }
    40 changes: 40 additions & 0 deletions partial construct using the above builder and ScriptAsset classes
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,40 @@

    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,
    });
  2. brettswift created this gist Oct 7, 2019.
    89 changes: 89 additions & 0 deletions partial_code_for_construct.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,89 @@

    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);
    73 changes: 73 additions & 0 deletions userdata.sh
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,73 @@
    # 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}