From a52cb9fabac8e5a2443735e5dc3f1fcaffa3e3d6 Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 31 Jul 2024 10:35:05 +0100 Subject: [PATCH] Update facia-responder to notify SNS queue on publish succeed/fail --- lambda/facia-responder/src/config.ts | 4 + .../src/facia-notifications.ts | 47 +++ lambda/facia-responder/src/fixtures/sns.ts | 48 ++++ lambda/facia-responder/src/main.test.ts | 271 ++++++++++-------- lambda/facia-responder/src/main.ts | 87 ++++-- 5 files changed, 305 insertions(+), 152 deletions(-) create mode 100644 lambda/facia-responder/src/config.ts create mode 100644 lambda/facia-responder/src/facia-notifications.ts create mode 100644 lambda/facia-responder/src/fixtures/sns.ts diff --git a/lambda/facia-responder/src/config.ts b/lambda/facia-responder/src/config.ts new file mode 100644 index 00000000..06f21594 --- /dev/null +++ b/lambda/facia-responder/src/config.ts @@ -0,0 +1,4 @@ +import { mandatoryParameter } from 'lib/recipes-data/src/lib/config'; + +export const faciaPublicationStatusTopicArn = mandatoryParameter("FACIA_PUBLISH_STATUS_TOPIC_ARN"); +export const faciaPublicationStatusRoleArn = mandatoryParameter("FACIA_PUBLISH_STATUS_ROLE_ARN"); diff --git a/lambda/facia-responder/src/facia-notifications.ts b/lambda/facia-responder/src/facia-notifications.ts new file mode 100644 index 00000000..ab222f8b --- /dev/null +++ b/lambda/facia-responder/src/facia-notifications.ts @@ -0,0 +1,47 @@ +import { SNS } from '@aws-sdk/client-sns'; +import { TemporaryCredentials } from 'aws-sdk'; +import { faciaPublicationStatusRoleArn, faciaPublicationStatusTopicArn } from './config'; + +// The publication status event we send over SNS. +type PublicationStatusEventEnvelope = { + event: PublicationStatusEvent; +}; + +export type PublicationStatusEvent = { + edition: string; + issueDate: string; + version: string; + status: + | 'Started' + | 'Proofing' + | 'Proofed' + | 'Publishing' + | 'Published' + | 'Failed' + | 'PostProcessing'; + message: string; + timestamp: number; +}; + +export async function notifyFaciaTool( + event: PublicationStatusEvent, +): Promise { + const payload = JSON.stringify({ event } as PublicationStatusEventEnvelope); + + console.log(`Publishing publish event to SNS: ${payload}`); + + const sns = new SNS({ + region: 'eu-west-1', + credentials: new TemporaryCredentials({ + RoleArn: faciaPublicationStatusRoleArn, + RoleSessionName: 'front-assume-role-access-for-sns', + }), + }); + + const sendStatus = await sns.publish({ + TopicArn: faciaPublicationStatusTopicArn, + Message: payload, + }); + + console.log(`SNS status publish response: ${JSON.stringify(sendStatus)}`); +} diff --git a/lambda/facia-responder/src/fixtures/sns.ts b/lambda/facia-responder/src/fixtures/sns.ts new file mode 100644 index 00000000..326af2c5 --- /dev/null +++ b/lambda/facia-responder/src/fixtures/sns.ts @@ -0,0 +1,48 @@ +export const validMessageContent = { + id: 'D9AEEA41-F8DB-4FC8-A0DA-275571EA7331', + edition: 'feast-northern-hemisphere', + version: 'v1', + issueDate: '2024-01-02', + fronts: { + 'all-recipes': [ + { + id: 'd353e2de-1a65-45de-85ca-d229bc1fafad', + title: 'Dish of the day', + body: '', + items: [ + { + recipe: { + id: '14129325', + }, + }, + ], + }, + ], + 'meat-free': [ + { + id: 'fa6ccb35-926b-4eff-b3a9-5d0ca88387ff', + title: 'Dish of the day', + body: '', + items: [ + { + recipe: { + id: '14132263', + }, + }, + ], + }, + ], + }, +}; + +export const validMessage = { + Message: JSON.stringify(validMessageContent), +}; + +export const brokenMessage = { + ...validMessage, + Message: JSON.stringify({ + ...validMessageContent, + issueDate: 'dfsdfsjk', + }), +}; diff --git a/lambda/facia-responder/src/main.test.ts b/lambda/facia-responder/src/main.test.ts index b0986b46..e8cd15ee 100644 --- a/lambda/facia-responder/src/main.test.ts +++ b/lambda/facia-responder/src/main.test.ts @@ -1,131 +1,150 @@ -import {deployCurationData} from "@recipes-api/lib/recipes-data"; -import {ZodError} from "zod"; -import {handler} from "./main"; +import { deployCurationData } from '@recipes-api/lib/recipes-data'; +import { ZodError } from 'zod'; +import { notifyFaciaTool } from './facia-notifications'; +import { + brokenMessage, + validMessage, + validMessageContent, +} from './fixtures/sns'; +import { handler } from './main'; -jest.mock("@recipes-api/lib/recipes-data", ()=>({ - deployCurationData: jest.fn() +jest.mock('@recipes-api/lib/recipes-data', () => ({ + deployCurationData: jest.fn(), })); -const message = { - id: "D9AEEA41-F8DB-4FC8-A0DA-275571EA7331", - edition: "feast-northern-hemisphere", - version: "v1", - issueDate: "2024-01-02", - fronts: { - "all-recipes": [ - { - "id": "d353e2de-1a65-45de-85ca-d229bc1fafad", - "title": "Dish of the day", - "body": "", - "items": [ - { - "recipe": { - "id": "14129325" - } - } - ] - } - ], - "meat-free": [ - { - "id": "fa6ccb35-926b-4eff-b3a9-5d0ca88387ff", - "title": "Dish of the day", - "body": "", - "items": [ - { - "recipe": { - "id": "14132263" - } - } - ] - } - ] - } -}; - -const rawContent = { - Message: JSON.stringify(message) -} - -describe("main", ()=>{ - it("should publish the content it was given", async ()=>{ - const rec = { - Records: [ - { - eventSource: "sqs", - awsRegion: "xx-north-n", - messageId: "BDB66A64-F095-4F4D-9B6A-135173E262A5", - body: JSON.stringify(rawContent) - } - ] - }; - - // @ts-ignore - await handler(rec, null, null); - - const importCurationDataMock = deployCurationData as jest.Mock; - expect(importCurationDataMock.mock.calls.length).toEqual(2); - expect(importCurationDataMock.mock.calls[0][0]).toEqual(JSON.stringify(message.fronts["all-recipes"])); - expect(importCurationDataMock.mock.calls[0][1]).toEqual(message.edition); - expect(importCurationDataMock.mock.calls[0][2]).toEqual("all-recipes"); - expect(importCurationDataMock.mock.calls[0][3]).toEqual(new Date(2024, 0, 2)); - - expect(importCurationDataMock.mock.calls[1][0]).toEqual(JSON.stringify(message.fronts["meat-free"])); - expect(importCurationDataMock.mock.calls[1][1]).toEqual(message.edition); - expect(importCurationDataMock.mock.calls[1][2]).toEqual("meat-free"); - expect(importCurationDataMock.mock.calls[1][3]).toEqual(new Date(2024, 0, 2)); - }); - - - it("should not accept valid json that does not match schema", async ()=>{ - const brokenContent = { - ...rawContent, - Message: JSON.stringify({ - ...message, - issueDate: 'dfsdfsjk', - }), +jest.mock('./config', () => ({ + faciaPublicationStatusTopicArn: 'config-param', + faciaPublicationStatusRoleArn: 'config-param', +})); + +jest.mock('./facia-notifications', () => ({ + notifyFaciaTool: jest.fn(), +})); + +const importCurationDataMock = deployCurationData as jest.Mock; +const notifyFaciaToolMock = notifyFaciaTool as jest.Mock; + +describe('main', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should publish the content it was given', async () => { + const rec = { + Records: [ + { + eventSource: 'sqs', + awsRegion: 'xx-north-n', + messageId: 'BDB66A64-F095-4F4D-9B6A-135173E262A5', + body: JSON.stringify(validMessage), + }, + ], }; - const rec = { - Records: [ - { - eventSource: "sqs", - awsRegion: "xx-north-n", - messageId: "BDB66A64-F095-4F4D-9B6A-135173E262A5", - body: JSON.stringify(brokenContent) - } - ] - }; - - const expectedError = new ZodError([ - { - "code": "custom", - "fatal": true, - "path": [ - "issueDate" - ], - "message": "Invalid input" - } - ]) - - // @ts-ignore - await expect(()=>handler(rec, null, null)).rejects.toEqual(expectedError); - }); - - it("should not accept invalid json", async ()=>{ - const rec = { - Records: [ - { - eventSource: "sqs", - awsRegion: "xx-north-n", - messageId: "BDB66A64-F095-4F4D-9B6A-135173E262A5", - body: "blahblahblahthisisnotjson" - } - ] - }; - - const expectedError = new SyntaxError("Unexpected token b in JSON at position 0"); - // @ts-ignore - await expect(()=>handler(rec, null, null)).rejects.toEqual(expectedError); - }) -}) + // @ts-ignore + await handler(rec, null, null); + + expect(importCurationDataMock.mock.calls.length).toEqual(2); + expect(importCurationDataMock.mock.calls[0][0]).toEqual( + JSON.stringify(validMessageContent.fronts['all-recipes']), + ); + expect(importCurationDataMock.mock.calls[0][1]).toEqual( + validMessageContent.edition, + ); + expect(importCurationDataMock.mock.calls[0][2]).toEqual('all-recipes'); + expect(importCurationDataMock.mock.calls[0][3]).toEqual( + new Date(2024, 0, 2), + ); + + expect(importCurationDataMock.mock.calls[1][0]).toEqual( + JSON.stringify(validMessageContent.fronts['meat-free']), + ); + expect(importCurationDataMock.mock.calls[1][1]).toEqual( + validMessageContent.edition, + ); + expect(importCurationDataMock.mock.calls[1][2]).toEqual('meat-free'); + expect(importCurationDataMock.mock.calls[1][3]).toEqual( + new Date(2024, 0, 2), + ); + + const notifyFaciaToolMock = notifyFaciaTool as jest.Mock; + expect(notifyFaciaToolMock.mock.calls[0][0]).toMatchObject({ + edition: 'feast-northern-hemisphere', + issueDate: '2024-01-02', + message: 'This issue has been published', + status: 'Published', + version: 'v1', + }); + }); + + it('should not accept valid json that does not match schema', async () => { + const rec = { + Records: [ + { + eventSource: 'sqs', + awsRegion: 'xx-north-n', + messageId: 'BDB66A64-F095-4F4D-9B6A-135173E262A5', + body: JSON.stringify(brokenMessage), + }, + ], + }; + + const expectedError = new ZodError([ + { + code: 'custom', + fatal: true, + path: ['issueDate'], + message: 'Invalid input', + }, + ]); + + // @ts-ignore + await expect(() => handler(rec, null, null)).rejects.toEqual(expectedError); + }); + + it('should not accept invalid json', async () => { + const rec = { + Records: [ + { + eventSource: 'sqs', + awsRegion: 'xx-north-n', + messageId: 'BDB66A64-F095-4F4D-9B6A-135173E262A5', + body: 'blahblahblahthisisnotjson', + }, + ], + }; + + const expectedError = new SyntaxError( + 'Unexpected token b in JSON at position 0', + ); + // @ts-ignore + await expect(() => handler(rec, null, null)).rejects.toEqual(expectedError); + }); + + it('should notify when the deploy fails', async () => { + const rec = { + Records: [ + { + eventSource: 'sqs', + awsRegion: 'xx-north-n', + messageId: 'BDB66A64-F095-4F4D-9B6A-135173E262A5', + body: JSON.stringify(validMessage), + }, + ], + }; + + const expectedError = new Error('Error deploying content'); + importCurationDataMock.mockRejectedValueOnce(expectedError); + + // @ts-ignore + await handler(rec, null, null); + + expect(notifyFaciaToolMock.mock.calls[0][0]).toMatchObject({ + edition: 'feast-northern-hemisphere', + issueDate: '2024-01-02', + message: 'Failed to publish this issue. Error: Error deploying content', + status: 'Failed', + version: 'v1', + }); + }); +}); diff --git a/lambda/facia-responder/src/main.ts b/lambda/facia-responder/src/main.ts index b5674a90..ae45c8cd 100644 --- a/lambda/facia-responder/src/main.ts +++ b/lambda/facia-responder/src/main.ts @@ -1,32 +1,67 @@ -import * as facia from "@recipes-api/lib/facia"; -import {deployCurationData } from "@recipes-api/lib/recipes-data"; -import type {SNSMessage, SQSHandler, SQSRecord} from "aws-lambda"; -import format from "date-fns/format"; +import * as facia from '@recipes-api/lib/facia'; +import { deployCurationData } from '@recipes-api/lib/recipes-data'; +import type { SNSMessage, SQSHandler, SQSRecord } from 'aws-lambda'; +import format from 'date-fns/format'; +import { notifyFaciaTool } from './facia-notifications'; -function parseMesssage(from:SQSRecord):facia.FeastCuration -{ - const parsedSNSMessage = JSON.parse(from.body) as SNSMessage; // will throw if the content is not valid; - const parsedBody = JSON.parse(parsedSNSMessage.Message) as unknown; - return facia.FeastCuration.parse(parsedBody); +function parseMesssage(from: SQSRecord): facia.FeastCuration { + const parsedSNSMessage = JSON.parse(from.body) as SNSMessage; // will throw if the content is not valid; + const parsedBody = JSON.parse(parsedSNSMessage.Message) as unknown; + return facia.FeastCuration.parse(parsedBody); } -async function deployCuration(curation:facia.FeastCuration) -{ - const issueDate = new Date(curation.issueDate); - for(const frontName of Object.keys(curation.fronts)) { - console.log(`Deploying new front for ${frontName} in ${curation.edition as string} on ${format(issueDate, "yyyy-MM-dd")}`); - const serializedFront = JSON.stringify(curation.fronts[frontName]); - await deployCurationData(serializedFront, curation.edition, frontName, issueDate); - } +async function deployCuration(curation: facia.FeastCuration) { + const issueDate = new Date(curation.issueDate); + for (const frontName of Object.keys(curation.fronts)) { + console.log( + `Deploying new front for ${frontName} in ${ + curation.edition as string + } on ${format(issueDate, 'yyyy-MM-dd')}`, + ); + const serializedFront = JSON.stringify(curation.fronts[frontName]); + await deployCurationData( + serializedFront, + curation.edition, + frontName, + issueDate, + ); + } } -export const handler: SQSHandler = async (event)=> { - for(const rec of event.Records) { - console.log(`Received message with ID ${rec.messageId}, payload ${rec.body}`); +export const handler: SQSHandler = async (event) => { + for (const rec of event.Records) { + console.log( + `Received message with ID ${rec.messageId}, payload ${rec.body}`, + ); - //If something fails here, let it crash. The message will get retried and then sent to DLQ - // by the Lambda runtime and we will continue running - const newCuration = parseMesssage(rec); - await deployCuration(newCuration); - } -} + //If something fails here, let it crash. The message will get retried and then sent to DLQ + // by the Lambda runtime and we will continue running + const newCuration = parseMesssage(rec); + + try { + await deployCuration(newCuration); + } catch (e) { + void notifyFaciaTool({ + edition: newCuration.edition, + issueDate: newCuration.issueDate, + version: newCuration.version, + status: 'Failed', + message: `Failed to publish this issue. Error: ${getErrorMessage(e)}`, + timestamp: Date.now(), + }); + } + + void notifyFaciaTool({ + edition: newCuration.edition, + issueDate: newCuration.issueDate, + version: newCuration.version, + status: 'Published', + message: 'This issue has been published', + timestamp: Date.now(), + }); + } +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- this function must handle an any type +const getErrorMessage = (e: any) => + e instanceof Error ? e.message : String(e);