Skip to content

Commit

Permalink
Merge pull request #269 from guardian/aa-asg-auto-userdata
Browse files Browse the repository at this point in the history
feat: auto generating UserData
  • Loading branch information
akash1810 authored Feb 23, 2021
2 parents 574fe9c + 5975106 commit a95f27a
Show file tree
Hide file tree
Showing 3 changed files with 202 additions and 2 deletions.
4 changes: 3 additions & 1 deletion src/constructs/autoscaling/asg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ describe("The GuAutoScalingGroup", () => {
publicSubnetIds: [""],
});

const { userData } = new GuUserData().addCommands(...["service some-dependency start", "service my-app start"]);
const { userData } = new GuUserData(simpleGuStackForTesting()).addCommands(
...["service some-dependency start", "service my-app start"]
);

const defaultProps: GuAutoScalingGroupProps = {
vpc,
Expand Down
117 changes: 117 additions & 0 deletions src/constructs/autoscaling/user-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import "@aws-cdk/assert/jest";
import { Vpc } from "@aws-cdk/aws-ec2";
import { Stack } from "@aws-cdk/core";
import { simpleGuStackForTesting } from "../../../test/utils";
import { Stage } from "../../constants";
import { GuDistributionBucketParameter } from "../core";
import { GuAutoScalingGroup } from "./asg";
import type { GuUserDataProps } from "./user-data";
import { GuUserData } from "./user-data";

describe("GuUserData", () => {
const vpc = Vpc.fromVpcAttributes(new Stack(), "VPC", {
vpcId: "test",
availabilityZones: [""],
publicSubnetIds: [""],
});

test("Distributable should be downloaded from a standard path in S3 (bucket/stack/stage/app/filename)", () => {
const stack = simpleGuStackForTesting();

const props: GuUserDataProps = {
distributable: {
bucketName: new GuDistributionBucketParameter(stack).valueAsString,
fileName: "my-app.deb",
executionStatement: `dpkg -i /${stack.app}/my-app.deb`,
},
};

const { userData } = new GuUserData(stack, props);

new GuAutoScalingGroup(stack, "AutoscalingGroup", {
vpc,
userData,
stageDependentProps: {
[Stage.CODE]: {
minimumInstances: 1,
},
[Stage.PROD]: {
minimumInstances: 3,
},
},
});

expect(stack).toHaveResource("AWS::AutoScaling::LaunchConfiguration", {
UserData: {
"Fn::Base64": {
"Fn::Join": [
"",
[
"#!/bin/bash\nmkdir -p $(dirname '/testing/my-app.deb')\naws s3 cp 's3://",
{
Ref: "DistributionBucketName",
},
"/test-stack/",
{
Ref: "Stage",
},
"/testing/my-app.deb' '/testing/my-app.deb'\ndpkg -i /testing/my-app.deb",
],
],
},
},
});
});

test("Distributable should download configuration first", () => {
const stack = simpleGuStackForTesting();

const props: GuUserDataProps = {
distributable: {
bucketName: new GuDistributionBucketParameter(stack).valueAsString,
fileName: "my-app.deb",
executionStatement: `dpkg -i /${stack.app}/my-app.deb`,
},
configuration: {
bucketName: "test-app-config",
files: ["secrets.json", "application.conf"],
},
};

const { userData } = new GuUserData(stack, props);

new GuAutoScalingGroup(stack, "AutoscalingGroup", {
vpc,
userData,
stageDependentProps: {
[Stage.CODE]: {
minimumInstances: 1,
},
[Stage.PROD]: {
minimumInstances: 3,
},
},
});

expect(stack).toHaveResource("AWS::AutoScaling::LaunchConfiguration", {
UserData: {
"Fn::Base64": {
"Fn::Join": [
"",
[
"#!/bin/bash\nmkdir -p $(dirname '/etc/testing/secrets.json')\naws s3 cp 's3://test-app-config/secrets.json' '/etc/testing/secrets.json'\nmkdir -p $(dirname '/etc/testing/application.conf')\naws s3 cp 's3://test-app-config/application.conf' '/etc/testing/application.conf'\nmkdir -p $(dirname '/testing/my-app.deb')\naws s3 cp 's3://",
{
Ref: "DistributionBucketName",
},
"/test-stack/",
{
Ref: "Stage",
},
"/testing/my-app.deb' '/testing/my-app.deb'\ndpkg -i /testing/my-app.deb",
],
],
},
},
});
});
});
83 changes: 82 additions & 1 deletion src/constructs/autoscaling/user-data.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,94 @@
import type { S3DownloadOptions } from "@aws-cdk/aws-ec2";
import { UserData } from "@aws-cdk/aws-ec2";
import { Bucket } from "@aws-cdk/aws-s3";
import type { GuStack } from "../core";

/**
* Where to download a distributable from.
* We'll look for `fileName` on the path "bucket/stack/stage/app/<fileName>".
* `executionStatement` will be something like "dpkg -i application.deb` or `service foo start`.
*/
export interface GuUserDataS3DistributableProps {
bucketName: string;
fileName: string;
executionStatement: string; // TODO can we detect this and auto generate it? Maybe from the file extension?
}

/**
* Where to download configuration from.
* `files` are paths from the root of the bucket.
* TODO change this once we have defined best practice for configuration.
*/
export interface GuUserDataS3ConfigurationProps {
bucketName: string;
files: string[];
}

export interface GuUserDataProps {
distributable: GuUserDataS3DistributableProps;
configuration?: GuUserDataS3ConfigurationProps;
}

/**
* An abstraction over UserData to simplify its creation.
* Especially useful for simple user data where we:
* - (optional) download config
* - download distributable
* - execute distributable
*/
export class GuUserData {
private _userData = UserData.forLinux();
private readonly _userData: UserData;

get userData(): UserData {
return this._userData;
}

private downloadDistributable(scope: GuStack, props: GuUserDataS3DistributableProps) {
const localDirectory = `/${scope.app}`;
const { bucketName, fileName } = props;
const bucketKey = [scope.stack, scope.stage, scope.app, fileName].join("/");

const bucket = Bucket.fromBucketAttributes(scope, "DistributionBucket", {
bucketName,
});

this.addS3DownloadCommand({
bucket: bucket,
bucketKey,
localFile: `${localDirectory}/${fileName}`,
});
}

private downloadConfiguration(scope: GuStack, props: GuUserDataS3ConfigurationProps) {
const localDirectory = `/etc/${scope.app}`;
const { bucketName, files } = props;

const bucket = Bucket.fromBucketAttributes(scope, `${scope.app}ConfigurationBucket`, {
bucketName,
});

files.forEach((bucketKey) => {
const fileName = bucketKey.split("/").slice(-1)[0];

this.addS3DownloadCommand({
bucket,
bucketKey,
localFile: `${localDirectory}/${fileName}`,
});
});
}

// eslint-disable-next-line custom-rules/valid-constructors -- TODO only lint for things that extend IConstruct
constructor(scope: GuStack, props?: GuUserDataProps) {
this._userData = UserData.forLinux();

if (props) {
props.configuration && this.downloadConfiguration(scope, props.configuration);
this.downloadDistributable(scope, props.distributable);
this.addCommands(props.distributable.executionStatement);
}
}

addCommands(...commands: string[]): GuUserData {
this._userData.addCommands(...commands);
return this;
Expand Down

0 comments on commit a95f27a

Please sign in to comment.