diff --git a/howdju-service-common/lib/initializers/parameterStore.test.ts b/howdju-service-common/lib/initializers/parameterStore.test.ts index bb89f5d0..99cee05a 100644 --- a/howdju-service-common/lib/initializers/parameterStore.test.ts +++ b/howdju-service-common/lib/initializers/parameterStore.test.ts @@ -1,7 +1,15 @@ +import { + Command, + DescribeParametersCommand, + GetParametersCommand, +} from "@aws-sdk/client-ssm"; import { toJson } from "howdju-common"; describe("parameterStore", () => { describe("getParameterStoreConfig", () => { + afterEach(() => { + jest.clearAllMocks(); + }); it("should return deserialized parameters", async () => { // Arrange jest.mock("@aws-sdk/client-ssm", () => { @@ -11,6 +19,8 @@ describe("parameterStore", () => { return { GetParametersCommand: jest.requireActual("@aws-sdk/client-ssm") .GetParametersCommand, + DescribeParametersCommand: jest.requireActual("@aws-sdk/client-ssm") + .DescribeParametersCommand, SSMClient: jest.fn().mockImplementation(() => { return ssmClient; }), @@ -18,8 +28,9 @@ describe("parameterStore", () => { }); const environment = "test"; + const databaseConnectionInfoName = `/${environment}/database-connection-info`; const expected = { - DATABASE_CONNECTION_INFO: { + "database-connection-info": { host: "the-host", database: "the-db-name", username: "the-username", @@ -30,13 +41,28 @@ describe("parameterStore", () => { const ssmClient = new SSMClient(); // We mocked the constructor always to return the same instance, so mocking this one will // mock the one in parameterStore.ts too. - ssmClient.send.mockResolvedValue({ - Parameters: [ - { - Name: "/test/DATABASE_CONNECTION_INFO", - Value: toJson(expected.DATABASE_CONNECTION_INFO), - }, - ], + ssmClient.send.mockImplementation((command: Command) => { + if (command instanceof GetParametersCommand) { + return { + Parameters: [ + { + Name: databaseConnectionInfoName, + Value: toJson(expected["database-connection-info"]), + }, + ], + }; + } + if (command instanceof DescribeParametersCommand) { + return { + Parameters: [ + { + Name: databaseConnectionInfoName, + Type: "String", + }, + ], + }; + } + throw new Error("Unexpected command type"); }); const { getParameterStoreConfig } = @@ -48,5 +74,77 @@ describe("parameterStore", () => { // Assert expect(result).toEqual(expected); }); + + it("only requests extant parameters", async () => { + // Arrange + jest.mock("@aws-sdk/client-ssm", () => { + const ssmClient = { + send: jest.fn(), + }; + return { + GetParametersCommand: jest.requireActual("@aws-sdk/client-ssm") + .GetParametersCommand, + DescribeParametersCommand: jest.requireActual("@aws-sdk/client-ssm") + .DescribeParametersCommand, + SSMClient: jest.fn().mockImplementation(() => { + return ssmClient; + }), + }; + }); + + const environment = "test"; + const databaseConnectionInfoName = `/${environment}/database-connection-info`; + const expected = { + "database-connection-info": { + host: "the-host", + database: "the-db-name", + username: "the-username", + password: "the-password", + }, + }; + const { SSMClient } = jest.requireMock("@aws-sdk/client-ssm"); + const ssmClient = new SSMClient(); + // We mocked the constructor always to return the same instance, so mocking this one will + // mock the one in parameterStore.ts too. + ssmClient.send.mockImplementation((command: Command) => { + if (command instanceof GetParametersCommand) { + return { + Parameters: [ + { + Name: databaseConnectionInfoName, + Value: toJson(expected["database-connection-info"]), + }, + ], + }; + } + if (command instanceof DescribeParametersCommand) { + return { + Parameters: [ + { + Name: databaseConnectionInfoName, + Type: "String", + }, + ], + }; + } + throw new Error("Unexpected command type"); + }); + + const { getParameterStoreConfig } = + jest.requireActual("./parameterStore"); + + // Act + await getParameterStoreConfig(environment); + + // Assert + expect(ssmClient.send).toHaveBeenCalledTimes(2); + const getParametersCall = ssmClient.send.mock.calls.find( + ([command]: [Command]) => command instanceof GetParametersCommand + ); + const getParametersCommand = getParametersCall[0]; + expect(getParametersCommand.input).toMatchObject({ + Names: [databaseConnectionInfoName], + }); + }); }); }); diff --git a/howdju-service-common/lib/initializers/parameterStore.ts b/howdju-service-common/lib/initializers/parameterStore.ts index 8f7d1384..5d7a0edf 100644 --- a/howdju-service-common/lib/initializers/parameterStore.ts +++ b/howdju-service-common/lib/initializers/parameterStore.ts @@ -1,11 +1,13 @@ import { SSMClient, GetParametersCommand, + DescribeParametersCommand, Parameter, } from "@aws-sdk/client-ssm"; import { PoolConfig } from "pg"; import { logger } from "howdju-common"; +import { groupBy } from "lodash"; const client = new SSMClient(); @@ -30,13 +32,18 @@ export async function getParameterStoreConfig( return {} as AsyncConfig; } + const parameterNames = [ + `/${environment}/${DATABASE_CONNECTION_INFO}`, + `/${environment}/${SCRAPING_ANT_API_KEY}`, + `/${environment}/${ZEN_ROWS_API_KEY}`, + ]; + + // GetParametersCommand throws for missing parameters, so filter out the missing ones. + const extantParameterNames = await getExtantParameterNames(parameterNames); + const response = await client.send( new GetParametersCommand({ - Names: [ - `/${environment}/${DATABASE_CONNECTION_INFO}`, - `/${environment}/${SCRAPING_ANT_API_KEY}`, - `/${environment}/${ZEN_ROWS_API_KEY}`, - ], + Names: extantParameterNames, WithDecryption: true, }) ); @@ -82,6 +89,28 @@ export async function getParameterStoreConfig( return pairs.reduce((acc, cur) => ({ ...acc, ...cur }), {}) as AsyncConfig; } +async function getExtantParameterNames( + parameterNames: string[] +): Promise { + const response = await client.send( + new DescribeParametersCommand({ + ParameterFilters: [{ Key: "Name", Values: parameterNames }], + }) + ); + const foundParameterNames = new Set( + response.Parameters?.map((parameter) => parameter.Name) || [] + ); + const results = groupBy(parameterNames, (name) => + foundParameterNames.has(name) + ); + if (results.false?.length) { + logger.warn( + `Missing Parameter Store parameters: ${results.false.join(", ")}` + ); + } + return results.true || []; +} + function extractName(fullName: string) { const index = fullName.lastIndexOf("/"); if (index < 0) {