diff --git a/README.md b/README.md index 8761f59..88bd647 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ![Build](https://github.com/NHSDigital/eps-cdk-utils/workflows/release/badge.svg?branch=main) -This repository contains a docker image used to deploy CDK to our environments and a CDK constructs library for common EPS project patterns. +This repository contains a docker image used to deploy CDK to our environments and a CDK constructs library for common EPS project patterns, plus shared deployment utilities. - `docker/` Contains Dockerfile used to build image used fo for CDK deployments -- `packages/cdkConstructs/` Contains common CDK constructs used in EPS projects -- `packages/depoymentUtils/` Contains shared code for standardizing OAS files and performing proxygen deployments +- `packages/cdkConstructs/` Contains common CDK constructs and CDK helpers used in EPS projects +- `packages/deploymentUtils/` Contains shared code for standardising OpenAPI specifications and performing Proxygen-based deployments - `scripts/` Utilities helpful to developers of this specification - `.github/` Contains GitHub workflows that are used for building and deploying from pull requests and releases @@ -17,13 +17,89 @@ The release workflow does the following - creates a new version of the cdk construct library and publishes it to github - pushes the cdk-utils docker image to dev and all other environments (subject to manual release approval in github actions) -## CDK Constructs +## CDK Constructs (`packages/cdkConstructs`) -This contains common CDK constructs used in EPS projects. +This contains common CDK constructs and helpers used in EPS projects. -Available constructs are: +Available constructs and helpers include: -- `TypescriptLambdaFunction` - A reusable construct for TypeScript Lambda functions +- `TypescriptLambdaFunction` – A reusable construct for TypeScript Lambda functions +- `createApp` – Helper for creating a CDK `App` pre-configured with standard EPS tags and stack props +- `deleteUnusedStacks` – Helper functions for cleaning up superseded or PR-based CloudFormation stacks and their Route 53 records + +### CDK app bootstrap (`createApp`) + +The helper in [packages/cdkConstructs/src/apps/createApp.ts](packages/cdkConstructs/src/apps/createApp.ts) creates a CDK `App` and applies the standard NHS EPS tagging and configuration. + +Usage example: + +```ts +import {createApp} from "@NHSDigital/eps-cdk-constructs" + +const {app, props} = createApp({ + productName: "Electronic Prescription Service", + appName: "eps-api", + repoName: "eps-cdk-utils", + driftDetectionGroup: "eps-api" +}) + +// Use `app` and `props` when defining stacks +``` + +`createApp` reads deployment metadata from environment variables such as `versionNumber`, `commitId`, `environment` and `isPullRequest`, and exposes them via the returned `props` for use when defining stacks. + +### Stack cleanup helpers (`deleteUnusedStacks`) + +The helpers in [packages/cdkConstructs/src/stacks/deleteUnusedStacks.ts](packages/cdkConstructs/src/stacks/deleteUnusedStacks.ts) are used to clean up old CloudFormation stacks and their DNS records: + +- `deleteUnusedMainStacks(baseStackName, getActiveVersions, hostedZoneName?)` – deletes superseded main and sandbox stacks once the active version has been deployed for at least 24 hours, and removes matching CNAME records from Route 53. +- `deleteUnusedPrStacks(baseStackName, repoName, hostedZoneName?)` – deletes stacks created for pull requests whose GitHub PRs have been closed, and cleans up their CNAME records. + +These functions are designed to be invoked from scheduled jobs (for example, a nightly cleanup workflow) after deployment. They rely on: + +- APIM status endpoints to determine the active API versions (via `getActiveApiVersions`). +- GitHub’s API to determine whether PRs are closed. +- Route 53 APIs to enumerate and delete CNAME records associated with the stacks. + +Refer to [packages/cdkConstructs/tests/stacks/deleteUnusedStacks.test.ts](packages/cdkConstructs/tests/stacks/deleteUnusedStacks.test.ts) for example scenarios. + +## Deployment utilities (`packages/deploymentUtils`) + +The [packages/deploymentUtils](packages/deploymentUtils) package contains utilities for working with OpenAPI specifications and Proxygen-based API deployments. + +It exposes the following main entry points via [packages/deploymentUtils/src/index.ts](packages/deploymentUtils/src/index.ts): + +- `deployApi` – Normalises an OpenAPI specification and deploys it via Proxygen Lambda functions, optionally performing blue/green deployments and publishing documentation to the appropriate catalogue. +- `writeSchemas` – Writes JSON Schemas to disk, collapsing `examples` arrays into a single `example` value to be compatible with OAS. +- `deleteProxygenDeployments` – Removes Proxygen PTL instances that correspond to closed GitHub pull requests for a given API. +- Config helpers from `config/index` – used to resolve configuration and CloudFormation export values. + +Typical usage pattern (pseudo-code): + +```ts +import {deployApi} from "@NHSDigital/eps-deployment-utils" + +await deployApi({ + spec, + apiName: "eps-api", + version: "v1.2.3", + apigeeEnvironment: "int", + isPullRequest: false, + awsEnvironment: "dev", + stackName: "eps-api-v1-2-3", + mtlsSecretName: "eps-api-mtls", + clientCertExportName: "ClientCertArn", + clientPrivateKeyExportName: "ClientPrivateKeyArn", + proxygenPrivateKeyExportName: "ProxygenPrivateKeyArn", + proxygenKid: "kid-123", + hiddenPaths: ["/internal-only"] +}, +true, // blueGreen +false // dryRun +) +``` + +See the source files under [packages/deploymentUtils/src/specifications](packages/deploymentUtils/src/specifications) and their tests in [packages/deploymentUtils/tests](packages/deploymentUtils/tests) for fuller examples and expected behaviours. ## Contributing @@ -49,25 +125,35 @@ See [https://code.visualstudio.com/docs/devcontainers/containers](https://code.v All commits must be made using [signed commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). -### Testing changes to construct library -To test changes to the construct library, you need to package the library and install it on the project you want to test it on. +### Testing changes to construct or deploymentUtils libraries +To test changes to the construct library or the deploymentUtils package, you need to package the relevant library and install it into the project you want to test it in. Either - - run `make package` from this project and copy the .tgz file from lib folder to the project you want to test in - - create a pull request and from the pull request workflow run, download nhsdigital-eps-cdk-constructs-1.0.0.tgz to the project you want to test in + - run `make package` from this project and copy the generated `.tgz` file(s) from the lib folder to the project you want to test in + - create a pull request and from the pull request workflow run, download the generated `.tgz` artifact(s) (for example `nhsdigital-eps-cdk-constructs-1.0.0.tgz` and/or `nhsdigital-eps-deployment-utils-1.0.0.tgz`) to the project you want to test in - In the project you want to test in, run the following +In the project you want to test in, run the following as appropriate: ```bash -npm install --save NHSDigital-eps-cdk-constructs-1.0.0.tgz --workspace packages/cdk/ +# Install the CDK constructs library +npm install --save nhsdigital-eps-cdk-constructs-1.0.0.tgz --workspace packages/cdk/ + +# Install the deploymentUtils library +npm install --save nhsdigital-eps-deployment-utils-1.0.0.tgz --workspace packages/specifications/ ``` -You will then be able to use it - for example: +You will then be able to use them - for example: ```typescript import {TypescriptLambdaFunction} from "@NHSDigital/eps-cdk-constructs" ``` +or + +```typescript +import {deployApi} from "@nhsdigital/eps-deployment-utils" +``` + ### Make Commands There are `make` commands that are run as part of the CI pipeline and help alias some diff --git a/package-lock.json b/package-lock.json index 00a0370..47cc59a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,5 @@ { "name": "eps-cdk-utils", - "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -389,6 +388,58 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-route-53": { + "version": "3.978.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-route-53/-/client-route-53-3.978.0.tgz", + "integrity": "sha512-sXay1jhv4JiSjXUem1HFGisI0HMGw3J1+lQYp7IUNI7MOgvBcy9pyqO7MmFsZl2R9FrJqZhUoPSpLOai2Dj/IQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.4", + "@aws-sdk/credential-provider-node": "^3.972.2", + "@aws-sdk/middleware-host-header": "^3.972.2", + "@aws-sdk/middleware-logger": "^3.972.2", + "@aws-sdk/middleware-recursion-detection": "^3.972.2", + "@aws-sdk/middleware-sdk-route53": "^3.972.2", + "@aws-sdk/middleware-user-agent": "^3.972.4", + "@aws-sdk/region-config-resolver": "^3.972.2", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.2", + "@aws-sdk/util-user-agent-node": "^3.972.2", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/middleware-retry": "^4.4.29", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.28", + "@smithy/util-defaults-mode-node": "^4.2.31", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-s3": { "version": "3.978.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.978.0.tgz", @@ -558,18 +609,18 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.3.tgz", - "integrity": "sha512-IbBGWhaxiEl64fznwh5PDEB0N7YJEAvK5b6nRtPVUKdKAHlOPgo6B9XB8mqWDs8Ct0oF/E34ZLiq2U0L5xDkrg==", + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.4.tgz", + "integrity": "sha512-OC7F3ipXV12QfDEWybQGHLzoeHBlAdx/nLzPfHP0Wsabu3JBffu5nlzSaJNf7to9HGtOW8Bpu8NX0ugmDrCbtw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.2", + "@aws-sdk/core": "^3.973.4", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.10.12", + "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.10", "tslib": "^2.6.2" @@ -623,13 +674,13 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.2.tgz", - "integrity": "sha512-Lz1J5IZdTjLYTVIcDP5DVDgi1xlgsF3p1cnvmbfKbjCRhQpftN2e2J4NFfRRvPD54W9+bZ8l5VipPXtTYK7aEg==", + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.3.tgz", + "integrity": "sha512-iu+JwWHM7tHowKqE+8wNmI3sM6mPEiI9Egscz2BEV7adyKmV95oR9tBO4VIOl72FGDi7X9mXg19VtqIpSkEEsA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.2", - "@aws-sdk/credential-provider-http": "^3.972.3", + "@aws-sdk/credential-provider-http": "^3.972.4", "@aws-sdk/credential-provider-ini": "^3.972.2", "@aws-sdk/credential-provider-process": "^3.972.2", "@aws-sdk/credential-provider-sso": "^3.972.2", @@ -816,6 +867,20 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-route53": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-route53/-/middleware-sdk-route53-3.972.2.tgz", + "integrity": "sha512-qq8DKlxMvBjBXGXxH0eNvmdB4PtUo+rXV2LvD334rIav0FnUbHgJGaD3+ImDUjhlhgm5GazUMvS3Qz/7hnR9/w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-sdk-s3": { "version": "3.972.4", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.4.tgz", @@ -1116,15 +1181,15 @@ } }, "node_modules/@aws-sdk/util-locate-window": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", - "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", + "version": "3.965.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", + "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/util-user-agent-browser": { @@ -1178,22 +1243,22 @@ } }, "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", - "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -1209,9 +1274,9 @@ "license": "MIT" }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", "dev": true, "license": "MIT", "engines": { @@ -1219,21 +1284,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -1260,14 +1325,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1277,13 +1342,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -1314,29 +1379,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1346,9 +1411,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -1386,27 +1451,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -1471,13 +1536,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1513,13 +1578,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1639,13 +1704,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1655,42 +1720,42 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", "debug": "^4.3.1" }, "engines": { @@ -1698,9 +1763,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "dev": true, "license": "MIT", "dependencies": { @@ -1746,9 +1811,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, @@ -1758,9 +1823,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -2290,9 +2355,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2302,7 +2367,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -3016,9 +3081,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", - "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -3030,9 +3095,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", - "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -3044,9 +3109,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", - "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -3058,9 +3123,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", - "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -3072,9 +3137,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", - "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -3086,9 +3151,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", - "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -3100,9 +3165,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", - "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -3114,9 +3179,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", - "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -3128,9 +3193,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", - "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -3142,9 +3207,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", - "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -3156,9 +3221,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", - "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "cpu": [ "loong64" ], @@ -3170,9 +3235,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", - "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -3184,9 +3249,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", - "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "cpu": [ "ppc64" ], @@ -3198,9 +3263,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", - "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -3212,9 +3277,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", - "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -3226,9 +3291,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", - "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -3240,9 +3305,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", - "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -3254,9 +3319,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", - "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -3268,9 +3333,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", - "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -3282,9 +3347,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", - "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "cpu": [ "x64" ], @@ -3296,9 +3361,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", - "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -3310,9 +3375,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", - "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -3324,9 +3389,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", - "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -3338,9 +3403,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", - "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -3352,9 +3417,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", - "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -3366,9 +3431,9 @@ ] }, "node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -5596,13 +5661,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", - "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5619,7 +5683,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5639,9 +5702,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -5659,11 +5722,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -5723,9 +5786,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "dev": true, "funding": [ { @@ -5791,9 +5854,9 @@ } }, "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -5807,9 +5870,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", - "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true, "license": "MIT" }, @@ -6001,9 +6064,9 @@ } }, "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -6054,9 +6117,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6071,9 +6134,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.259", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", - "integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==", + "version": "1.5.283", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", + "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", "dev": true, "license": "ISC" }, @@ -6377,9 +6440,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6492,9 +6555,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -6639,9 +6702,9 @@ "license": "ISC" }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6833,7 +6896,6 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -7337,21 +7399,6 @@ "fsevents": "^2.3.3" } }, - "node_modules/jest-haste-map/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/jest-junit": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-16.0.0.tgz", @@ -8126,7 +8173,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -8714,7 +8760,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8912,9 +8957,9 @@ } }, "node_modules/rollup": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", - "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -8928,31 +8973,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.0", - "@rollup/rollup-android-arm64": "4.57.0", - "@rollup/rollup-darwin-arm64": "4.57.0", - "@rollup/rollup-darwin-x64": "4.57.0", - "@rollup/rollup-freebsd-arm64": "4.57.0", - "@rollup/rollup-freebsd-x64": "4.57.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", - "@rollup/rollup-linux-arm-musleabihf": "4.57.0", - "@rollup/rollup-linux-arm64-gnu": "4.57.0", - "@rollup/rollup-linux-arm64-musl": "4.57.0", - "@rollup/rollup-linux-loong64-gnu": "4.57.0", - "@rollup/rollup-linux-loong64-musl": "4.57.0", - "@rollup/rollup-linux-ppc64-gnu": "4.57.0", - "@rollup/rollup-linux-ppc64-musl": "4.57.0", - "@rollup/rollup-linux-riscv64-gnu": "4.57.0", - "@rollup/rollup-linux-riscv64-musl": "4.57.0", - "@rollup/rollup-linux-s390x-gnu": "4.57.0", - "@rollup/rollup-linux-x64-gnu": "4.57.0", - "@rollup/rollup-linux-x64-musl": "4.57.0", - "@rollup/rollup-openbsd-x64": "4.57.0", - "@rollup/rollup-openharmony-arm64": "4.57.0", - "@rollup/rollup-win32-arm64-msvc": "4.57.0", - "@rollup/rollup-win32-ia32-msvc": "4.57.0", - "@rollup/rollup-win32-x64-gnu": "4.57.0", - "@rollup/rollup-win32-x64-msvc": "4.57.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -8960,7 +9005,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9387,9 +9431,9 @@ } }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9810,9 +9854,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -9993,21 +10037,6 @@ } } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -10415,6 +10444,7 @@ "license": "MIT", "dependencies": { "@aws-sdk/client-cloudformation": "^3.978.0", + "@aws-sdk/client-route-53": "^3.975.0", "@aws-sdk/client-s3": "^3.978.0", "aws-cdk": "^2.1104.0", "aws-cdk-lib": "^2.236.0", diff --git a/packages/cdkConstructs/package.json b/packages/cdkConstructs/package.json index fb420a6..346fff1 100644 --- a/packages/cdkConstructs/package.json +++ b/packages/cdkConstructs/package.json @@ -21,6 +21,7 @@ "type": "module", "dependencies": { "@aws-sdk/client-cloudformation": "^3.978.0", + "@aws-sdk/client-route-53": "^3.975.0", "@aws-sdk/client-s3": "^3.978.0", "aws-cdk": "^2.1104.0", "aws-cdk-lib": "^2.236.0", diff --git a/packages/cdkConstructs/src/index.ts b/packages/cdkConstructs/src/index.ts index 426bfbe..d9c09c8 100644 --- a/packages/cdkConstructs/src/index.ts +++ b/packages/cdkConstructs/src/index.ts @@ -4,3 +4,4 @@ export * from "./constructs/PythonLambdaFunction.js" export * from "./apps/createApp.js" export * from "./config/index.js" export * from "./utils/helpers.js" +export * from "./stacks/deleteUnusedStacks.js" diff --git a/packages/cdkConstructs/src/stacks/deleteUnusedStacks.ts b/packages/cdkConstructs/src/stacks/deleteUnusedStacks.ts new file mode 100644 index 0000000..d46fe6b --- /dev/null +++ b/packages/cdkConstructs/src/stacks/deleteUnusedStacks.ts @@ -0,0 +1,312 @@ +import { + CloudFormationClient, + DeleteStackCommand, + ListStacksCommand, + StackSummary +} from "@aws-sdk/client-cloudformation" +import { + ChangeResourceRecordSetsCommand, + ListHostedZonesByNameCommand, + ListResourceRecordSetsCommand, + ResourceRecordSet, + Route53Client +} from "@aws-sdk/client-route-53" + +/** + * Deletes unused CloudFormation stacks and their associated Route 53 CNAME records. + * + * A stack is considered unused if it is a superseded version of the base stack + * (and is not within the 24‑hour embargo window). + * + * @param baseStackName - Base name/prefix of the CloudFormation stacks to evaluate. + * @param getActiveVersions - Function to get the currently active versions. + * @param hostedZoneName - Hosted zone name used to look up Route 53 records. + * (Only required if stacks have CNAME records that need cleaning up.) + * @returns A promise that resolves when all eligible stacks have been processed. + */ +export async function deleteUnusedMainStacks( + baseStackName: string, + getActiveVersions: () => Promise, + hostedZoneName?: string | undefined +): Promise { + const cloudFormationClient = new CloudFormationClient({}) + const route53Client = new Route53Client({}) + const {hostedZoneId, cnameRecords} = await getHostedZoneInfo(route53Client, hostedZoneName) + const activeVersions = await getActiveVersions() + console.log("checking cloudformation stacks") + + const allStacks = await listAllStacks(cloudFormationClient) + const activeVersionDeployed = allStacks.find(stack => { + const versionInfo = getVersion(stack.StackName!, baseStackName) + if (!versionInfo) { + return false + } + const {version, isSandbox} = versionInfo + return !isSandbox && version === activeVersions.baseEnvVersion?.replaceAll(".", "-") + })?.CreationTime + if (isEmbargoed(activeVersionDeployed)) { + console.log( + `Active version ${activeVersions.baseEnvVersion} deployed less than 24 hours ago,` + + "skipping deletion of superseded stacks") + return + } + + for (const stack of allStacks) { + if (stack.StackStatus === "DELETE_COMPLETE" || !stack.StackName) { + continue + } + + const stackName = stack.StackName + if (!isSupersededVersion(stack, baseStackName, activeVersions)) { + continue + } + + await deleteStack(cloudFormationClient, route53Client, hostedZoneId, cnameRecords, stackName) + } +} + +/** + * Deletes unused CloudFormation stacks and their associated Route 53 CNAME records. + * + * A stack is considered unused if it represents a pull request deployment whose PR has been closed. + * + * @param baseStackName - Base name/prefix of the CloudFormation stacks to evaluate. + * @param repoName - GitHub repository name used to look up pull request state. + * @param hostedZoneName - Hosted zone name used to look up Route 53 records. + * (Only required if stacks have CNAME records that need cleaning up.) + * @returns A promise that resolves when all eligible stacks have been processed. + */ +export async function deleteUnusedPrStacks( + baseStackName: string, + repoName: string, + hostedZoneName?: string | undefined): Promise { + const cloudFormationClient = new CloudFormationClient({}) + const route53Client = new Route53Client({}) + const {hostedZoneId, cnameRecords} = await getHostedZoneInfo(route53Client, hostedZoneName) + + console.log("checking cloudformation stacks") + + const allStacks = await listAllStacks(cloudFormationClient) + + for (const stack of allStacks) { + if (stack.StackStatus === "DELETE_COMPLETE" || !stack.StackName) { + continue + } + + const stackName = stack.StackName + if (!(await isClosedPullRequest(stackName, baseStackName, repoName))) { + continue + } + + await deleteStack(cloudFormationClient, route53Client, hostedZoneId, cnameRecords, stackName) + } +} + +async function deleteStack( + cloudFormationClient: CloudFormationClient, + route53Client: Route53Client, + hostedZoneId: string | undefined, + cnameRecords: Array, + stackName: string +): Promise { + await cloudFormationClient.send(new DeleteStackCommand({StackName: stackName})) + console.log("** Sleeping for 60 seconds to avoid 429 on delete stack **") + await new Promise((resolve) => setTimeout(resolve, 60_000)) + + console.log(`** going to delete CNAME records for stack ${stackName} **`) + const toDelete = cnameRecords.filter(r => r.Name?.includes(stackName)) + if (!hostedZoneId || toDelete.length === 0) { + console.log(`No CNAME records to delete for stack ${stackName}`) + return + } + await route53Client.send( + new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Changes: toDelete.map(r => ({ + Action: "DELETE", + ResourceRecordSet: r + })) + } + }) + ) + + for (const record of toDelete) { + console.log(`Deleted CNAME record: ${record.Name}`) + } +} + +async function listAllStacks(cloudFormationClient: CloudFormationClient): Promise> { + const stacks: Array = [] + let nextToken: string | undefined + + do { + const response = await cloudFormationClient.send(new ListStacksCommand({NextToken: nextToken})) + + if (response.StackSummaries) { + stacks.push(...response.StackSummaries) + } + + nextToken = response.NextToken + } while (nextToken) + + return stacks +} + +async function getHostedZoneInfo( + route53Client: Route53Client, + hostedZoneName: string | undefined +): Promise<{ hostedZoneId: string | undefined, cnameRecords: Array }> { + if (!hostedZoneName) { + return {hostedZoneId: undefined, cnameRecords: []} + } + const response = await route53Client.send( + new ListHostedZonesByNameCommand({ + DNSName: hostedZoneName + }) + ) + + const hostedZoneId = response.HostedZones?.[0]?.Id + if (!hostedZoneId) { + console.log(`Hosted zone ${hostedZoneName} not found`) + return {hostedZoneId: undefined, cnameRecords: []} + } + + let cnameRecords: Array = [] + let nextRecordName: string | undefined + do { + const response = await route53Client.send( + new ListResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + StartRecordName: nextRecordName + }) + ) + cnameRecords.push(...(response.ResourceRecordSets?.filter(record => record.Type === "CNAME") || [])) + nextRecordName = response.NextRecordName + } while (nextRecordName) + + return {hostedZoneId, cnameRecords} +} + +async function isClosedPullRequest(stackName: string, baseStackName: string, repoName: string): Promise { + const match = new RegExp(String.raw`^${baseStackName}-pr-(?\d+)(-sandbox)?$`).exec(stackName) + if (!match?.groups?.pullRequestId) { + return false + } + + const pullRequestId = match.groups.pullRequestId + console.log(`Checking pull request id ${pullRequestId}`) + const url = `https://api.github.com/repos/NHSDigital/${repoName}/pulls/${pullRequestId}` + + const headers: Record = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${process.env.GITHUB_TOKEN}` + } + + const response = await fetch(url, {headers}) + if (!response.ok) { + console.log(`Failed to fetch PR ${pullRequestId}: ${response.status} ${await response.text()}`) + return false + } + + const data = (await response.json()) as {state?: string} + if (data.state !== "closed") { + console.log(`not going to delete stack ${stackName} as PR state is ${data.state}`) + return false + } + + console.log(`** going to delete stack ${stackName} as PR state is ${data.state} **`) + return true +} + +/** + * Represents the currently active API versions in the base environment + * and (optionally) the corresponding sandbox environment. + */ +export type ActiveVersions = { + /** Currently deployed version in the base APIGEE environment (e.g. "v1.2.3"). */ + baseEnvVersion: string + /** + * Currently deployed version in the sandbox APIGEE environment, or null when + * there is no sandbox deployment for the given base environment. + */ + sandboxEnvVersion: string | null +} + +/** + * Fetches the active API versions from the APIM status endpoint for the + * configured APIGEE environment, and where applicable its sandbox variant. + * + * The base environment is taken from `process.env.APIGEE_ENVIRONMENT`, and the + * sandbox environment is queried for `int` ("sandbox") and `internal-dev` + * ("internal-dev-sandbox"). Failures to resolve the sandbox version are + * logged and surfaced as `sandboxEnvVersion: null`. + * + * @param basePath - Base path of the API used to build the _status URL. + * @returns An object containing the active base and sandbox API versions. + */ +export async function getActiveApiVersions(basePath: string): Promise { + let apigeeEnv = process.env.APIGEE_ENVIRONMENT! + const baseEnvVersion = await getActiveApiVersion(apigeeEnv, basePath) + let sandboxEnvVersion: string | null = null + try { + if (apigeeEnv === "int") { + sandboxEnvVersion = await getActiveApiVersion("sandbox", basePath) + } else if (apigeeEnv === "internal-dev") { + sandboxEnvVersion = await getActiveApiVersion("internal-dev-sandbox", basePath) + } + } catch (error) { + console.log(`Failed to get active version for sandbox environment: ${(error as Error).message}`) + } + return {baseEnvVersion, sandboxEnvVersion} +} + +async function getActiveApiVersion(apimDomain: string, basePath: string): Promise { + const headers: Record = { + Accept: "application/json", + apikey: `${process.env.APIM_STATUS_API_KEY}` + } + const url = `https://${apimDomain}/${basePath}/_status` + console.log(`Checking live api status endpoint at ${url} for active version`) + const response = await fetch(url, {headers}) + if (!response.ok) { + throw new Error(`Failed to fetch active version from ${url}: ${response.status} ${await response.text()}`) + } + + const data = (await response.json()) as {checks: {healthcheck: {outcome: {versionNumber: string}}}} + return data.checks.healthcheck.outcome.versionNumber +} + +function getVersion(stackName: string, baseStackName: string): {version: string, isSandbox: boolean} | null { + const pattern = String.raw`^${baseStackName}(?-sandbox)?-(?[\da-z-]+)?$` + const match = new RegExp(pattern).exec(stackName) + if (!match?.groups?.version || match.groups.version.startsWith("pr-")) { + return null + } + return {version: match.groups.version, isSandbox: match.groups.sandbox === "-sandbox"} +} + +function isEmbargoed(deployDate: Date | undefined): boolean { + return !!deployDate && Date.now() - deployDate.getTime() < 24 * 60 * 60 * 1000 +} + +function isSupersededVersion( + stack: StackSummary, + baseStackName: string, + activeVersions: ActiveVersions +): boolean { + const versionInfo = getVersion(stack.StackName!, baseStackName) + if (!versionInfo) { + return false + } + if (isEmbargoed(stack.CreationTime)) { + console.log(`Stack ${stack.StackName} created less than 24 hours ago, keeping for potential rollback`) + return false + } + const {version, isSandbox} = versionInfo + const currentVersion = isSandbox ? activeVersions.sandboxEnvVersion : activeVersions.baseEnvVersion + if (!currentVersion) { + return false + } + return version !== currentVersion.replaceAll(".", "-") +} diff --git a/packages/cdkConstructs/tests/stacks/deleteUnusedStacks.test.ts b/packages/cdkConstructs/tests/stacks/deleteUnusedStacks.test.ts new file mode 100644 index 0000000..def47d3 --- /dev/null +++ b/packages/cdkConstructs/tests/stacks/deleteUnusedStacks.test.ts @@ -0,0 +1,818 @@ +import { + describe, + test, + beforeEach, + afterEach, + expect, + vi +} from "vitest" + +import {deleteUnusedMainStacks, deleteUnusedPrStacks, getActiveApiVersions} from "../../src/stacks/deleteUnusedStacks" + +const mockListStacksSend = vi.fn() +const mockDeleteStackSend = vi.fn() +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const mockListHostedZonesByNameSend = vi.fn((_) => ({HostedZones: [{Id: "Z123"}]})) +const mockListResourceRecordSetsSend = vi.fn() +const mockChangeResourceRecordSetsSend = vi.fn() + +vi.mock("@aws-sdk/client-cloudformation", () => { + class CloudFormationClient { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(public config: any = {}) {} + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + send(command: any) { + if (command instanceof ListStacksCommand) { + return mockListStacksSend(command.input) + } else if (command instanceof DeleteStackCommand) { + return mockDeleteStackSend(command.input) + } else { + throw new TypeError("Unknown command") + } + } + } + + class ListStacksCommand { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(input: any) { + this.input = input + } + } + + class DeleteStackCommand { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(input: any) { + this.input = input + } + } + + return {CloudFormationClient, ListStacksCommand, DeleteStackCommand} +}) + +vi.mock("@aws-sdk/client-route-53", () => { + class Route53Client { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(public config: any = {}) {} + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + send(command: any) { + if (command instanceof ListHostedZonesByNameCommand) { + return mockListHostedZonesByNameSend(command.input) + } else if (command instanceof ListResourceRecordSetsCommand) { + return mockListResourceRecordSetsSend(command.input) + } else if (command instanceof ChangeResourceRecordSetsCommand) { + return mockChangeResourceRecordSetsSend(command.input) + } else { + throw new TypeError("Unknown command") + } + } + } + + class ListHostedZonesByNameCommand { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(input: any) { + this.input = input + } + } + + class ListResourceRecordSetsCommand { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(input: any) { + this.input = input + } + } + + class ChangeResourceRecordSetsCommand { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(input: any) { + this.input = input + } + } + + return {Route53Client, ListHostedZonesByNameCommand, ListResourceRecordSetsCommand, ChangeResourceRecordSetsCommand} +}) + +const originalEnv = process.env +const originalFetch = globalThis.fetch + +const mockActiveVersion = "v1.2.3" +const mockGetPRState = vi.fn<(url: string) => string>((url: string) => { + throw new Error(`Unexpected URL: ${url}`) +}) + +describe("stack deletion", () => { + const baseStackName = "eps-api" + const repoName = "eps-cdk-utils" + const basePath = "status-path" + const hostedZoneName = "dev.eps.national.nhs.uk." + + beforeEach(() => { + process.env = { + ...originalEnv, + APIGEE_ENVIRONMENT: "prod", + APIM_STATUS_API_KEY: "test-api-key", + GITHUB_TOKEN: "test-github-token" + } + + mockListStacksSend.mockReset() + mockDeleteStackSend.mockReset() + mockListHostedZonesByNameSend.mockReset() + mockListResourceRecordSetsSend.mockReset() + mockChangeResourceRecordSetsSend.mockReset() + mockGetPRState.mockReset() + + // By default, no CNAME records are present; individual tests + // can override this where specific records are required. + mockListResourceRecordSetsSend.mockReturnValue({ResourceRecordSets: []}) + + vi.useFakeTimers() + vi.setSystemTime(new Date("2024-01-03T00:00:00.000Z")) + }) + + afterEach(() => { + process.env = originalEnv + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).fetch = originalFetch + vi.useRealTimers() + }) + + describe("deleteUnusedMainStacks", () => { + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).fetch = () => { + return Promise.resolve({ + ok: true, + status: 200, + text: async () => "", + json: async () => ({checks: {healthcheck: {outcome: {versionNumber: mockActiveVersion}}}}) + }) + } + }) + + test("deletes superseded stacks when embargo has passed", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-v1-2-2`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-abcd123`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // Superseded version should be deleted + expect(mockDeleteStackSend).toHaveBeenCalledTimes(2) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-v1-2-2`}) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-abcd123`}) + }) + + test("does not delete embargoed versions even if active version is outside embargo period", async () => { + const now = new Date() + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-v1-2-4`, + StackStatus: "CREATE_COMPLETE", + CreationTime: oneHourAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // No delete stack call should have been made + expect(mockDeleteStackSend).not.toHaveBeenCalled() + }) + + test("does not delete superseded stack when active version is within embargo period", async () => { + const now = new Date() + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: oneHourAgo + }, + { + StackName: `${baseStackName}-v1-2-2`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // No delete stack call should have been made + expect(mockDeleteStackSend).not.toHaveBeenCalled() + }) + + test("deletes superseded sandbox stacks when embargo has passed", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + process.env.APIGEE_ENVIRONMENT = "int" + + mockListResourceRecordSetsSend.mockReturnValue({ + ResourceRecordSets: [ + { + Name: `${baseStackName}-sandbox-v1-2-3.dev.eps.national.nhs.uk.`, + Type: "CNAME" + }, + { + Name: `${baseStackName}-sandbox-v1-2-2.dev.eps.national.nhs.uk.`, + Type: "CNAME" + } + ] + }) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-sandbox-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-sandbox-v1-2-2`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // Superseded sandbox version should be deleted + expect(mockDeleteStackSend).toHaveBeenCalledTimes(1) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-sandbox-v1-2-2`}) + + // CNAME deletion for the superseded sandbox stack + expect(mockChangeResourceRecordSetsSend).toHaveBeenCalledTimes(1) + expect(mockChangeResourceRecordSetsSend).toHaveBeenCalledWith({ + HostedZoneId: "Z123", + ChangeBatch: { + Changes: [{ + Action: "DELETE", + ResourceRecordSet: { + Name: `${baseStackName}-sandbox-v1-2-2.dev.eps.national.nhs.uk.`, + Type: "CNAME" + } + }] + } + }) + }) + + test("does not delete CNAME records if no hosted zone name is provided", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + process.env.APIGEE_ENVIRONMENT = "int" + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-v1-2-2`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), undefined) + await vi.runAllTimersAsync() + await promise + + // Superseded sandbox version should be deleted + expect(mockDeleteStackSend).toHaveBeenCalledTimes(1) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-v1-2-2`}) + + // No CNAME deletion should have been made + expect(mockChangeResourceRecordSetsSend).not.toHaveBeenCalled() + }) + + test("does not delete CNAME records if hosted zone cannot be found", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + process.env.APIGEE_ENVIRONMENT = "int" + + mockListHostedZonesByNameSend.mockReturnValueOnce({HostedZones: []}) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-v1-2-2`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // Superseded sandbox version should be deleted + expect(mockDeleteStackSend).toHaveBeenCalledTimes(1) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-v1-2-2`}) + + // No CNAME deletion should have been made + expect(mockChangeResourceRecordSetsSend).not.toHaveBeenCalled() + }) + + test("does not delete CNAME records if no CNAME records are found", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + process.env.APIGEE_ENVIRONMENT = "int" + mockListResourceRecordSetsSend.mockReturnValueOnce({}) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-v1-2-2`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // Superseded sandbox version should be deleted + expect(mockDeleteStackSend).toHaveBeenCalledTimes(1) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-v1-2-2`}) + + // No CNAME deletion should have been made + expect(mockChangeResourceRecordSetsSend).not.toHaveBeenCalled() + }) + + test("deletes superseded internal-dev sandbox stacks when embargo has passed", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + process.env.APIGEE_ENVIRONMENT = "internal-dev" + + mockListResourceRecordSetsSend.mockReturnValue({ + ResourceRecordSets: [ + { + Name: `${baseStackName}-sandbox-v1-2-3.dev.eps.national.nhs.uk.`, + Type: "CNAME" + }, + { + Name: `${baseStackName}-sandbox-v1-2-2.dev.eps.national.nhs.uk.`, + Type: "CNAME" + } + ] + }) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-sandbox-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-sandbox-v1-2-2`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // Superseded sandbox version should be deleted + expect(mockDeleteStackSend).toHaveBeenCalledTimes(1) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-sandbox-v1-2-2`}) + + // CNAME deletion for the superseded sandbox stack + expect(mockChangeResourceRecordSetsSend).toHaveBeenCalledTimes(1) + expect(mockChangeResourceRecordSetsSend).toHaveBeenCalledWith({ + HostedZoneId: "Z123", + ChangeBatch: { + Changes: [{ + Action: "DELETE", + ResourceRecordSet: { + Name: `${baseStackName}-sandbox-v1-2-2.dev.eps.national.nhs.uk.`, + Type: "CNAME" + } + }] + } + }) + }) + + test("still deletes non sandbox superseded stacks when fetching sandbox state fails", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + process.env.APIGEE_ENVIRONMENT = "int" + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-v1-2-2`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-sandbox-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-sandbox-v1-2-2`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).fetch = (url: string) => { + if (url.includes("sandbox")) { + return Promise.resolve({ + ok: false, + status: 500, + text: async () => "Error fetching sandbox status" + }) + } + // Default mock for other fetch calls + return Promise.resolve({ + ok: true, + status: 200, + text: async () => "", + json: async () => ({checks: {healthcheck: {outcome: {versionNumber: mockActiveVersion}}}}) + }) + } + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // Superseded version should be deleted + expect(mockDeleteStackSend).toHaveBeenCalledTimes(1) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-v1-2-2`}) + }) + + test("ignores PR stacks", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-pr-123`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // No delete stack call should have been made + expect(mockDeleteStackSend).not.toHaveBeenCalled() + }) + + test("skips stacks with DELETE_COMPLETE status", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-2`, + StackStatus: "DELETE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedMainStacks(baseStackName, () => getActiveApiVersions(basePath), hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // No delete stack call should have been made + expect(mockDeleteStackSend).not.toHaveBeenCalled() + }) + }) + + describe("deleteUnusedPrStacks", () => { + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).fetch = (url: string) => { + return Promise.resolve({ + ok: true, + status: 200, + text: async () => "", + json: async () => ({state: mockGetPRState(url)}) + }) + } + }) + + test("deletes closed PR stacks and CNAME records", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + }, + { + StackName: `${baseStackName}-pr-123`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + mockListResourceRecordSetsSend.mockReturnValue({ + ResourceRecordSets: [ + { + Name: `${baseStackName}-pr-123.dev.eps.national.nhs.uk.`, + Type: "CNAME" + } + ] + }) + + mockGetPRState.mockImplementation((url: string) => { + if (url.endsWith("/repos/NHSDigital/eps-cdk-utils/pulls/123")) { + return "closed" + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const promise = deleteUnusedPrStacks(baseStackName, repoName, hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // One delete stack call for the PR stack + expect(mockDeleteStackSend).toHaveBeenCalledTimes(1) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-pr-123`}) + + // CNAME deletion for the PR stack + expect(mockChangeResourceRecordSetsSend).toHaveBeenCalledTimes(1) + expect(mockChangeResourceRecordSetsSend).toHaveBeenCalledWith({ + HostedZoneId: "Z123", + ChangeBatch: { + Changes: [{ + Action: "DELETE", + ResourceRecordSet: { + Name: `${baseStackName}-pr-123.dev.eps.national.nhs.uk.`, + Type: "CNAME" + } + }] + } + }) + }) + + test("does not delete open PR stacks", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-pr-456`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + mockGetPRState.mockImplementation((url: string) => { + if (url.endsWith("/repos/NHSDigital/eps-cdk-utils/pulls/456")) { + return "open" + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const promise = deleteUnusedPrStacks(baseStackName, repoName, hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // No delete stack call should have been made + expect(mockDeleteStackSend).not.toHaveBeenCalled() + + // No CNAME deletion should have been made + expect(mockChangeResourceRecordSetsSend).not.toHaveBeenCalled() + }) + + test("handles multiple pages of CloudFormation stacks", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockImplementation(({NextToken}) => { + if (!NextToken) { + return { + StackSummaries: [ + { + StackName: `${baseStackName}-v1-2-3`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ], + NextToken: "token-1" + } + } + + return { + StackSummaries: [ + { + StackName: `${baseStackName}-pr-789`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + } + }) + + mockGetPRState.mockImplementation((url: string) => { + if (url.endsWith("/repos/NHSDigital/eps-cdk-utils/pulls/789")) { + return "closed" + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const promise = deleteUnusedPrStacks(baseStackName, repoName, hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // Both pages of stacks should have been requested + expect(mockListStacksSend).toHaveBeenCalledTimes(2) + + // PR stack from the second page should be deleted + expect(mockDeleteStackSend).toHaveBeenCalledTimes(1) + expect(mockDeleteStackSend).toHaveBeenCalledWith({StackName: `${baseStackName}-pr-789`}) + }) + + test("skips stacks with DELETE_COMPLETE status", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-pr-101`, + StackStatus: "DELETE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + const promise = deleteUnusedPrStacks(baseStackName, repoName, hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // No delete stack call should have been made + expect(mockDeleteStackSend).not.toHaveBeenCalled() + + // No CNAME deletion should have been made + expect(mockChangeResourceRecordSetsSend).not.toHaveBeenCalled() + }) + + test("skips PR stacks when fetching PR state fails", async () => { + const now = new Date() + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000) + + mockListStacksSend.mockReturnValue({ + StackSummaries: [ + { + StackName: `${baseStackName}-pr-202`, + StackStatus: "CREATE_COMPLETE", + CreationTime: twoDaysAgo + } + ] + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).fetch = (url: string) => { + if (url.includes("api.github.com")) { + return Promise.resolve({ + ok: false, + status: 500, + text: async () => "Error fetching PR" + }) + } + // Default mock for other fetch calls + return Promise.resolve({ + ok: true, + status: 200, + text: async () => "", + json: async () => ({checks: {healthcheck: {outcome: {versionNumber: mockActiveVersion}}}}) + }) + } + + const promise = deleteUnusedPrStacks(baseStackName, repoName, hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // No delete stack call should have been made + expect(mockDeleteStackSend).not.toHaveBeenCalled() + + // No CNAME deletion should have been made + expect(mockChangeResourceRecordSetsSend).not.toHaveBeenCalled() + }) + + test("handles no stacks returned", async () => { + mockListStacksSend.mockReturnValue({}) + + const promise = deleteUnusedPrStacks(baseStackName, repoName, hostedZoneName) + await vi.runAllTimersAsync() + await promise + + // No delete stack call should have been made + expect(mockDeleteStackSend).not.toHaveBeenCalled() + + // No CNAME deletion should have been made + expect(mockChangeResourceRecordSetsSend).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/deploymentUtils/src/index.ts b/packages/deploymentUtils/src/index.ts index 460d8c6..b6b8443 100644 --- a/packages/deploymentUtils/src/index.ts +++ b/packages/deploymentUtils/src/index.ts @@ -1,3 +1,4 @@ export * from "./specifications/deployApi" export * from "./specifications/writeSchemas" +export * from "./specifications/deleteProxygenDeployments" export * from "./config/index" diff --git a/packages/deploymentUtils/src/specifications/deleteProxygenDeployments.ts b/packages/deploymentUtils/src/specifications/deleteProxygenDeployments.ts new file mode 100644 index 0000000..53dc69b --- /dev/null +++ b/packages/deploymentUtils/src/specifications/deleteProxygenDeployments.ts @@ -0,0 +1,120 @@ +import {LambdaClient} from "@aws-sdk/client-lambda" +import {getCFConfigValue, getCloudFormationExports} from "../config" +import {invokeLambda} from "./invokeLambda" + +interface ProxygenInstance { + name: string +} + +async function isClosedPullRequest(instanceName: string, apigeeApi: string, repoName: string): Promise { + const match = new RegExp(String.raw`^${apigeeApi}-pr-(?\d+)$`).exec(instanceName) + if (!match?.groups?.pullRequestId) { + return false + } + + const pullRequestId = match.groups.pullRequestId + console.log(`Checking pull request id ${pullRequestId}`) + const url = `https://api.github.com/repos/NHSDigital/${repoName}/pulls/${pullRequestId}` + + const headers: Record = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${process.env.GITHUB_TOKEN}` + } + + const response = await fetch(url, {headers}) + if (!response.ok) { + console.log(`Failed to fetch PR ${pullRequestId}: ${response.status} ${await response.text()}`) + return false + } + + const data = (await response.json()) as {state?: string} + if (data.state !== "closed") { + console.log(`not going to delete instance ${instanceName} as PR state is ${data.state}`) + return false + } + + console.log(`** going to delete instance ${instanceName} as PR state is ${data.state} **`) + return true +} + +async function deleteEnvProxygenDeployments( + apigeeEnvironment: string, + apigeeApi: string, + repoName: string, + proxygenPrivateKeyName: string, + proxygenKid: string +): Promise { + const lambda = new LambdaClient({}) + + const exports = await getCloudFormationExports() + const proxygenPrivateKeyArn = getCFConfigValue(exports, `account-resources:${proxygenPrivateKeyName}`) + + console.log(`Checking Apigee deployments of ${apigeeApi} on ${apigeeEnvironment}`) + const instances = JSON.parse(await invokeLambda( + lambda, + false, + "lambda-resources-ProxygenPTLInstanceGet", + { + apiName: apigeeApi, + environment: apigeeEnvironment, + kid: proxygenKid, + proxygenSecretName: proxygenPrivateKeyArn + } + )) as Array + + for (const instance of instances) { + const name = instance.name + + if (!(await isClosedPullRequest(name, apigeeApi, repoName))) { + continue + } + + await invokeLambda( + lambda, + false, + "lambda-resources-ProxygenPTLInstanceDelete", + { + apiName: apigeeApi, + environment: apigeeEnvironment, + instance: name, + kid: proxygenKid, + proxygenSecretName: proxygenPrivateKeyArn + } + ) + } +} + +/** + * Deletes Proxygen PTL deployments for closed pull requests across internal-dev and internal-dev-sandbox. + * + * For each supported Apigee environment, this function queries existing Proxygen instances + * for the given API and deletes those whose instance name corresponds to a closed GitHub PR + * in the specified repository. + * + * @param apigeeApi - The Apigee API name whose Proxygen deployments should be cleaned up. + * @param repoName - The GitHub repository name used to look up pull request state. + * @param proxygenPrivateKeyName - The CloudFormation export key for the Proxygen private key secret. + * @param proxygenKid - The key ID (kid) used when invoking the Proxygen Lambda functions. + * @returns A promise that resolves when all eligible deployments have been processed. + */ +export async function deleteProxygenDeployments( + apigeeApi: string, + repoName: string, + proxygenPrivateKeyName: string, + proxygenKid: string +): Promise { + await deleteEnvProxygenDeployments( + "internal-dev", + apigeeApi, + repoName, + proxygenPrivateKeyName, + proxygenKid + ) + await deleteEnvProxygenDeployments( + "internal-dev-sandbox", + apigeeApi, + repoName, + proxygenPrivateKeyName, + proxygenKid + ) +} diff --git a/packages/deploymentUtils/src/specifications/deployApi.ts b/packages/deploymentUtils/src/specifications/deployApi.ts index 369d6e4..8a0d69c 100644 --- a/packages/deploymentUtils/src/specifications/deployApi.ts +++ b/packages/deploymentUtils/src/specifications/deployApi.ts @@ -1,6 +1,7 @@ -import {LambdaClient, InvokeCommand} from "@aws-sdk/client-lambda" +import {LambdaClient} from "@aws-sdk/client-lambda" import {getCFConfigValue, getCloudFormationExports} from "../config/index" import {fixSpec} from "./fixSpec" +import {invokeLambda} from "./invokeLambda" export type ApiConfig = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -19,28 +20,6 @@ export type ApiConfig = { hiddenPaths: Array } -const lambda = new LambdaClient({}) - -async function invokeLambda( - dryRun: boolean, - functionName: string, - payload: unknown -): Promise { - if (dryRun) { - console.log(`Would invoke lambda ${functionName}`) - return - } - const invokeResult = await lambda.send(new InvokeCommand({ - FunctionName: functionName, - Payload: Buffer.from(JSON.stringify(payload)) - })) - const responsePayload = Buffer.from(invokeResult.Payload!).toString() - if (invokeResult.FunctionError) { - throw new Error(`Error calling lambda ${functionName}: ${responsePayload}`) - } - console.log(`Lambda ${functionName} invoked successfully. Response:`, responsePayload) -} - export async function deployApi( { spec, @@ -60,6 +39,7 @@ export async function deployApi( blueGreen: boolean, dryRun: boolean ): Promise { + const lambda = new LambdaClient({}) const instance = fixSpec({ spec, apiName, @@ -89,6 +69,7 @@ export async function deployApi( if (!isPullRequest) { console.log("Store the secret used for mutual TLS to AWS using Proxygen proxy lambda") await invokeLambda( + lambda, dryRun, put_secret_lambda, { @@ -105,6 +86,7 @@ export async function deployApi( console.log("Deploy the API instance using Proxygen proxy lambda") await invokeLambda( + lambda, dryRun, instance_put_lambda, { @@ -132,6 +114,7 @@ export async function deployApi( delete spec.paths[path] } await invokeLambda( + lambda, dryRun, spec_publish_lambda, { diff --git a/packages/deploymentUtils/src/specifications/invokeLambda.ts b/packages/deploymentUtils/src/specifications/invokeLambda.ts new file mode 100644 index 0000000..8a3f343 --- /dev/null +++ b/packages/deploymentUtils/src/specifications/invokeLambda.ts @@ -0,0 +1,23 @@ +import {InvokeCommand, LambdaClient} from "@aws-sdk/client-lambda" + +export async function invokeLambda( + lambda: LambdaClient, + dryRun: boolean, + functionName: string, + payload: unknown +): Promise { + if (dryRun) { + console.log(`Would invoke lambda ${functionName}`) + return "null" + } + const invokeResult = await lambda.send(new InvokeCommand({ + FunctionName: functionName, + Payload: Buffer.from(JSON.stringify(payload)) + })) + const responsePayload = Buffer.from(invokeResult.Payload!).toString() + if (invokeResult.FunctionError) { + throw new Error(`Error calling lambda ${functionName}: ${responsePayload}`) + } + console.log(`Lambda ${functionName} invoked successfully. Response:`, responsePayload) + return responsePayload +} diff --git a/packages/deploymentUtils/tests/specifications/deleteProxygenDeployments.test.ts b/packages/deploymentUtils/tests/specifications/deleteProxygenDeployments.test.ts new file mode 100644 index 0000000..02b981a --- /dev/null +++ b/packages/deploymentUtils/tests/specifications/deleteProxygenDeployments.test.ts @@ -0,0 +1,132 @@ +import { + beforeEach, + afterEach, + describe, + expect, + test, + vi +} from "vitest" +import {deleteProxygenDeployments} from "../../src/specifications/deleteProxygenDeployments" + +const getCloudFormationExportsMock = vi.hoisted(() => vi.fn()) +const invokeLambdaMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/config/index", async (importOriginal) => { + const originalModule = await importOriginal() + return { + ...originalModule, + getCloudFormationExports: getCloudFormationExportsMock + } +}) + +vi.mock("../../src/specifications/invokeLambda", async () => { + return { + invokeLambda: invokeLambdaMock + } +}) + +const originalFetch = globalThis.fetch + +function createFetchResponse(state: string, ok = true, status = 200, textBody = "") { + return Promise.resolve({ + ok, + status, + text: async () => textBody, + json: async () => ({state}) + }) as unknown as Promise +} + +describe("deleteProxygenDeployments", () => { + beforeEach(() => { + getCloudFormationExportsMock.mockReset().mockResolvedValue({ + "account-resources:proxygenKey": "arn:proxygen-key" + }) + invokeLambdaMock.mockReset() + + // default fetch mock; tests can override behaviour + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).fetch = vi.fn((url: string) => { + if (url.includes("/pulls/456")) { + return createFetchResponse("open") + } + return createFetchResponse("closed") + }) + }) + + afterEach(() => { + // restore original fetch between tests + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).fetch = originalFetch + }) + + test("deletes instances whose pull requests are closed in both environments", async () => { + const deletePayloads: Array<{environment: string, instance: string}> = [] + invokeLambdaMock.mockImplementation(async (_lambda, _dryRun, functionName: string, payload: unknown) => { + if (functionName === "lambda-resources-ProxygenPTLInstanceGet") { + const {apiName} = payload as {apiName: string} + return JSON.stringify([{name: `${apiName}-pr-123`}]) + } + if (functionName === "lambda-resources-ProxygenPTLInstanceDelete") { + deletePayloads.push(payload as {environment: string, instance: string}) + return "\"deleted\"" + } + return "\"ok\"" + }) + + await deleteProxygenDeployments("eps", "eps-repo", "proxygenKey", "kid-123") + + expect(deletePayloads).toEqual(expect.arrayContaining([ + expect.objectContaining({environment: "internal-dev", instance: "eps-pr-123"}), + expect.objectContaining({environment: "internal-dev-sandbox", instance: "eps-pr-123"}) + ])) + }) + + test("does not delete instances for open pull requests or non-PR names", async () => { + let deleteCalls = 0 + invokeLambdaMock.mockImplementation(async (_lambda, _dryRun, functionName: string) => { + if (functionName === "lambda-resources-ProxygenPTLInstanceGet") { + return JSON.stringify([ + {name: "eps-pr-456"}, + {name: "eps"} + ]) + } + if (functionName === "lambda-resources-ProxygenPTLInstanceDelete") { + deleteCalls++ + return "\"deleted\"" + } + return "\"ok\"" + }) + + await deleteProxygenDeployments("eps", "eps-repo", "proxygenKey", "kid-123") + + expect(deleteCalls).toBe(0) + }) + + test("does not delete instances when GitHub API call fails", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).fetch = vi.fn(() => { + return Promise.resolve({ + ok: false, + status: 500, + text: async () => "server error", + json: async () => ({state: "unknown"}) + }) as unknown as Promise + }) + + let deleteCalls = 0 + invokeLambdaMock.mockImplementation(async (_lambda, _dryRun, functionName: string) => { + if (functionName === "lambda-resources-ProxygenPTLInstanceGet") { + return JSON.stringify([{name: "eps-pr-999"}]) + } + if (functionName === "lambda-resources-ProxygenPTLInstanceDelete") { + deleteCalls++ + return "\"deleted\"" + } + return "\"ok\"" + }) + + await deleteProxygenDeployments("eps", "eps-repo", "proxygenKey", "kid-123") + + expect(deleteCalls).toBe(0) + }) +})