Skip to content

Commit

Permalink
Create GuSSMParameter
Browse files Browse the repository at this point in the history
  • Loading branch information
stephengeller committed Mar 22, 2021
1 parent 6ad6a37 commit d4ce39c
Show file tree
Hide file tree
Showing 8 changed files with 19,706 additions and 81 deletions.
19,669 changes: 19,615 additions & 54 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"@typescript-eslint/eslint-plugin": "^4.16.1",
"@typescript-eslint/parser": "^4.16.1",
"cz-conventional-changelog": "^3.3.0",
"aws-sdk": "^2.866.0",
"eslint": "^7.22.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-custom-rules": "file:eslint",
Expand Down Expand Up @@ -61,6 +60,7 @@
"@aws-cdk/aws-rds": "1.94.1",
"@aws-cdk/aws-s3": "1.94.1",
"@aws-cdk/core": "1.94.1",
"aws-sdk": "^2.868.0",
"read-pkg-up": "^7.0.1"
},
"config": {
Expand Down
4 changes: 4 additions & 0 deletions src/constructs/core/custom-resources/runtime/lambda.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/*
* The reason for this file is to appease the tests in `core/ssm.test.ts`.
* We don't need this to contain anything useful because we're not testing the contents of it within that testing suite.
* */
17 changes: 17 additions & 0 deletions src/constructs/core/custom-resources/runtime/lambda.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* You can't specify the file extension (eg .ts) of the file to import,
* and lambda.js exists as a dummy file, so we'd always import that over lambda.ts (the actual code to be tested) if
* we imported `lambda`
* lambda.symlink is a symlink to lambda.ts, so we can test it without importing lambda.js
* */
import { flatten } from "./lambda.symlink";

describe("The flatten function", function () {
it("should flatten nested objects into one", function () {
expect(flatten({ foo: { bar: "baz" } })).toEqual({ "foo.bar": "baz" });
});

it("should flatten infinitely nested objects into one", function () {
expect(flatten({ foo: { bar: { baz: { blah: "foo" } } } })).toEqual({ "foo.bar.baz.blah": "foo" });
});
});
18 changes: 10 additions & 8 deletions src/constructs/core/custom-resources/runtime/lambda.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
import { request } from "https";
import { parse } from "url";
// eslint-disable-next-line import/no-unresolved -- this should come from @types/aws-lambda, but doesn't for some reason
// eslint-disable-next-line import/no-unresolved -- this comes from @types/aws-lambda, but eslint can't seem to read it properly
import type { CloudFormationCustomResourceEvent, Context } from "aws-lambda";
import AWS from "aws-sdk";
import SSM from "aws-sdk/clients/ssm";
import type { CustomResourceGetParameterProps } from "../interfaces";

// Copied over from
/* eslint-disable -- This function is copied straight from AWS */
// https://github.com/aws/aws-cdk/blob/95438b56bfdc90e94f969f6998e5b5b680cbd7a8/packages/%40aws-cdk/custom-resources/lib/aws-custom-resource/runtime/index.ts#L16-L29
export function flatten(object: Record<string, unknown>): Record<string, string> {
return Object.assign(
{},
...(function _flatten(child: any, path: string[] = []): any {
...(function _flatten(child: Record<string, any>, path: string[] = []): any {
return [].concat(
...Object.keys(child).map((key) => {
const childKey = Buffer.isBuffer(child[key]) ? child[key].toString("utf8") : child[key];
return typeof childKey === "object" && childKey !== null
const childKey = Buffer.isBuffer(child[key]) ? child[key].toString() : child[key];
return typeof childKey === "object"
? _flatten(childKey, path.concat([key]))
: { [path.concat([key]).join(".")]: childKey };
})
);
})(object)
) as Record<string, string>;
}
/* eslint-enable */

export async function handler(event: CloudFormationCustomResourceEvent, context: Context): Promise<void> {
try {
Expand All @@ -47,7 +48,7 @@ export async function handler(event: CloudFormationCustomResourceEvent, context:

if (getParamsProps) {
const request = decodeCall(getParamsProps);
const ssmClient = new AWS.SSM();
const ssmClient = new SSM();
const response = await ssmClient.getParameter(request.apiRequest).promise();
console.log("Response:", JSON.stringify(response, null, 4));
data = { ...flatten((response as unknown) as Record<string, string>) };
Expand All @@ -57,7 +58,8 @@ export async function handler(event: CloudFormationCustomResourceEvent, context:
await respond("SUCCESS", "OK", physicalResourceId, data);
} catch (e) {
console.log(e);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- e.message exists, not sure how to type it explicitly
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/message
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- e.message is valid, see docs above
await respond("FAILED", e.message || "Internal Error", context.logStreamName, {});
}

Expand Down
33 changes: 33 additions & 0 deletions src/constructs/core/ssm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import "@aws-cdk/assert/jest";
import { simpleGuStackForTesting } from "../../../test/utils";
import { GuSSMIdentityParameter, GuSSMParameter } from "./ssm";

describe("SSM:", () => {
describe("The GuSSMIdentityParameter construct", () => {
beforeAll(() => {
// TODO: Experiment with compiling `custom-resources/runtime/lambda.ts` to JS so that tests run
});

it("requires the scope and parameter name", function () {
const stack = simpleGuStackForTesting({ stack: "some-stack" });
const param = new GuSSMIdentityParameter(stack, { parameter: "some-param", app: "foo" });
expect(stack).toHaveResourceLike("Custom::GuGetSSMParameter", {
getParamsProps: {
"Fn::Join": ["", ['{"apiRequest":{"Name":"/', { Ref: "Stage" }, '/some-stack/foo/some-param"}}']],
},
});
expect(param.getValue()).toMatch(/TOKEN/i);
});
});

describe("The GuSSMParameter construct", () => {
it("requires the scope and parameter name", function () {
const stack = simpleGuStackForTesting({ stack: "some-stack" });
const param = new GuSSMParameter(stack, { parameter: "/path/to/some-param" });
expect(stack).toHaveResourceLike("Custom::GuGetSSMParameter", {
getParamsProps: '{"apiRequest":{"Name":"/path/to/some-param"}}',
});
expect(param.getValue()).toMatch(/TOKEN/i);
});
});
});
43 changes: 25 additions & 18 deletions src/constructs/core/ssm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,38 @@ import type { Reference } from "@aws-cdk/core";
import { Construct, CustomResource, Duration } from "@aws-cdk/core";
import { AwsCustomResourcePolicy } from "@aws-cdk/custom-resources";
import type { CustomResourceGetParameterProps } from "./custom-resources/interfaces";
import type { AppIdentity } from "./identity";
import type { GuStack } from "./stack";

export interface GuSSMParameterProps {
parameter: string;
secure?: boolean;
/*
* Assumes the path of `/${STAGE}/${STACK}/${APP}/${param}`
* */
defaultPath: boolean;
}

export interface GuSSMIdentityParameterProps extends GuSSMParameterProps, AppIdentity {}

const stripped = (str: string) => str.replace(/[-/]/g, "");

export class GuSSMParameter extends Construct implements IGrantable {
private readonly customResource: CustomResource;
readonly grantPrincipal: IPrincipal;

// eslint-disable-next-line custom-rules/valid-constructors -- I think stating an ID would be overkill for this, but happy to discuss
constructor(scope: GuStack, param: string, props?: GuSSMParameterProps) {
// TODO: Establish if this is an accepted, safe way of creating IDs
const id = (id: string) =>
param.toUpperCase().includes("TOKEN") ? `${id}-token-${Date.now()}` : `${id}-${stripped(param)}`;
// eslint-disable-next-line custom-rules/valid-constructors -- TODO: Remove once linting rules have been relaxed for this
constructor(scope: GuStack, props: GuSSMParameterProps) {
const { parameter } = props;

const id = (id: string) => {
const now = Date.now();
// We need to create UIDs for the resources in this construct, as otherwise CFN will not trigger the lambda on updates for resources that appear to be the same
const uid = now.toString().substr(now.toString().length - 4);
return parameter.toUpperCase().includes("TOKEN") ? `${id}-token-${uid}` : `${id}-${stripped(parameter)}-${uid}`;
};

super(scope, id("GuSSMParameter"));

const provider = new SingletonFunction(scope, id("Provider"), {
code: Code.fromInline(readFileSync(join(__dirname, "/custom-resources/runtime/lambda.js")).toString()),
// runtime: new Runtime("nodejs14.x", RuntimeFamily.NODEJS, { supportsInlineCode: true }), -- we can use Node14 once we bump the version of @aws-cdk to v1.94 https://github.com/aws/aws-cdk/releases/tag/v1.94.0
runtime: Runtime.NODEJS_12_X,
runtime: Runtime.NODEJS_12_X, // TODO: Ensure that the TS -> JS compile creates a NODE12-compliant file
handler: "index.handler",
uuid: "eda001a3-b7c8-469d-bc13-787c4e13cfd9",
lambdaPurpose: "Lambda to fetch SSM parameters",
Expand All @@ -56,10 +60,8 @@ export class GuSSMParameter extends Construct implements IGrantable {
policy.attachToRole(provider.role);
}

const fullParamName = props?.defaultPath ? `/${scope.stage}/${scope.stack}/${scope.app}/${param}` : param;

const getParamsProps: CustomResourceGetParameterProps = {
apiRequest: { Name: fullParamName, WithDecryption: props?.secure },
apiRequest: { Name: parameter, WithDecryption: props.secure },
};

this.customResource = new CustomResource(this, id("Resource"), {
Expand All @@ -75,16 +77,21 @@ export class GuSSMParameter extends Construct implements IGrantable {
}

public getValueReference(): Reference {
console.log(this.customResource.toString());
return this.customResource.getAtt("Parameter.Value");
}

public getValue(): string {
console.log(this.customResource.toString());
return this.customResource.getAttString("Parameter.Value");
}
}

export function GuSSMDefaultParam(scope: GuStack, param: string): GuSSMParameter {
return new GuSSMParameter(scope, param, { defaultPath: true });
/*
* Assumes the path of `/${STAGE}/${STACK}/${APP}/${parameter}`
*
* */
export class GuSSMIdentityParameter extends GuSSMParameter {
// eslint-disable-next-line custom-rules/valid-constructors -- this may not be necessary going forward
constructor(scope: GuStack, props: GuSSMIdentityParameterProps) {
super(scope, { ...props, parameter: `/${scope.stage}/${scope.stack}/${props.app}/${props.parameter}` });
}
}

0 comments on commit d4ce39c

Please sign in to comment.