Skip to content

Commit

Permalink
feat: Create GuSSMParameter using AWS Custom Resources (#336)
Browse files Browse the repository at this point in the history
* Create GuSSMParameter and the AWS Custom Resource that allows it to fetch latest version

* Create GuSSMParameter

* remove extras

* Add tests for random UIDs

* update package-lock

* feat: revise custom lint rule (#352)

We have a few too many instances of // eslint-disable-next-line custom-rules/valid-constructors 😅 .

It looks like this is mainly because the id field is static (for example the Stage parameter) or generated by the props passed in (for example InstanceType).

This PR revises the rules to be:
1. Private constructors don't get linted
2. Must be 1, 2 or 3 parameters
3. First parameter must be called scope
4. First parameter must be of type GuStack
5. If 2 parameters:
   - The second parameter must be called props
   - The second parameter must be a custom type
6. If 3 parameters:
   - The second parameter must be called id
   - The second parameter must be of type string
   - The third parameter must be called props
   - The third parameter must be a custom type
7. Only the third parameter can be optional or have a default value

* chore(release): 6.1.0 (#360)

* extract id to fn outside constructor

* Add tests for random UIDs

Co-authored-by: stephengeller <[email protected]>
Co-authored-by: Akash Askoolum <[email protected]>
Co-authored-by: theguardian.com continuous integration <[email protected]>
  • Loading branch information
4 people authored Mar 29, 2021
1 parent 39934c6 commit b65a4f0
Show file tree
Hide file tree
Showing 9 changed files with 383 additions and 2 deletions.
102 changes: 100 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
},
"devDependencies": {
"@guardian/eslint-config-typescript": "^0.5.0",
"@types/aws-lambda": "^8.10.72",
"@types/jest": "^26.0.22",
"@types/node": "14.14.37",
"@typescript-eslint/eslint-plugin": "^4.19.0",
Expand Down Expand Up @@ -60,6 +61,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": "^8.0.0"
},
"config": {
Expand Down
5 changes: 5 additions & 0 deletions src/constructs/core/custom-resources/interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { GetParameterRequest } from "aws-sdk/clients/ssm";

export interface CustomResourceGetParameterProps {
apiRequest: GetParameterRequest;
}
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" });
});
});
102 changes: 102 additions & 0 deletions src/constructs/core/custom-resources/runtime/lambda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { request } from "https";
import { parse } from "url";
// 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 SSM from "aws-sdk/clients/ssm";
import type { CustomResourceGetParameterProps } from "../interfaces";

/* 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: Record<string, any>, path: string[] = []): any {
return [].concat(
...Object.keys(child).map((key) => {
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 {
console.log(JSON.stringify(event));

// Default physical resource id
let physicalResourceId: string;
switch (event.RequestType) {
case "Create":
physicalResourceId = event.LogicalResourceId;
break;
case "Update":
physicalResourceId = event.PhysicalResourceId;
break;
case "Delete":
await respond("SUCCESS", "OK", event.PhysicalResourceId, {});
return;
}

let data: Record<string, string> = {};

const getParamsProps = event.ResourceProperties.getParamsProps as string | undefined;

if (getParamsProps) {
const request = decodeCall(getParamsProps);
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>) };
}

console.log("data: ", data);
await respond("SUCCESS", "OK", physicalResourceId, data);
} catch (e) {
console.log(e);
// 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, {});
}

function respond(responseStatus: string, reason: string, physicalResourceId: string, data: Record<string, string>) {
const responseBody = JSON.stringify({
Status: responseStatus,
Reason: reason,
PhysicalResourceId: physicalResourceId,
StackId: event.StackId,
RequestId: event.RequestId,
LogicalResourceId: event.LogicalResourceId,
NoEcho: false,
Data: data,
});

console.log("Responding", responseBody);
const parsedUrl = parse(event.ResponseURL);
const requestOptions = {
hostname: parsedUrl.hostname,
path: parsedUrl.path,
method: "PUT",
headers: { "content-type": "", "content-length": responseBody.length },
};

return new Promise((resolve, reject) => {
try {
const r = request(requestOptions, resolve);
r.on("error", reject);
r.write(responseBody);
r.end();
} catch (e) {
reject(e);
}
});
}
}

function decodeCall(call: string): CustomResourceGetParameterProps {
return JSON.parse(call) as CustomResourceGetParameterProps;
}
58 changes: 58 additions & 0 deletions src/constructs/core/ssm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import "@aws-cdk/assert/jest";
import { simpleGuStackForTesting } from "../../../test/utils";
import { GuSSMIdentityParameter, GuSSMParameter, id } from "./ssm";

describe("SSM:", () => {
describe("The GuSSMIdentityParameter construct", () => {
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);
});

it("creates unique IDs using param and stack name for the parameters", function () {
const stack = simpleGuStackForTesting({ stack: "some-stack" });
const param1 = new GuSSMParameter(stack, { parameter: "/path/to/some-param" });
const param2 = new GuSSMParameter(stack, { parameter: "/path/to/some-param" });

expect(param1.toString()).toMatch(/Test\/GuSSMParameter-pathtosomeparam/i);
expect(param2.toString()).toMatch(/Test\/GuSSMParameter-pathtosomeparam/i);
expect(param1.toString()).not.toEqual(param2.toString());
});

it("creates unique IDs that handles tokens", function () {
const stack = simpleGuStackForTesting({ stack: "some-stack" });
const param1 = new GuSSMParameter(stack, { parameter: `/path/${stack.stage}/some-param` });
const param2 = new GuSSMParameter(stack, { parameter: `/path/${stack.stage}/some-param` });
expect(param1.toString()).toMatch(/Test\/GuSSMParameter-token/i);
expect(param2.toString()).toMatch(/Test\/GuSSMParameter-token/i);
expect(param1.toString()).not.toEqual(param2.toString());
});
});

describe("the id function", function () {
it("creates a unique ID given a string", function () {
expect(id("NameOfConstruct", "some-parameter")).toMatch(/NameOfConstruct-someparameter/i);
});

it("will substitute a CDK token for an acceptable string", function () {
expect(id("NameOfConstruct", "${TOKEN}foobar")).toMatch(/NameOfConstruct-token/i);
});
});
});
Loading

0 comments on commit b65a4f0

Please sign in to comment.