diff --git a/packages/infra/README.md b/packages/infra/README.md index 30665d0..48f8ba5 100644 --- a/packages/infra/README.md +++ b/packages/infra/README.md @@ -26,6 +26,19 @@ app.synth(); ## Usage +### Gantry service + +In a Gantry resource file: + +```diff +kind: Service + +# ... + ++ hooks: ++ beforeAllowTraffic: aws-codedeploy-hook-BeforeAllowTraffic +``` + ### Lambda function (CDK) In a CDK stack: diff --git a/packages/infra/package.json b/packages/infra/package.json index 39d4616..e2c302e 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -31,6 +31,7 @@ "aws-sdk-client-mock": "3.1.0-beta.0", "aws-sdk-client-mock-jest": "3.1.0-beta.0", "isomorphic-git": "1.25.3", + "msw": "2.0.12", "pino-pretty": "10.3.1" }, "skuba": { diff --git a/packages/infra/src/constructs/lambda.ts b/packages/infra/src/constructs/lambda.ts index b3ae68c..9dc55f8 100644 --- a/packages/infra/src/constructs/lambda.ts +++ b/packages/infra/src/constructs/lambda.ts @@ -2,7 +2,7 @@ import path from 'path'; import { Duration, aws_lambda } from 'aws-cdk-lib'; -export const LAMBDA_HOOK_PROPS: aws_lambda.FunctionProps = { +export const createLambdaHookProps = (): aws_lambda.FunctionProps => ({ code: aws_lambda.Code.fromAsset( path.join(__dirname, '..', 'assets', 'handlers'), ), @@ -19,4 +19,4 @@ export const LAMBDA_HOOK_PROPS: aws_lambda.FunctionProps = { runtime: aws_lambda.Runtime.NODEJS_20_X, timeout: Duration.seconds(300), -}; +}); diff --git a/packages/infra/src/constructs/network.test.ts b/packages/infra/src/constructs/network.test.ts new file mode 100644 index 0000000..d3fab98 --- /dev/null +++ b/packages/infra/src/constructs/network.test.ts @@ -0,0 +1,135 @@ +import { Stack } from 'aws-cdk-lib'; +import { Vpc } from 'aws-cdk-lib/aws-ec2'; + +import { getNetworkConfig } from './network'; + +describe('getNetworkConfig', () => { + const fromLookup = jest + .spyOn(Vpc, 'fromLookup') + .mockImplementation((scope, id) => new Vpc(scope, id)); + + afterEach(fromLookup.mockClear); + + it('processes the development Managed Network', () => { + const construct = new Stack(); + + expect( + getNetworkConfig(construct, { + type: 'seek-managed-network', + name: 'development', + }), + ).toMatchInlineSnapshot( + { vpc: expect.any(Object) }, + ` + { + "description": "BeforeAllowTraffic hook deployed to the development managed network", + "suffix": "-managed-network-dev", + "vpc": Any, + } + `, + ); + + expect(fromLookup).toHaveBeenCalledTimes(1); + + const [constructArg, ...restArgs] = fromLookup.mock.lastCall!; + + expect(constructArg).toBe(construct); + expect(restArgs).toMatchInlineSnapshot(` + [ + "Vpc-managed-network-dev", + { + "tags": { + "Name": "Gantry Development Managed Network", + }, + "vpcName": "Gantry Development Managed Network", + }, + ] + `); + }); + + it('processes the production Managed Network', () => { + const construct = new Stack(); + + expect( + getNetworkConfig(construct, { + type: 'seek-managed-network', + name: 'production', + }), + ).toMatchInlineSnapshot( + { vpc: expect.any(Object) }, + ` + { + "description": "BeforeAllowTraffic hook deployed to the production managed network", + "suffix": "-managed-network-prod", + "vpc": Any, + } + `, + ); + + expect(fromLookup).toHaveBeenCalledTimes(1); + + const [constructArg, ...restArgs] = fromLookup.mock.lastCall!; + + expect(constructArg).toBe(construct); + expect(restArgs).toMatchInlineSnapshot(` + [ + "Vpc-managed-network-prod", + { + "tags": { + "Name": "Gantry Production Managed Network", + }, + "vpcName": "Gantry Production Managed Network", + }, + ] + `); + }); + + it('processes a custom VPC', () => { + const construct = new Stack(); + + expect( + getNetworkConfig(construct, { + type: 'vpc', + id: 'mock-id', + label: 'mock-label', + }), + ).toMatchInlineSnapshot( + { vpc: expect.any(Object) }, + ` + { + "description": "BeforeAllowTraffic hook deployed to the mock-label VPC (mock-id)", + "suffix": "-vpc-mock-label", + "vpc": Any, + } + `, + ); + + expect(fromLookup).toHaveBeenCalledTimes(1); + + const [constructArg, ...restArgs] = fromLookup.mock.lastCall!; + + expect(constructArg).toBe(construct); + expect(restArgs).toMatchInlineSnapshot(` + [ + "Vpc-vpc-mock-label", + { + "vpcId": "mock-id", + }, + ] + `); + }); + + it('processes null', () => { + const construct = new Stack(); + + expect(getNetworkConfig(construct, null)).toMatchInlineSnapshot(` + { + "description": "BeforeAllowTraffic hook deployed outside of a VPC", + "suffix": "", + "vpc": undefined, + } + `); + + expect(fromLookup).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/infra/src/constructs/network.ts b/packages/infra/src/constructs/network.ts new file mode 100644 index 0000000..80e7255 --- /dev/null +++ b/packages/infra/src/constructs/network.ts @@ -0,0 +1,96 @@ +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import type { Construct } from 'constructs'; + +export type Network = + | { + /** + * A VPC in the Managed Network. + */ + type: 'seek-managed-network'; + + /** + * The name of the Managed Network environment. + */ + name: 'development' | 'production'; + } + | { + /** + * A custom VPC. + */ + type: 'vpc'; + + /** + * The ID of the VPC. + */ + id: string; + + /** + * A kebab-cased label for this network. + * + * This is used as a suffix for the Lambda function name and has a maximum + * length of 21 characters. + * + * ```bash + * aws-codedeploy-hook-BeforeAllowTraffic-vpc-${label} + * ``` + */ + label: string; + }; + +const SEEK_MANAGED_NETWORK_NAMES = { + development: 'Gantry Development Managed Network', + production: 'Gantry Production Managed Network', +}; + +const SEEK_MANAGED_NETWORK_SUFFIXES = { + development: 'dev', + production: 'prod', +}; + +export const getNetworkConfig = ( + scope: Construct, + network: Network | null, +): { + description: string; + suffix: string; + vpc: ec2.IVpc | undefined; +} => { + switch (network?.type) { + case 'seek-managed-network': { + const name = SEEK_MANAGED_NETWORK_NAMES[network.name]; + + const suffix = `-managed-network-${ + SEEK_MANAGED_NETWORK_SUFFIXES[network.name] + }`; + + return { + description: `BeforeAllowTraffic hook deployed to the ${network.name} managed network`, + suffix, + vpc: ec2.Vpc.fromLookup(scope, `Vpc${suffix}`, { + tags: { Name: name }, + vpcName: name, + }), + }; + } + + case 'vpc': { + const suffix = `-vpc-${network.label}`; + + return { + description: `BeforeAllowTraffic hook deployed to the ${network.label} VPC (${network.id})`, + suffix, + vpc: ec2.Vpc.fromLookup(scope, `Vpc${suffix}`, { + vpcId: network.id, + }), + }; + } + + case undefined: { + return { + description: 'BeforeAllowTraffic hook deployed outside of a VPC', + suffix: '', + vpc: undefined, + }; + } + } +}; diff --git a/packages/infra/src/constructs/stack.test.ts b/packages/infra/src/constructs/stack.test.ts index 1f408f9..0ad207f 100644 --- a/packages/infra/src/constructs/stack.test.ts +++ b/packages/infra/src/constructs/stack.test.ts @@ -1,4 +1,4 @@ -import { App, assertions } from 'aws-cdk-lib'; +import { App, assertions, aws_ec2 } from 'aws-cdk-lib'; import { HookStack } from './stack'; @@ -24,3 +24,35 @@ it('returns expected CloudFormation stack', () => { expect(JSON.parse(json)).toMatchSnapshot(); }); + +it('supports additional networks', () => { + jest + .spyOn(aws_ec2.Vpc, 'fromLookup') + .mockImplementation((scope, id) => new aws_ec2.Vpc(scope, id)); + + const app = new App(); + + const stack = new HookStack(app, undefined, { + additionalNetworks: [ + { + type: 'seek-managed-network', + name: 'development', + }, + { + type: 'vpc', + id: 'mock-id', + label: 'mock-label', + }, + ], + }); + + const template = assertions.Template.fromStack(stack); + + template.resourceCountIs('AWS::Lambda::Function', 3); + + template.resourcePropertiesCountIs( + 'AWS::Lambda::Function', + { VpcConfig: {} }, + 2, + ); +}); diff --git a/packages/infra/src/constructs/stack.ts b/packages/infra/src/constructs/stack.ts index bba56db..363eea1 100644 --- a/packages/infra/src/constructs/stack.ts +++ b/packages/infra/src/constructs/stack.ts @@ -1,68 +1,81 @@ import { Stack, aws_iam, aws_lambda } from 'aws-cdk-lib'; import type { Construct } from 'constructs'; -import { LAMBDA_HOOK_PROPS } from './lambda'; +import { createLambdaHookProps } from './lambda'; +import { type Network, getNetworkConfig } from './network'; -export type HookStackProps = Record; +export type HookStackProps = { + additionalNetworks?: Network[]; +}; export class HookStack extends Stack { - constructor(scope: Construct, id?: string, _props: HookStackProps = {}) { + constructor( + scope: Construct, + id?: string, + { additionalNetworks }: HookStackProps = {}, + ) { super(scope, id ?? 'HookStack', { description: 'AWS CodeDeploy hooks', stackName: 'aws-codedeploy-hooks', terminationProtection: true, }); - const beforeAllowTrafficHook = new aws_lambda.Function( - this, - 'BeforeAllowTrafficHook', - { - ...LAMBDA_HOOK_PROPS, - description: 'BeforeAllowTraffic hook deployed outside of a VPC', - functionName: 'aws-codedeploy-hook-BeforeAllowTraffic', - vpc: undefined, - }, - ); + for (const network of [null, ...(additionalNetworks ?? [])]) { + const { description, suffix, vpc } = getNetworkConfig(this, network); - beforeAllowTrafficHook.addToRolePolicy( - new aws_iam.PolicyStatement({ - actions: [ - 'codedeploy:GetApplicationRevision', - 'codedeploy:GetDeployment', - 'codedeploy:PutLifecycleEventHookExecutionStatus', - 'lambda:InvokeFunction', - ], - effect: aws_iam.Effect.ALLOW, - resources: ['*'], - }), - ); + const beforeAllowTrafficHook = new aws_lambda.Function( + this, + `BeforeAllowTrafficHook${suffix}`, + { + ...createLambdaHookProps(), + description, + functionName: `aws-codedeploy-hook-BeforeAllowTraffic${suffix}`, + vpc, + }, + ); + + // TODO: consider creating a shared policy that is used across the hooks. + + beforeAllowTrafficHook.addToRolePolicy( + new aws_iam.PolicyStatement({ + actions: [ + 'codedeploy:GetApplicationRevision', + 'codedeploy:GetDeployment', + 'codedeploy:PutLifecycleEventHookExecutionStatus', + 'lambda:InvokeFunction', + ], + effect: aws_iam.Effect.ALLOW, + resources: ['*'], + }), + ); - // Deny access to resources that lack an `aws-codedeploy-hooks` tag. - beforeAllowTrafficHook.addToRolePolicy( - new aws_iam.PolicyStatement({ - actions: ['*'], - conditions: { - Null: { - 'aws:ResourceTag/aws-codedeploy-hooks': 'true', + // Deny access to resources that lack an `aws-codedeploy-hooks` tag. + beforeAllowTrafficHook.addToRolePolicy( + new aws_iam.PolicyStatement({ + actions: ['*'], + conditions: { + Null: { + 'aws:ResourceTag/aws-codedeploy-hooks': 'true', + }, }, - }, - effect: aws_iam.Effect.DENY, - resources: ['*'], - }), - ); + effect: aws_iam.Effect.DENY, + resources: ['*'], + }), + ); - // Deny access to resources that have a falsy `aws-codedeploy-hooks` tag. - beforeAllowTrafficHook.addToRolePolicy( - new aws_iam.PolicyStatement({ - actions: ['*'], - conditions: { - StringEquals: { - 'aws:ResourceTag/aws-codedeploy-hooks': ['', 'false'], + // Deny access to resources that have a falsy `aws-codedeploy-hooks` tag. + beforeAllowTrafficHook.addToRolePolicy( + new aws_iam.PolicyStatement({ + actions: ['*'], + conditions: { + StringEquals: { + 'aws:ResourceTag/aws-codedeploy-hooks': ['', 'false'], + }, }, - }, - effect: aws_iam.Effect.DENY, - resources: ['*'], - }), - ); + effect: aws_iam.Effect.DENY, + resources: ['*'], + }), + ); + } } } diff --git a/packages/infra/src/handlers/framework/aws.ts b/packages/infra/src/handlers/framework/aws.ts index ed98850..03db73b 100644 --- a/packages/infra/src/handlers/framework/aws.ts +++ b/packages/infra/src/handlers/framework/aws.ts @@ -1,3 +1,4 @@ +import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; import { CodeDeployClient } from '@aws-sdk/client-codedeploy'; import { LambdaClient } from '@aws-sdk/client-lambda'; @@ -8,6 +9,8 @@ const clientConfig = { region: config.region, }; +export const cloudFormationClient = new CloudFormationClient(clientConfig); + export const codeDeployClient = new CodeDeployClient(clientConfig); export const lambdaClient = new LambdaClient(clientConfig); diff --git a/packages/infra/src/handlers/process/ecs/ecs.test.ts b/packages/infra/src/handlers/process/ecs/ecs.test.ts new file mode 100644 index 0000000..ea702f3 --- /dev/null +++ b/packages/infra/src/handlers/process/ecs/ecs.test.ts @@ -0,0 +1,43 @@ +jest.mock('./gantrySmokeTest'); + +import { ecs } from './ecs'; +import { gantrySmokeTest } from './gantrySmokeTest'; + +const gantrySmokeTestMock = jest.mocked(gantrySmokeTest); + +afterEach(() => { + gantrySmokeTestMock.mockReset(); +}); + +describe('ecs', () => { + it('executes a smoke test on Gantry application', async () => { + await expect( + ecs({ + applicationName: 'gantry-environment-env-foo', + deploymentGroupName: 'svc-bar', + }), + ).resolves.toBeUndefined(); + + expect(gantrySmokeTestMock).toHaveBeenCalledTimes(1); + + expect(gantrySmokeTestMock.mock.lastCall).toMatchInlineSnapshot(` + [ + { + "applicationName": "gantry-environment-env-foo", + "deploymentGroupName": "svc-bar", + }, + ] + `); + }); + + it('throws an error if application is not configured by Gantry', async () => { + await expect( + ecs({ + applicationName: 'non-gantry-application-name', + deploymentGroupName: 'svc-bar', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"CodeDeploy ECS application not configured by Gantry: non-gantry-application-name"`, + ); + }); +}); diff --git a/packages/infra/src/handlers/process/ecs/ecs.ts b/packages/infra/src/handlers/process/ecs/ecs.ts new file mode 100644 index 0000000..13f83b5 --- /dev/null +++ b/packages/infra/src/handlers/process/ecs/ecs.ts @@ -0,0 +1,18 @@ +import { gantrySmokeTest } from './gantrySmokeTest'; + +export type Options = { + applicationName: string; + deploymentGroupName: string; +}; + +export const ecs = async (opts: Options): Promise => { + const { applicationName } = opts; + + if (!applicationName.startsWith('gantry-environment-')) { + throw new Error( + `CodeDeploy ECS application not configured by Gantry: ${applicationName}`, + ); + } + + await gantrySmokeTest(opts); +}; diff --git a/packages/infra/src/handlers/process/ecs/gantrySmokeTest.test.ts b/packages/infra/src/handlers/process/ecs/gantrySmokeTest.test.ts new file mode 100644 index 0000000..93ce8ab --- /dev/null +++ b/packages/infra/src/handlers/process/ecs/gantrySmokeTest.test.ts @@ -0,0 +1,401 @@ +import 'aws-sdk-client-mock-jest'; + +import { + CloudFormationClient, + DescribeStacksCommand, + type DescribeStacksOutput, +} from '@aws-sdk/client-cloudformation'; +import { + GetFunctionConfigurationCommand, + LambdaClient, +} from '@aws-sdk/client-lambda'; +import { mockClient } from 'aws-sdk-client-mock'; +import { HttpResponse, delay, http } from 'msw'; +import { setupServer } from 'msw/node'; + +import * as context from '../../framework/context'; +import { storage } from '../../framework/context'; + +import { + DEFAULT_TIMEOUT_MS, + type Options, + gantrySmokeTest, +} from './gantrySmokeTest'; + +const SMOKE_TEST_URL_OUTPUT = { + OutputKey: 'SmokeTestUrl', + OutputValue: 'https://ext.example.com/smoke', +}; + +const DESCRIBE_STACKS_OUTPUT: DescribeStacksOutput = { + Stacks: [ + { + CreationTime: new Date(0), + Outputs: [ + SMOKE_TEST_URL_OUTPUT, + { + OutputKey: 'SmokeTestUseExternalDns', + OutputValue: 'true', + }, + ], + StackName: 'gantry-svc-bar-env-foo', + StackStatus: 'CREATE_COMPLETE', + }, + ], +}; + +const cloudFormationClient = mockClient(CloudFormationClient); + +const lambdaClient = mockClient(LambdaClient); + +const withTimeout = jest.spyOn(context, 'withTimeout'); + +afterEach(() => { + cloudFormationClient.reset(); + lambdaClient.reset(); + withTimeout.mockClear(); +}); + +describe('gantrySmokeTest', () => { + const albSmokeTest = jest.fn, [{ request: Request }]>(); + const extSmokeTest = jest.fn, [{ request: Request }]>(); + + const serialiseRequest = ( + mock: typeof albSmokeTest | typeof extSmokeTest, + ) => { + const request = mock.mock.lastCall![0].request; + + return { + headers: Object.fromEntries( + request.headers as unknown as Iterable<[unknown, unknown]>, + ), + url: request.url, + }; + }; + + const server = setupServer( + http.get('https://alb.example.com/smoke', albSmokeTest), + http.get('https://ext.example.com/smoke', extSmokeTest), + ); + + beforeAll(() => server.listen()); + + afterEach(() => server.resetHandlers()); + + afterAll(() => server.close()); + + const opts: Options = { + applicationName: 'gantry-environment-env-foo', + deploymentGroupName: 'svc-bar', + }; + + it('returns on happy path', async () => { + cloudFormationClient + .on(DescribeStacksCommand) + .resolves(DESCRIBE_STACKS_OUTPUT); + + extSmokeTest.mockResolvedValue(HttpResponse.text(null, { status: 200 })); + + await expect(gantrySmokeTest(opts)).resolves.toBeUndefined(); + + expect(cloudFormationClient).toHaveReceivedCommandTimes( + DescribeStacksCommand, + 1, + ); + + const [description] = cloudFormationClient.commandCalls( + DescribeStacksCommand, + ); + + expect(description!.firstArg.input).toMatchInlineSnapshot(` + { + "StackName": "gantry-svc-bar-env-foo", + } + `); + + expect(withTimeout).toHaveBeenCalledWith( + expect.any(Function), + DEFAULT_TIMEOUT_MS, + ); + + expect(extSmokeTest).toHaveBeenCalledTimes(1); + + expect(serialiseRequest(extSmokeTest)).toMatchInlineSnapshot(` + { + "headers": { + "host": "ext.example.com", + "user-agent": "aws-codedeploy-hooks/local", + }, + "url": "https://ext.example.com/smoke", + } + `); + }); + + it('embeds a request ID where available', async () => { + cloudFormationClient + .on(DescribeStacksCommand) + .resolves(DESCRIBE_STACKS_OUTPUT); + + extSmokeTest.mockResolvedValue(HttpResponse.text(null, { status: 200 })); + + await expect( + storage.run({ requestId: 'mock-request-id' }, () => + gantrySmokeTest(opts), + ), + ).resolves.toBeUndefined(); + + expect(serialiseRequest(extSmokeTest)).toMatchInlineSnapshot(` + { + "headers": { + "host": "ext.example.com", + "user-agent": "aws-codedeploy-hooks/local", + "x-request-id": "mock-request-id", + }, + "url": "https://ext.example.com/smoke", + } + `); + }); + + it('supports ALB DNS', async () => { + const output = structuredClone(DESCRIBE_STACKS_OUTPUT); + output.Stacks![0]!.Outputs = [ + SMOKE_TEST_URL_OUTPUT, + { + OutputKey: 'SmokeTestUseExternalDns', + OutputValue: 'false', + }, + ]; + + cloudFormationClient.on(DescribeStacksCommand).resolves(output); + + lambdaClient.on(GetFunctionConfigurationCommand).resolves({ + Environment: { + Variables: { + LoadBalancerDNSName: 'alb.example.com', + }, + }, + }); + + albSmokeTest.mockResolvedValue(HttpResponse.text(null, { status: 200 })); + + await expect(gantrySmokeTest(opts)).resolves.toBeUndefined(); + + expect( + lambdaClient.commandCalls(GetFunctionConfigurationCommand)[0]!.firstArg + .input, + ).toMatchInlineSnapshot(` + { + "FunctionName": "gantry-codedeploy-hook-BeforeAllowTraffic-env-foo", + } + `); + + expect(albSmokeTest).toHaveBeenCalledTimes(1); + + expect(serialiseRequest(albSmokeTest)).toMatchInlineSnapshot(` + { + "headers": { + "host": "ext.example.com", + "user-agent": "aws-codedeploy-hooks/local", + }, + "url": "https://alb.example.com/smoke", + } + `); + }); + + it('supports a custom timeout', async () => { + const timeoutS = 1; + + const output = structuredClone(DESCRIBE_STACKS_OUTPUT); + output.Stacks![0]!.Outputs!.push({ + OutputKey: 'SmokeTestTimeout', + OutputValue: String(timeoutS), + }); + + cloudFormationClient.on(DescribeStacksCommand).resolves(output); + + await expect(gantrySmokeTest(opts)).resolves.toBeUndefined(); + + expect(withTimeout).toHaveBeenCalledWith( + expect.any(Function), + timeoutS * 1_000, + ); + }); + + it('propagates an error from the CloudFormation API', async () => { + const err = new Error('mock-error'); + + cloudFormationClient.on(DescribeStacksCommand).rejects(err); + + await expect(gantrySmokeTest(opts)).rejects.toThrow(err); + }); + + it('propagates an error from the Lambda API', async () => { + const err = new Error('mock-error'); + + const output = structuredClone(DESCRIBE_STACKS_OUTPUT); + output.Stacks![0]!.Outputs = [ + SMOKE_TEST_URL_OUTPUT, + { + OutputKey: 'SmokeTestUseExternalDns', + OutputValue: 'false', + }, + ]; + + cloudFormationClient.on(DescribeStacksCommand).resolves(output); + + lambdaClient.on(GetFunctionConfigurationCommand).rejects(err); + + await expect(gantrySmokeTest(opts)).rejects.toThrow(err); + }); + + it('throws on missing CloudFormation stack', async () => { + // Indicates a `DryRun` invocation type. + // https://docs.aws.amazon.com/lambda/latest/dg/API_Invoke.html#API_Invoke_ResponseSyntax + cloudFormationClient.on(DescribeStacksCommand).resolves({ Stacks: [] }); + + await expect( + gantrySmokeTest(opts), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Stack missing for Gantry service: gantry-svc-bar-env-foo"`, + ); + }); + + it.each(['SmokeTestUrl', 'SmokeTestUseExternalDns'])( + 'throws on missing %s output', + async (outputKey) => { + const output = structuredClone(DESCRIBE_STACKS_OUTPUT); + output.Stacks![0]!.Outputs = output.Stacks![0]!.Outputs!.filter( + ({ OutputKey }) => OutputKey !== outputKey, + ); + + cloudFormationClient.on(DescribeStacksCommand).resolves(output); + + await expect(gantrySmokeTest(opts)).rejects.toThrow( + `${outputKey} output missing from Gantry service stack: gantry-svc-bar-env-foo`, + ); + }, + ); + + it('throws on error response from smoke test endpoint', async () => { + cloudFormationClient + .on(DescribeStacksCommand) + .resolves(DESCRIBE_STACKS_OUTPUT); + + extSmokeTest.mockResolvedValue(HttpResponse.error()); + + await expect( + gantrySmokeTest(opts), + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Failed to fetch"`); + }); + + it('throws on missing LoadBalancerDNSName environment variable', async () => { + const output = structuredClone(DESCRIBE_STACKS_OUTPUT); + output.Stacks![0]!.Outputs = [ + SMOKE_TEST_URL_OUTPUT, + { + OutputKey: 'SmokeTestUseExternalDns', + OutputValue: 'false', + }, + ]; + + cloudFormationClient.on(DescribeStacksCommand).resolves(output); + + lambdaClient.on(GetFunctionConfigurationCommand).resolves({ + Environment: { + Variables: {}, + }, + }); + + await expect( + gantrySmokeTest(opts), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"LoadBalancerDNSName environment variable missing in Gantry hook function: gantry-codedeploy-hook-BeforeAllowTraffic-env-foo"`, + ); + }); + + it('throws on redirection from smoke test endpoint', async () => { + cloudFormationClient + .on(DescribeStacksCommand) + .resolves(DESCRIBE_STACKS_OUTPUT); + + extSmokeTest.mockResolvedValue( + HttpResponse.redirect('https://another.example.com/smoke', 301), + ); + + await expect( + gantrySmokeTest(opts), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Smoke test endpoint responded with error status: 301"`, + ); + }); + + it('throws on timeout', async () => { + const timeoutS = 0.1; + + const output = structuredClone(DESCRIBE_STACKS_OUTPUT); + output.Stacks![0]!.Outputs!.push({ + OutputKey: 'SmokeTestTimeout', + OutputValue: String(timeoutS), + }); + + cloudFormationClient.on(DescribeStacksCommand).resolves(output); + + extSmokeTest.mockImplementation(async () => { + await delay(timeoutS * 1000 + 1); + + if (context.getAbortSignal()!.aborted) { + return HttpResponse.text('Client aborted', { status: 499 }); + } + + return HttpResponse.text(null, { status: 200 }); + }); + + await expect( + storage.run({ abortSignal: AbortSignal.timeout(100) }, () => + gantrySmokeTest(opts), + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Smoke test endpoint responded with error status: 499"`, + ); + + expect(withTimeout).toHaveBeenCalledWith( + expect.any(Function), + timeoutS * 1_000, + ); + }, 15_000); + + it('propagates parent signal abortion', async () => { + const timeoutS = 3; + + const output = structuredClone(DESCRIBE_STACKS_OUTPUT); + output.Stacks![0]!.Outputs!.push({ + OutputKey: 'SmokeTestTimeout', + OutputValue: String(timeoutS), + }); + + cloudFormationClient.on(DescribeStacksCommand).resolves(output); + + extSmokeTest.mockImplementation(async () => { + await delay(100 + 1); + + if (context.getAbortSignal()!.aborted) { + return HttpResponse.text('Client aborted', { status: 499 }); + } + + return HttpResponse.text(null, { status: 200 }); + }); + + await expect( + storage.run({ abortSignal: AbortSignal.timeout(100) }, () => + gantrySmokeTest(opts), + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Smoke test endpoint responded with error status: 499"`, + ); + + expect(withTimeout).toHaveBeenCalledWith( + expect.any(Function), + timeoutS * 1_000, + ); + }, 15_000); +}); diff --git a/packages/infra/src/handlers/process/ecs/gantrySmokeTest.ts b/packages/infra/src/handlers/process/ecs/gantrySmokeTest.ts new file mode 100644 index 0000000..0d46775 --- /dev/null +++ b/packages/infra/src/handlers/process/ecs/gantrySmokeTest.ts @@ -0,0 +1,133 @@ +import { + DescribeStacksCommand, + type Stack, +} from '@aws-sdk/client-cloudformation'; +import { GetFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; + +import { config } from '../../config'; +import { cloudFormationClient, lambdaClient } from '../../framework/aws'; +import { getContext, withTimeout } from '../../framework/context'; + +export const DEFAULT_TIMEOUT_MS = 10_000; + +export type Options = { + applicationName: string; + deploymentGroupName: string; +}; + +export const gantrySmokeTest = async ({ + applicationName, + deploymentGroupName, +}: Options) => { + const { abortSignal, requestId } = getContext(); + + // gantry-environment-env-foo -> env-foo + const environment = applicationName.replace(/^gantry-environment-/, ''); + + // svc-bar + const serviceName = deploymentGroupName; + + // gantry-svc-bar-env-foo + const stackName = `gantry-${serviceName}-${environment}`; + + const stacks = await cloudFormationClient.send( + new DescribeStacksCommand({ StackName: stackName }), + { abortSignal }, + ); + + const stack = stacks.Stacks?.[0]; + + if (!stack) { + throw new Error(`Stack missing for Gantry service: ${stackName}`); + } + + const smokeTestUrl = getOutputValue(stack, 'SmokeTestUrl'); + + if (!smokeTestUrl) { + throw new Error( + `SmokeTestUrl output missing from Gantry service stack: ${stackName}`, + ); + } + + const url = new URL(smokeTestUrl); + + const host = url.host.replace(/:.+$/, ''); + + const useExternalDns = getOutputValue(stack, 'SmokeTestUseExternalDns'); + + if (!useExternalDns) { + throw new Error( + `SmokeTestUseExternalDns output missing from Gantry service stack: ${stackName}`, + ); + } + + if (useExternalDns !== 'true') { + const functionName = `gantry-codedeploy-hook-BeforeAllowTraffic-${environment}`; + + const lambdaConfiguration = await lambdaClient.send( + new GetFunctionConfigurationCommand({ FunctionName: functionName }), + { abortSignal }, + ); + + const loadBalancerHost = + lambdaConfiguration.Environment?.Variables?.LoadBalancerDNSName; + + if (!loadBalancerHost) { + throw new Error( + `LoadBalancerDNSName environment variable missing in Gantry hook function: ${functionName}`, + ); + } + + // Reference the ALB hostname in the URL but send the configured hostname in + // the Host header. This allows the smoke test to hit the service prior to + // DNS being configured. + url.host = loadBalancerHost; + } + + const timeoutString = getOutputValue(stack, 'SmokeTestTimeout'); + + const timeoutMs = timeoutString + ? Number(timeoutString) * 1000 + : DEFAULT_TIMEOUT_MS; + + const controller = new AbortController(); + + const { signal } = controller; + + const headers = { + Host: host, + 'User-Agent': config.userAgent, + ...(requestId ? { 'X-Request-Id': requestId } : null), + }; + + // The default Gantry hook allows time for the ALB listener to be updated to + // point to the new task group. + // + // TODO: Is this still necessary, and if so, can we instead eagerly send the + // request and inspect a piece of correlating version information in the + // `Server` or `X-Api-Version` response header? + // + // https://github.com/seek-oss/koala/tree/master/src/versionMiddleware#readme + // await setTimeout(10_000); + + // TODO: implement retries? + const response = await withTimeout( + () => + fetch(url, { + headers, + method: 'GET', + redirect: 'error', + signal, + }), + timeoutMs, + ); + + if (!response.ok) { + throw new Error( + `Smoke test endpoint responded with error status: ${response.status}`, + ); + } +}; + +const getOutputValue = (stack: Stack, key: string): string | undefined => + stack.Outputs?.find((output) => output.OutputKey === key)?.OutputValue; diff --git a/packages/infra/src/handlers/process/process.test.ts b/packages/infra/src/handlers/process/process.test.ts index d8ff296..136b11c 100644 --- a/packages/infra/src/handlers/process/process.test.ts +++ b/packages/infra/src/handlers/process/process.test.ts @@ -1,3 +1,4 @@ +jest.mock('./ecs/ecs'); jest.mock('./lambda/lambda'); import { @@ -7,13 +8,13 @@ import { } from '@aws-sdk/client-codedeploy'; import { mockClient } from 'aws-sdk-client-mock'; +import { ecs } from './ecs/ecs'; import { lambda } from './lambda/lambda'; import { parseDeploymentInfo, processEvent } from './process'; const deploymentInfo: DeploymentInfo = { applicationName: 'mock-application-name', computePlatform: 'Lambda', - deploymentConfigName: 'CodeDeployDefault.LambdaAllAtOnce', deploymentGroupName: 'mock-deployment-group-name', deploymentStyle: { deploymentOption: 'WITH_TRAFFIC_CONTROL', @@ -26,13 +27,23 @@ const deploymentInfo: DeploymentInfo = { }, }; +const gantryDeploymentInfo: DeploymentInfo = { + ...deploymentInfo, + applicationName: 'gantry-environment-env-foo', + computePlatform: 'ECS', + deploymentGroupName: 'svc-bar', + revision: {}, +}; + describe('processEvent', () => { const codeDeploy = mockClient(CodeDeployClient); + const ecsMock = jest.mocked(ecs); const lambdaMock = jest.mocked(lambda); afterEach(() => { codeDeploy.reset(); + ecsMock.mockReset(); lambdaMock.mockReset(); }); @@ -43,6 +54,32 @@ describe('processEvent', () => { LifecycleEventHookExecutionId: 'mock-lifecycle-event-hook-execution-id', }; + it('propagates a supported ECS deployment to the relevant handler', async () => { + codeDeploy + .on(GetDeploymentCommand, { + deploymentId: event.DeploymentId, + }) + .resolves({ + deploymentInfo: gantryDeploymentInfo, + }); + + ecsMock.mockResolvedValue(undefined); + + await expect(processEvent(event)).resolves.toBeUndefined(); + + expect(ecsMock).toHaveBeenCalledTimes(1); + + expect(ecsMock.mock.lastCall).toMatchInlineSnapshot(` + [ + { + "applicationName": "gantry-environment-env-foo", + "deploymentGroupName": "svc-bar", + "revision": {}, + }, + ] + `); + }); + it('propagates a supported Lambda deployment to the relevant handler', async () => { codeDeploy .on(GetDeploymentCommand, { @@ -60,6 +97,7 @@ describe('processEvent', () => { [ { "applicationName": "mock-application-name", + "deploymentGroupName": "mock-deployment-group-name", "revision": { "appSpecContent": { "sha256": "mock-sha256", @@ -84,10 +122,22 @@ describe('processEvent', () => { }); describe('parseDeploymentInfo', () => { - it('parses a supported deployment', () => + it('parses a supported ECS deployment', () => + expect(parseDeploymentInfo(gantryDeploymentInfo)).toMatchInlineSnapshot(` + { + "applicationName": "gantry-environment-env-foo", + "computePlatform": "ECS", + "deploymentGroupName": "svc-bar", + "revision": {}, + } + `)); + + it('parses a supported Lambda deployment', () => expect(parseDeploymentInfo(deploymentInfo)).toMatchInlineSnapshot(` { "applicationName": "mock-application-name", + "computePlatform": "Lambda", + "deploymentGroupName": "mock-deployment-group-name", "revision": { "appSpecContent": { "sha256": "mock-sha256", diff --git a/packages/infra/src/handlers/process/process.ts b/packages/infra/src/handlers/process/process.ts index 96c34aa..fd48f5d 100644 --- a/packages/infra/src/handlers/process/process.ts +++ b/packages/infra/src/handlers/process/process.ts @@ -7,6 +7,7 @@ import { codeDeployClient } from '../framework/aws'; import { getAbortSignal } from '../framework/context'; import type { CodeDeployLifecycleHookEvent } from '../types'; +import { ecs } from './ecs/ecs'; import { lambda } from './lambda/lambda'; export const processEvent = async ( @@ -17,9 +18,17 @@ export const processEvent = async ( { abortSignal: getAbortSignal() }, ); - const opts = parseDeploymentInfo(deploymentInfo ?? {}); + const { computePlatform, ...opts } = parseDeploymentInfo( + deploymentInfo ?? {}, + ); + + switch (computePlatform) { + case 'ECS': + return ecs(opts); - return lambda(opts); + case 'Lambda': + return lambda(opts); + } }; export const parseDeploymentInfo = ({ @@ -63,11 +72,11 @@ export const parseDeploymentInfo = ({ ); } - if (computePlatform !== 'Lambda') { + if (computePlatform !== 'ECS' && computePlatform !== 'Lambda') { throw new Error( `The following compute platform is not supported: ${computePlatform}`, ); } - return { applicationName, revision }; + return { applicationName, computePlatform, deploymentGroupName, revision }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9169e46..1866d40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: isomorphic-git: specifier: 1.25.3 version: 1.25.3 + msw: + specifier: 2.0.12 + version: 2.0.12(typescript@5.2.2) pino-pretty: specifier: 10.3.1 version: 10.3.1 @@ -1073,6 +1076,24 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true + /@bundled-es-modules/cookie@2.0.0: + resolution: {integrity: sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==} + dependencies: + cookie: 0.5.0 + dev: true + + /@bundled-es-modules/js-levenshtein@2.0.1: + resolution: {integrity: sha512-DERMS3yfbAljKsQc0U2wcqGKUWpdFjwqWuoMugEJlqBnKO180/n+4SR/J8MRDt1AN48X1ovgoD9KrdVXcaa3Rg==} + dependencies: + js-levenshtein: 1.1.6 + dev: true + + /@bundled-es-modules/statuses@1.0.1: + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + dependencies: + statuses: 2.0.1 + dev: true + /@changesets/apply-release-plan@7.0.0: resolution: {integrity: sha512-vfi69JR416qC9hWmFGSxj7N6wA5J222XNBmezSVATPWDVPIF7gkd4d8CpbEbXmRWbVrkoli3oerGS6dcL/BGsQ==} dependencies: @@ -2099,6 +2120,23 @@ packages: resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} dev: true + /@mswjs/cookies@1.1.0: + resolution: {integrity: sha512-0ZcCVQxifZmhwNBoQIrystCb+2sWBY2Zw8lpfJBPCHGCA/HWqehITeCRVIv4VMy8MPlaHo2w2pTHFV2pFfqKPw==} + engines: {node: '>=18'} + dev: true + + /@mswjs/interceptors@0.25.13: + resolution: {integrity: sha512-xfjR81WwXPHwhDbqJRHlxYmboJuiSaIKpP4I5TJVFl/EmByOU13jOBT9hmEnxcjR3jvFYoqoNKt7MM9uqerj9A==} + engines: {node: '>=18'} + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.2 + strict-event-emitter: 0.5.1 + dev: true + /@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1: resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} dependencies: @@ -2279,6 +2317,21 @@ packages: '@octokit/openapi-types': 19.1.0 dev: true + /@open-draft/deferred-promise@2.2.0: + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + dev: true + + /@open-draft/logger@0.3.0: + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.2 + dev: true + + /@open-draft/until@2.1.0: + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + dev: true + /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2933,6 +2986,10 @@ packages: '@babel/types': 7.23.6 dev: true + /@types/cookie@0.4.1: + resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + dev: true + /@types/eslint@8.56.0: resolution: {integrity: sha512-FlsN0p4FhuYRjIxpbdXovvHQhtlG05O1GG/RNWvdAxTboR438IOTwmrY/vLA+Xfgg06BTkP045M3vpFwTMv1dg==} dependencies: @@ -2973,6 +3030,10 @@ packages: pretty-format: 29.7.0 dev: true + /@types/js-levenshtein@1.1.3: + resolution: {integrity: sha512-jd+Q+sD20Qfu9e2aEXogiO3vpOC1PYJOUdyN9gvs4Qrvkg4wF43L5OhqrPeokdv8TL0/mXoYfpkcoGZMNN2pkQ==} + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -3017,6 +3078,10 @@ packages: resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} dev: true + /@types/statuses@2.0.4: + resolution: {integrity: sha512-eqNDvZsCNY49OAXB0Firg/Sc2BgoWsntsLUdybGFOhAfCD6QJ2n9HXUIHGqt5qjrxmMv4wS8WLAw43ZkKcJ8Pw==} + dev: true + /@types/strip-bom@3.0.0: resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==} dev: true @@ -3640,6 +3705,14 @@ packages: engines: {node: '>=8'} dev: true + /bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: true + /bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} dev: true @@ -3709,6 +3782,13 @@ packages: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true + /buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + /buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} dependencies: @@ -3878,6 +3958,18 @@ packages: escape-string-regexp: 5.0.0 dev: true + /cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + dependencies: + restore-cursor: 3.1.0 + dev: true + + /cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + dev: true + /cli-table3@0.6.3: resolution: {integrity: sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==} engines: {node: 10.* || >= 12.*} @@ -3887,6 +3979,11 @@ packages: '@colors/colors': 1.5.0 dev: true + /cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + dev: true + /cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} dependencies: @@ -4035,6 +4132,11 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + dev: true + /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} dev: true @@ -5130,6 +5232,13 @@ packages: escape-string-regexp: 1.0.5 dev: true + /figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + dependencies: + escape-string-regexp: 1.0.5 + dev: true + /figures@5.0.0: resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==} engines: {node: '>=14'} @@ -5595,6 +5704,10 @@ packages: function-bind: 1.1.2 dev: true + /headers-polyfill@4.0.2: + resolution: {integrity: sha512-EWGTfnTqAO2L/j5HZgoM/3z82L7necsJ0pO9Tp0X1wil3PDLrkypTBRgVO2ExehEEvUycejZD3FuRaXpZZc3kw==} + dev: true + /help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} dev: true @@ -5750,6 +5863,27 @@ packages: engines: {node: '>=10'} dev: true + /inquirer@8.2.6: + resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} + engines: {node: '>=12.0.0'} + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.1 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + dev: true + /internal-slot@1.0.6: resolution: {integrity: sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==} engines: {node: '>= 0.4'} @@ -5892,6 +6026,11 @@ packages: is-path-inside: 3.0.3 dev: true + /is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + dev: true + /is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} dev: true @@ -5905,6 +6044,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + dev: true + /is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} @@ -6000,6 +6143,11 @@ packages: which-typed-array: 1.1.13 dev: true + /is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + dev: true + /is-unicode-supported@1.3.0: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} engines: {node: '>=12'} @@ -6609,6 +6757,11 @@ packages: engines: {node: '>=10'} dev: true + /js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + dev: true + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: true @@ -6862,6 +7015,14 @@ packages: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: true + /log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + dev: true + /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -7177,6 +7338,45 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true + /msw@2.0.12(typescript@5.2.2): + resolution: {integrity: sha512-TN9HuRDRf8dA2tmIhc7xpX45A37zBMcjBBem490lBK+zz/eyveGoQZQTARAIiEHld6rz9bpzl1GSuxmy1mG87A==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + peerDependencies: + typescript: '>= 4.7.x <= 5.3.x' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@bundled-es-modules/cookie': 2.0.0 + '@bundled-es-modules/js-levenshtein': 2.0.1 + '@bundled-es-modules/statuses': 1.0.1 + '@mswjs/cookies': 1.1.0 + '@mswjs/interceptors': 0.25.13 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.4.1 + '@types/js-levenshtein': 1.1.3 + '@types/statuses': 2.0.4 + chalk: 4.1.2 + chokidar: 3.5.3 + graphql: 16.8.1 + headers-polyfill: 4.0.2 + inquirer: 8.2.6 + is-node-process: 1.2.0 + js-levenshtein: 1.1.6 + outvariant: 1.4.2 + path-to-regexp: 6.2.1 + strict-event-emitter: 0.5.1 + type-fest: 2.19.0 + typescript: 5.2.2 + yargs: 17.7.2 + dev: true + + /mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + dev: true + /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -7516,6 +7716,21 @@ packages: type-check: 0.4.0 dev: true + /ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + dev: true + /os-tmpdir@1.0.2: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} @@ -7525,6 +7740,10 @@ packages: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} dev: true + /outvariant@1.4.2: + resolution: {integrity: sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==} + dev: true + /p-each-series@3.0.0: resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==} engines: {node: '>=12'} @@ -7727,6 +7946,10 @@ packages: isarray: 0.0.1 dev: true + /path-to-regexp@6.2.1: + resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} + dev: true + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -8165,6 +8388,14 @@ packages: supports-preserve-symlinks-flag: 1.0.0 dev: true + /restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: true + /retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -8196,6 +8427,11 @@ packages: execa: 5.1.1 dev: true + /run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -8680,6 +8916,11 @@ packages: escape-string-regexp: 2.0.0 dev: true + /statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + dev: true + /stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} dependencies: @@ -8693,6 +8934,10 @@ packages: mixme: 0.5.10 dev: true + /strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + dev: true + /string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} diff --git a/tsconfig.json b/tsconfig.json index 2b42072..73f07e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,12 @@ { "compilerOptions": { "composite": true, - "lib": ["ES2023"], + "lib": [ + // https://github.com/mswjs/msw/issues/1836#issuecomment-1839364365 + "DOM", + + "ES2023" + ], "outDir": "lib", "target": "ES2022" },