diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 0000000..df1c395 --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,12 @@ +{ + "comment": "Disable some rules to match the config of conventional-commit-GCF app", + "extends": ["@commitlint/config-conventional"], + "rules": { + "body-case": [0], + "body-max-line-length": [0], + "footer-max-line-length": [0], + "header-max-length": [0], + "subject-case": [0], + "subject-full-stop": [0] + } +} diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..0d92de4 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +.next +next-env.d.ts +node_modules +yarn.lock +package-lock.json +public +configeditor/build diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..7b8ae3f --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,39 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = { + 'env': { + 'browser': true, + 'commonjs': true, + 'es2021': true, + }, + 'extends': ['google', 'plugin:prettier/recommended'], + 'overrides': [ + { + 'env': { + 'node': true, + }, + 'files': ['.eslintrc.{js,cjs}'], + 'parserOptions': { + 'sourceType': 'script', + }, + }, + ], + 'parserOptions': { + 'ecmaVersion': 'latest', + }, + 'rules': {}, +}; diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 0000000..9270f79 --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,30 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore + +.git +.gitignore + +.github +.nyc_output +.vscode +kubernetes +node_modules +resources +terraform +test/ +.eslint* +.husky +.mdl* +.prettier* +*release-please* +*.md +configeditor/ + +#!include:.gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b1eee0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Auto-generated when installing Node packages, e.g. CF emulator +node_modules + +# General +tmp +*.swp +*.swo +.DS_Store + +# https://www.gitignore.io/api/visualstudiocode +.vscode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history + +# Misc +setenv.sh +out + +# Terraform +*.tfstate +*.tfstate.backup +*.tfstate.lock.info +*.tfplan +.terraform +terraform/*/build +terraform/*/*.json +terraform/*/*/build +terraform/*/*/*/build +terraform/*/*/*.json +!dashboard.json + +# Code coverage report +.nyc_output + +# Kubernetes manifests generated from templates +kubernetes/**/autoscaler-config/*.yaml +kubernetes/**/resourcegroup.yaml + +# Terratest +.test-data + +configeditor/build diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..b3d13f5 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,122 @@ +#!/bin/sh +# From Gerrit Code Review 3.9.2-695-gc36e51bbb2 +# +# Part of Gerrit Code Review (https://www.gerritcodereview.com/) +# +# Copyright (C) 2009 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -u +set +e + +echo "" +echo "running .husky/commit-msg checks" +echo "" + +# avoid [[ which is not POSIX sh. +if test "$#" != 1 ; then + echo "$0 requires an argument." + exit 1 +fi + +if test ! -f "$1" ; then + echo "file does not exist: $1" + exit 1 +fi + +# Run conventional-commit checks +# +if ! npx @commitlint/cli -e $1 ; then + echo "Conventional commit message checks failed" + exit 1 +fi + +# Do not create a change id if requested +case "$(git config --get gerrit.createChangeId)" in + false) + exit 0 + ;; + always) + ;; + *) + # Do not create a change id for squash/fixup commits. + if head -n1 "$1" | LC_ALL=C grep -q '^[a-z][a-z]*! '; then + exit 0 + fi + ;; +esac + + +if git rev-parse --verify HEAD >/dev/null 2>&1; then + refhash="$(git rev-parse HEAD)" +else + refhash="$(git hash-object -t tree /dev/null)" +fi + +random=$({ git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "$1"; } | git hash-object --stdin) +dest="$1.tmp.${random}" + +trap 'rm -f "$dest" "$dest-2"' EXIT + +if ! cat "$1" | sed -e '/>8/q' | git stripspace --strip-comments > "${dest}" ; then + echo "cannot strip comments from $1" + exit 1 +fi + +if test ! -s "${dest}" ; then + echo "file is empty: $1" + exit 1 +fi + +reviewurl="$(git config --get gerrit.reviewUrl)" +if test -n "${reviewurl}" ; then + token="Link" + value="${reviewurl%/}/id/I$random" + pattern=".*/id/I[0-9a-f]\{40\}" +else + token="Change-Id" + value="I$random" + pattern=".*" +fi + +if git interpret-trailers --parse < "$1" | grep -q "^$token: $pattern$" ; then + exit 0 +fi + +# There must be a Signed-off-by trailer for the code below to work. Insert a +# sentinel at the end to make sure there is one. +# Avoid the --in-place option which only appeared in Git 2.8 +if ! git interpret-trailers \ + --trailer "Signed-off-by: SENTINEL" < "$1" > "$dest-2" ; then + echo "cannot insert Signed-off-by sentinel line in $1" + exit 1 +fi + +# Make sure the trailer appears before any Signed-off-by trailers by inserting +# it as if it was a Signed-off-by trailer and then use sed to remove the +# Signed-off-by prefix and the Signed-off-by sentinel line. +# Avoid the --in-place option which only appeared in Git 2.8 +# Avoid the --where option which only appeared in Git 2.15 +if ! git -c trailer.where=before interpret-trailers \ + --trailer "Signed-off-by: $token: $value" < "$dest-2" | + sed -e "s/^Signed-off-by: \($token: \)/\1/" \ + -e "/^Signed-off-by: SENTINEL/d" > "$dest" ; then + echo "cannot insert $token line in $1" + exit 1 +fi + +if ! mv "${dest}" "$1" ; then + echo "cannot mv ${dest} to $1" + exit 1 +fi diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..999f147 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,33 @@ +#!/bin/bash + +echo "Running .husky/pre-commit checks. Use -n/--no-verify to skip" +echo "------------------------------------------------------------" + +npm run prettier-check +npm run eslint + +function hasModifiedMatching() { + [[ -z "$1" ]] && echo "hasModifiedMatching needs arg" && return 1 + git status --short --untracked-files=all --column=never | grep -q "$1" + return $? +} + +# check for modified markdown? +if hasModifiedMatching '\.md$' ; then + echo "Markdown files modified... running checks" + npm run markdown-link-check + npm run mdlint +fi + +if hasModifiedMatching ' src/' ; then + echo "src files modified... running checks" + npm run typecheck + npm test +fi + + +if hasModifiedMatching '\.tf$' ; then + echo "Terraform files modified... running checks" + npm run terraform-fmt-check + npm run terraform-validate +fi diff --git a/.mdl.json b/.mdl.json new file mode 100644 index 0000000..923106f --- /dev/null +++ b/.mdl.json @@ -0,0 +1,15 @@ +{ + "default": true, + "MD033": false, + "MD041": false, + "MD002": false, + "MD004": { "style": "asterisk" }, + "MD007": { "indent": 4 }, + "MD013": { + "ignore_code_blocks": true, + "code_blocks": false, + "tables": false + }, + "MD029": { "style": "ordered" }, + "MD030": { "ul_single": 3, "ul_multi": 3, "ol_single": 2, "ol_multi": 2 } +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..017e6cf --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.next +next-env.d.ts +node_modules +yarn.lock +package-lock.json +public +*.md +configeditor/build diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..77305b3 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,77 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +const shared = { + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: true, + quoteProps: 'preserve', + bracketSpacing: false, + trailingComma: 'all', + arrowParens: 'always', + embeddedLanguageFormatting: 'off', + bracketSameLine: true, + singleAttributePerLine: false, + jsxSingleQuote: false, + htmlWhitespaceSensitivity: 'strict', +}; + +module.exports = { + overrides: [ + { + /** TSX/TS/JS-specific configuration. */ + files: '*.tsx', + options: shared, + }, + { + files: '*.ts', + options: shared, + }, + { + files: '*.js', + options: shared, + }, + { + /** Sass-specific configuration. */ + files: '*.scss', + options: { + singleQuote: true, + }, + }, + { + files: '*.html', + options: { + printWidth: 100, + }, + }, + { + files: '*.acx.html', + options: { + parser: 'angular', + singleQuote: true, + }, + }, + { + files: '*.ng.html', + options: { + parser: 'angular', + singleQuote: true, + printWidth: 100, + }, + }, + ], +}; diff --git a/Dockerfile-unified b/Dockerfile-unified new file mode 100644 index 0000000..95461fb --- /dev/null +++ b/Dockerfile-unified @@ -0,0 +1,34 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ARG NODE_VERSION=20 +FROM node:${NODE_VERSION}-alpine AS build-env + +WORKDIR /usr/src/app +COPY src/autoscaler-common/ src/autoscaler-common/ +COPY src/scaler/scaler-core/ src/scaler/scaler-core/ +COPY src/poller/poller-core/ src/poller/poller-core/ +COPY src/unified-scaler.js src/ +COPY package*.json ./ +COPY autoscaler-config.schema.json ./ +RUN npm config set update-notifier false +RUN npm install --omit=dev +RUN find /usr/src/app/ -type d -exec chmod a+x '{}' ';' +RUN find /usr/src/app/ -type f -name '*.js*' -exec chmod a+r '{}' ';' + +FROM gcr.io/distroless/nodejs${NODE_VERSION}:nonroot +COPY --from=build-env /usr/src/app /usr/src/app +WORKDIR /usr/src/app/ + +CMD ["-e", "require('./src/unified-scaler').main()"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4da4bfc --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ An open source tool to autoscale Memorystore Cluster instances +
+ Home + · + Poller component + · + Scaler component + · + Forwarder component + · + Terraform configuration + · + Monitoring +

+

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Architecture](#architecture) +* [Deployment](#deployment) +* [Configuration](#configuration) +* [Licensing](#licensing) +* [Contributing](#contributing) + +## Overview + +The Cloud Memorystore Cluster Autoscaler is a companion tool +that allows you to automatically increase or reduce the number of nodes/shards +in one or more Memorystore Cluster instances, based on their utilization. + +When you create a [Memorystore Cluster instance][memorystore-cluster-instance], +you choose the number of [shards/nodes][compute-capacity] that provide compute +resources for the instance. + +The Autoscaler monitors your instances and automatically adds or +removes capacity to ensure that the memory, CPU utilization, and other metrics +remain within recommend limits. + +If you would like to get started quickly with a test deployment of the Autoscaler, +you can deploy to [Cloud Run functions in a single project][single-project-deployment]. + +## Architecture + +![architecture-abstract](resources/architecture-abstract.png) + +The diagram above shows the high level components of the Autoscaler and the +interaction flow: + +1. The Autoscaler consists of two main decoupled components: + * [The Poller component][autoscaler-poller] + * [The Scaler component][autoscaler-scaler] + + These can be deployed to [Cloud Run functions][cloud-functions] and configured + so that the Autoscaler runs according to a user-defined schedule. + +2. At the specified time and frequency, the Poller component queries the + [Cloud Monitoring][cloud-monitoring] API to retrieve the utilization metrics + for each Memorystore Cluster instance. + +3. For each instance, the Poller component pushes one message to the Scaler + component. The payload contains the utilization metrics for the + specific Memorystore Cluster instance, and some of its corresponding configuration + parameters. + +4. Using the chosen [scaling method](src/scaler/README.md#scaling-methods), + the Scaler compares the cluster instance metrics against the recommended + thresholds, (plus or minus an [allowed margin](src/poller/README.md#margins)), + and determines if the instance should be scaled, and the number of shards/nodes + that it should be scaled to. If the configured cooldown period has passed, then + the Scaler component requests the cluster to scale out or in. + +Throughout the flow, the Autoscaler writes a step by step summary +of its recommendations and actions to [Cloud Logging][cloud-logging] for +tracking and auditing. + +## Deployment + +To deploy the Autoscaler, decide which of the following strategies +is best adjusted to fulfill your technical and operational needs: + +* [Deployment to Cloud Run functions](terraform/cloud-functions/README.md) +* [Deployment to Google Kubernetes Engine (GKE)](terraform/gke/README.md) + +In both of the above cases, the Google Cloud Platform resources are +deployed using Terraform. Please see the [Terraform instructions](terraform/README.md) +for more information on the deployment options available. + +You can find some recommendations for productionizing deployment of the +Autoscaler in the [Productionization section](terraform/README.md#productionization) +of the Terraform documentation. + +## Configuration + +The parameters for configuring the Autoscaler are identical regardless of the chosen +deployment type, but the mechanism for configuration differs slightly: + +In the case of the +[Cloud Run functions](terraform/cloud-functions/README.md#configuration) deployment, +the parameters are defined using the JSON payload of the PubSub message that +is published by the Cloud Scheduler job. + +In the case of the +[Kubernetes deployment](terraform/gke/README.md#configuration), the parameters +are defined using a [Kubernetes ConfigMap][configmap] that is loaded by the Cron +job. + +You can find the details about the parameters and their default values in the +[Poller component page][autoscaler-poller]. + +There is also a [browser-based configuration file editor and a command line +configuration file validator][configeditor]. + +## Licensing + +```lang-none +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +## Getting Support + +The Autoscaler project is based on open source contributions +(see [Contributing](README.md#contributing)). + +Please note that this is not an officially supported Google product. + +During the Preview period we recommend that you test in non-production +environments only. + +## Contributing + +* [Contributing guidelines][contributing-guidelines] +* [Code of conduct][code-of-conduct] + + + +[autoscaler-poller]: src/poller/README.md +[autoscaler-scaler]: src/scaler/README.md +[cloud-functions]: https://cloud.google.com/functions +[cloud-logging]: https://cloud.google.com/logging +[cloud-monitoring]: https://cloud.google.com/monitoring +[code-of-conduct]: code-of-conduct.md +[configeditor]: configeditor/README.md +[configmap]: https://kubernetes.io/docs/concepts/configuration/configmap +[compute-capacity]: https://cloud.google.com/memorystore/docs/cluster/cluster-node-specification +[contributing-guidelines]: contributing.md +[memorystore-cluster-instance]: https://cloud.google.com/memorystore/docs/cluster/memorystore-for-redis-cluster-overview +[single-project-deployment]: terraform/cloud-functions/per-project/README.md diff --git a/autoscaler-config.schema.json b/autoscaler-config.schema.json new file mode 100644 index 0000000..71ec889 --- /dev/null +++ b/autoscaler-config.schema.json @@ -0,0 +1,367 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/GoogleCloudPlatform/memorystore-cluster-autoscaler/autoscaler-config.schema.json", + "title": "Memorystore Cluster Autoscaler configuration", + "description": "JSON schema for the Cloud Memorystore autoscaler, specifying one or more Memorystore clusters to monitor and automatically scale.", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/memorystoreInstance" + }, + "$comment": "Any changes to this file also need to be reflected in src/poller/README.md, and in autoscaler-common/types.js.", + "$defs": { + "memorystoreInstance": { + "type": "object", + "title": "Memorystore Cluster", + "description": "Specification of a Cloud Memorystore cluster to be managed by the autoscaler.", + "additionalProperties": false, + "required": ["projectId", "regionId", "clusterId"], + "properties": { + "$comment": { + "type": "string" + }, + "projectId": { + "type": "string", + "minLength": 2, + "description": "Project ID of the Cloud Memorystore cluster to be monitored." + }, + "regionId": { + "type": "string", + "minLength": 2, + "description": "Region ID of the Cloud Memorystore cluster to be monitored." + }, + "clusterId": { + "type": "string", + "minLength": 2, + "description": "Instance ID of the Cloud Memorystore cluster to be monitored." + }, + "units": { + "enum": ["SHARDS"], + "description": "Specifies the units how the Cloud Memorystore cluster capacity will be measured.", + "default": "SHARDS" + }, + "minSize": { + "type": "number", + "minimum": 1, + "description": "Minimum number of Cloud Memorystore cluster `SHARDS` that the instance can be scaled IN to.", + "default": "3 SHARDS" + }, + "maxSize": { + "type": "number", + "minimum": 1, + "description": "Maximum number of Cloud Memorystore cluster `SHARDS` that the instance can be scaled OUT to.", + "default": "10 SHARDS" + }, + "scalingProfile": { + "type": "string", + "minLength": 2, + "description": "Scaling profile that should be used. See the [scaling profiles](https://github.com/GoogleCloudPlatform/memorystore-cluster-autoscaler/blob/main/src/scaler/README.md#scaling-profiles) for more information.", + "default": "CPU_AND_MEMORY" + }, + "scalingMethod": { + "type": "string", + "minLength": 2, + "description": "Scaling method that should be used. See the [scaling methods](https://github.com/GoogleCloudPlatform/memorystore-cluster-autoscaler/blob/main/src/scaler/README.md#scaling-methods) for more information.", + "default": "STEPWISE" + }, + "stepSize": { + "type": "number", + "minimum": 1, + "description": "Amount of capacity that should be added or removed when scaling with the STEPWISE method.", + "default": "1 SHARD" + }, + "scaleInLimit": { + "type": "number", + "minimum": 0, + "description": "Maximum number of shards that can be removed on a single step when scaling with the `LINEAR` method. If `undefined` or `0`, it will not limit the number of shards.", + "default": 0 + }, + "scaleOutLimit": { + "type": "number", + "minimum": 0, + "description": "Maximum number of shards that can be added on a single step when scaling with the `LINEAR` method. If `undefined` or `0`, it will not limit the number of shards.", + "default": 0 + }, + "minFreeMemoryPercent": { + "type": "number", + "minimum": 1, + "maximum": 100, + "description": "Percentage of total memory to maintain as safety (i.e. free, unused) headroom.", + "default": 30 + }, + "scaleOutCoolingMinutes": { + "type": "number", + "minimum": 1, + "description": "Minutes to wait after scaling IN or OUT before a scale OUT event can be processed.", + "default": 5 + }, + "scaleInCoolingMinutes": { + "type": "number", + "minimum": 1, + "description": "Minutes to wait after scaling IN or OUT before a scale IN event can be processed.", + "default": 30 + }, + "stateProjectId": { + "type": "string", + "minLength": 2, + "description": "The project ID where the Autoscaler state will be persisted.\nBy default it is persisted using Cloud Firestore in the same project as the Memorystore instance being scaled - see `stateDatabase`.", + "default": "${projectId}" + }, + "stateDatabase": { + "type": "object", + "description": "Object defining the database for managing the state of the Autoscaler.", + "default": "firestore", + "additionalProperties": false, + "properties": { + "name": { + "enum": ["firestore", "spanner"], + "description": "Type of the database for storing the persistent state of the Autoscaler.", + "default": "firestore" + }, + "instanceId": { + "type": "string", + "minLength": 2, + "description": "The instance id of Cloud Memorystore cluster in which you want to persist the state. Required if name=spanner." + }, + "databaseId": { + "type": "string", + "minLength": 2, + "description": "The instance id of Cloud Memorystore cluster in which you want to persist the state. Required if name=spanner." + } + } + }, + "scalerPubSubTopic": { + "type": "string", + "minLength": 2, + "pattern": "^projects/[^/]+/topics/[^/]+$", + "description": "PubSub topic (in the form `projects/${projectId}/topics/scaler-topic`) for the Poller function to publish messages for the Scaler function (Required for Cloud Run functions deployments)." + }, + "downstreamPubSubTopic": { + "type": "string", + "minLength": 2, + "pattern": "^projects/[^/]+/topics/[^/]+$", + "description": "Set this parameter to point to a pubsub topic (in the form `projects/${projectId}/topics/downstream-topic-name`) to make the Autoscaler publish events that can be consumed by downstream applications.\nSee [Downstream messaging](https://github.com/cloudspannerecosystem/autoscaler/blob/main/src/scaler/README.md#downstream-messaging) for more information." + }, + "scalingRules": { + "type": "array", + "minItems": 1, + "description": "Array containing rules for custom scaling", + "items": { + "$ref": "#/$defs/scalingRule" + } + } + } + }, + "scalingRule": { + "type": "object", + "title": "Scaling Rule", + "description": "A Scaling Rule contains a set of conditions and a single event. When the engine is run, each rule condition is evaluated. If the results are truthy, the rule's event is triggered.", + "required": ["conditions", "event"], + "properties": { + "name": { + "$id": "#/defs/scalingRule/properties/name", + "anyOf": [ + { + "type": "string" + } + ], + "title": "The Name Schema", + "description": "A way of naming your rules, allowing them to be easily identifiable in Rule Results. Note that the name need not be unique, and that it has no impact on execution of the rule.", + "default": {}, + "examples": ["My Rule Name"] + }, + "priority": { + "$id": "#/defs/scalingRule/properties/priority", + "anyOf": [ + { + "type": "integer", + "minimum": 1 + } + ], + "title": "Priority", + "description": "Dictates when rule should be run, relative to other rules. Higher priority rules are run before lower priority rules. Rules with the same priority are run in parallel. Priority must be a positive, non-zero integer.", + "default": 1, + "examples": [1] + }, + "conditions": { + "$ref": "#/defs/scalingRule/definitions/conditions" + }, + "event": { + "$id": "#/defs/scalingRule/properties/event", + "type": "object", + "title": "The Event Schema", + "description": "Sets the on('success') and on('failure') event argument emitted whenever the rule passes. Event objects must have a type property, and an optional params property.", + "default": {}, + "required": ["type", "params"], + "properties": { + "type": { + "$id": "#/defs/scalingRule/properties/event/properties/type", + "enum": ["IN", "OUT"], + "title": "Event Type", + "description": "A string representing the scaling event this describes.", + "default": "" + }, + "params": { + "$id": "#/defs/scalingRule/properties/event/properties/params", + "type": "object", + "title": "Event Params", + "description": "Params to make available to the event processor.", + "required": ["message", "scalingMetrics"], + "examples": [ + { + "message": "Low average memory utilization", + "scalingMetrics": "['memory_average_utilization']" + } + ] + } + } + } + }, + "definitions": { + "conditions": { + "$id": "#/defs/scalingRule/definitions/conditions", + "type": "object", + "title": "Conditions", + "description": "Rule conditions are a combination of facts, operators, and values that determine whether the rule is a success or a failure. The simplest form of a condition consists of a fact, an operator, and a value. When the engine runs, the operator is used to compare the fact against the value. Each rule's conditions must have either an all or an any operator at its root, containing an array of conditions. The all operator specifies that all conditions contained within must be truthy for the rule to be considered a success. The any operator only requires one condition to be truthy for the rule to succeed.", + "default": {}, + "examples": [ + { + "all": [ + { + "value": true, + "fact": "displayMessage", + "operator": "equal" + } + ] + } + ], + "oneOf": [ + { + "required": ["any"] + }, + { + "required": ["all"] + } + ], + "properties": { + "any": { + "$ref": "#/defs/scalingRule/definitions/conditions/conditionArray" + }, + "all": { + "$ref": "#/defs/scalingRule/definitions/conditions/conditionArray" + } + } + }, + "conditionArray": { + "$id": "#/defs/scalingRule/definitions/conditions/conditionArray", + "type": "array", + "title": "Condition Array", + "description": "An array of conditions with a possible recursive inclusion of another condition array.", + "default": [], + "items": { + "anyOf": [ + { + "$ref": "#/defs/scalingRule/definitions/conditions" + }, + { + "$ref": "#/defs/scalingRule/definitions/condition" + } + ] + } + }, + "condition": { + "$id": "#/defs/scalingRule/definitions/condition", + "type": "object", + "title": "Condition", + "description": "Rule conditions are a combination of facts, operators, and values that determine whether the rule is a success or a failure. The simplest form of a condition consists of a fact, an operator, and a value. When the engine runs, the operator is used to compare the fact against the value. Sometimes facts require additional input to perform calculations. For this, the params property is passed as an argument to the fact handler. params essentially functions as fact arguments, enabling fact handlers to be more generic and reusable.", + "default": { + "fact": "my-fact", + "operator": "lessThanInclusive", + "value": 1 + }, + "required": ["fact", "operator", "value"], + "properties": { + "fact": { + "type": "string", + "title": "Fact", + "description": "Facts are methods or constants registered with the engine prior to runtime and referenced within rule conditions. Each fact method should be a pure function that may return a either computed value, or promise that resolves to a computed value. As rule conditions are evaluated during runtime, they retrieve fact values dynamically and use the condition operator to compare the fact result with the condition value.", + "default": "" + }, + "operator": { + "type": "string", + "anyOf": [ + { + "const": "equal", + "title": "fact must equal value" + }, + { + "const": "notEqual", + "title": "fact must not equal value" + }, + { + "const": "lessThan", + "title": "fact must be less than value" + }, + { + "const": "lessThanInclusive", + "title": "fact must be less than or equal to value" + }, + { + "const": "greaterThan", + "title": "fact must be greater than value" + }, + { + "const": "greaterThanInclusive", + "title": "fact must be greater than or equal to value" + }, + { + "const": "in", + "title": "fact must be included in value (an array)" + }, + { + "const": "notIn", + "title": "fact must not be included in value (an array)" + }, + { + "const": "contains", + "title": "fact (an array) must include value" + }, + { + "const": "doesNotContain", + "title": "fact (an array) must not include value" + } + ], + "title": "Operator", + "description": "The operator compares the value returned by the fact to what is stored in the value property. If the result is truthy, the condition passes.", + "default": "", + "examples": ["equal"] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object" + }, + { + "type": "array" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "title": "Value", + "description": "The value the fact should be compared to.", + "default": 0, + "examples": [40] + } + } + } + } + } + } +} diff --git a/cloudbuild-unified.yaml b/cloudbuild-unified.yaml new file mode 100644 index 0000000..3eb3a2a --- /dev/null +++ b/cloudbuild-unified.yaml @@ -0,0 +1,28 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +steps: + - name: "gcr.io/cloud-builders/docker" + args: + [ + "build", + "--tag=$LOCATION-docker.pkg.dev/$PROJECT_ID/memorystore-cluster-autoscaler/scaler", + "-f", + "Dockerfile-unified", + ".", + ] +images: + ["$LOCATION-docker.pkg.dev/$PROJECT_ID/memorystore-cluster-autoscaler/scaler"] +options: + logging: "CLOUD_LOGGING_ONLY" diff --git a/code-of-conduct.md b/code-of-conduct.md new file mode 100644 index 0000000..f8b12cb --- /dev/null +++ b/code-of-conduct.md @@ -0,0 +1,63 @@ +# Google Open Source Community Guidelines + +At Google, we recognize and celebrate the creativity and collaboration of open +source contributors and the diversity of skills, experiences, cultures, and +opinions they bring to the projects and communities they participate in. + +Every one of Google's open source projects and communities are inclusive +environments, based on treating all individuals respectfully, regardless of +gender identity and expression, sexual orientation, disabilities, +neurodiversity, physical appearance, body size, ethnicity, nationality, race, +age, religion, or similar personal characteristic. + +We value diverse opinions, but we value respectful behavior more. + +Respectful behavior includes: + +* Being considerate, kind, constructive, and helpful. +* Not engaging in demeaning, discriminatory, harassing, hateful, sexualized, or + physically threatening behavior, speech, and imagery. +* Not engaging in unwanted physical contact. + +Some Google open source projects [may adopt][] an explicit project code of +conduct, which may have additional detailed expectations for participants. Most +of those projects will use our [modified Contributor Covenant][]. + +[may adopt]: https://opensource.google/docs/releasing/preparing/#conduct +[modified Contributor Covenant]: https://opensource.google/docs/releasing/template/CODE_OF_CONDUCT/ + +## Resolve peacefully + +We do not believe that all conflict is necessarily bad; healthy debate and +disagreement often yields positive results. However, it is never okay to be +disrespectful. + +If you see someone behaving disrespectfully, you are encouraged to address the +behavior directly with those involved. Many issues can be resolved quickly and +easily, and this gives people more control over the outcome of their dispute. +If you are unable to resolve the matter for any reason, or if the behavior is +threatening or harassing, report it. We are dedicated to providing an +environment where participants feel welcome and safe. + +## Reporting problems + +Some Google open source projects may adopt a project-specific code of conduct. +In those cases, a Google employee will be identified as the Project Steward, +who will receive and handle reports of code of conduct violations. In the event +that a project hasn’t identified a Project Steward, you can report problems by +emailing opensource@google.com. + +We will investigate every complaint, but you may not receive a direct response. +We will use our discretion in determining when and how to follow up on reported +incidents, which may range from not taking action to permanent expulsion from +the project and project-sponsored spaces. We will notify the accused of the +report and provide them an opportunity to discuss it before any action is +taken. The identity of the reporter will be omitted from the details of the +report supplied to the accused. In potentially harmful situations, such as +ongoing harassment or threats to anyone's safety, we may take action without +notice. + +*This document was adapted from the [IndieWeb Code of Conduct][] and can also +be found at .* + +[IndieWeb Code of Conduct]: https://indieweb.org/code-of-conduct diff --git a/configeditor/README.md b/configeditor/README.md new file mode 100644 index 0000000..47d3cc0 --- /dev/null +++ b/configeditor/README.md @@ -0,0 +1,52 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ Validating editor for Autoscaler configuration. +
+ Home + · + Scaler component + · + Poller component + · + Forwarder component + · + Terraform configuration + · + Monitoring +

+ +## Overview + +This directory contains a simple web-based autoscaler config file editor that +validates that the JSON config is correct - both for JSON syntax errors and that +the config has the correct set of parameters and values. + +For GKE configurations, a YAML ConfigMap equivalent is displayed below. + +While directly editing the YAML configMap for GKE is not supported in this +editor, you can paste the configmap into the JSON editor, and it will be +converted to JSON for editing and validation, with the equivalent YAML shown +below. + +## Usage + +Build the editor and start the HTTP server on port `8080`: + +```sh +npm run start-configeditor-server -- --port 8080 +``` + +Then browse to `http://127.0.0.1:8080/` + +## Command line config validation + +The JSON and YAML configurations can also be validated using the command line: + +```sh +npm install +npm run validate-config-file -- path/to/config_file +``` diff --git a/configeditor/build-configeditor.sh b/configeditor/build-configeditor.sh new file mode 100755 index 0000000..3b54dc0 --- /dev/null +++ b/configeditor/build-configeditor.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# +set -e + +SCRIPTDIR=$(dirname "$0") +cd "$SCRIPTDIR" + +npm install --quiet +mkdir -p build/vanilla-jsoneditor +JSONEDITOR_JS=build/vanilla-jsoneditor/standalone.js +# renovate: datasource=npm packageName=vanilla-jsoneditor +JSONEDITOR_VERSION=0.23.8 +JSONEDITOR_JS_URL="https://cdn.jsdelivr.net/npm/vanilla-jsoneditor@${JSONEDITOR_VERSION}/standalone.js" +# sha256sum of file at $JSONEDITOR_JS_URL +JSONEDITOR_JS_HASH="91886177f9cab8541f73e02aa195fcea27089acfdf5be48b20ed60f65543f6cf" +if [[ ! -e "$JSONEDITOR_JS" ]]; then + echo "Downloading npm/vanilla-jsoneditor@${JSONEDITOR_VERSION}/standalone.js" + curl -s -o "$JSONEDITOR_JS" "$JSONEDITOR_JS_URL" + + # Check sha256sum hash + if ! echo "$JSONEDITOR_JS_HASH $JSONEDITOR_JS" \ + | sha256sum --check --quiet ; then + echo "" + echo "FAILED $JSONEDITOR_JS Checksum does not match expected value" + rm "$JSONEDITOR_JS" + exit 1 + fi +fi + +cp -r ../node_modules/js-yaml ../autoscaler-config.schema.json build/ + +[[ "$1" == "--quiet" ]] || cat < + + + + Autoscaler config file editor + + + + + + +

JSON Autoscaler config file editor

+

Copy/Paste your YAML or JSON autoscaler config in the editor below.

+

The configuration will automatically be validated and any errors shown

+
+ Loading... +
+ If this Loading message does not disappear, check that you have run + ./build-configeditor.sh +
+
+
+

Equivalent GKE configmap YAML

+ +

+ Powered by jsoneditoronline.org +

+ + diff --git a/configeditor/index.mjs b/configeditor/index.mjs new file mode 100644 index 0000000..e3dbccd --- /dev/null +++ b/configeditor/index.mjs @@ -0,0 +1,179 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Support scripts for browser-based config editor. + */ + +// eslint-disable-next-line +import { + JSONEditor, + createAjvValidator, + renderJSONSchemaEnum, + renderValue, +} from "./build/vanilla-jsoneditor/standalone.js"; +import schema from "./build/autoscaler-config.schema.json" with { type: "json" }; +import * as JsYaml from "./build/js-yaml/dist/js-yaml.mjs"; + +/** @typedef {import("vanilla-jsoneditor").Content} Content */ + +/** @type {JSONEditor} */ +let editor; + +/** + * Checks if the JSON is valid, and if so, copy it to the YAML textarea. + * + * If it is not, but _is_ valid YAML, convert it to JSON and update both the + * JSON editor and the YAML textarea. + * + * @param {Content} content + * @param {Content} previousContent + * @param {{ + * contentErrors: import("vanilla-jsoneditor").ContentErrors | null, + * patchResult: import("vanilla-jsoneditor").JSONPatchResult | null + * }} changeStatus + */ +function jsonEditorChangeHandler(newcontent, previousContent, changeStatus) { + const yamlTextarea = document.getElementById("yamlequivalent"); + + if (changeStatus?.contentErrors?.parseError) { + console.log( + "jsonEditorChangeHandler - got JSON parsing errors %o", + changeStatus.contentErrors, + ); + if (newcontent.text?.search("\nkind: ConfigMap\n") >= 0) { + // Check if it is valid YAML text to see if we need to convert it back + // to JSON. + try { + const configMap = JsYaml.load(newcontent.text); + if ( + configMap && + configMap.kind === "ConfigMap" && + configMap.data && + Object.values(configMap.data)[0] + ) { + // The autoscaler ConfigMap data is YAML stored as text in a YAML, + // so we need to re-parse the data object. + const configMapData = JsYaml.load(Object.values(configMap.data)[0]); + console.log("got yaml configMap data object: %o", configMapData); + + // Asynchronously update the content with the parsed configmap data. + // This is because JSON editor likes to finish the onchange before + // anything else happens! + setTimeout(() => { + /** @type {Content} */ + const content = { json: configMapData }; + editor.updateProps({ + content, + mode: "text", + selection: null, + }); + editor.refresh(); + // Trigger refresh of YAML textarea. + updateYamlWithJsonContent(content); + }, 100); + return; + } + } catch (e) { + console.log("not valid yaml " + e); + } + } + // Some other unparsable JSON. + yamlTextarea.setAttribute("disabled", "true"); + } else { + // Got valid JSON, even if it might not be valid Autoscaler config, we + // update the YAML version. + updateYamlWithJsonContent(newcontent); + } +} + +/** + * Converts the content from JSONEditor to YAML and stores it in the YAML + * textarea. + * + * @param {Content} content + */ +function updateYamlWithJsonContent(content) { + const yamlTextarea = document.getElementById("yamlequivalent"); + yamlTextarea.removeAttribute("disabled"); + const json = content.text ? JSON.parse(content.text) : content.json; + + const configMap = { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: "autoscaler-config", + namespace: "memorystore-cluster-autoscaler", + }, + data: { + // Autoscaler configmap.data is YAML as text. + "autoscaler-config.yaml": JsYaml.dump(json, { lineWidth: -1 }), + }, + }; + yamlTextarea.value = JsYaml.dump(configMap, { lineWidth: -1 }); +} + +/** + * Handles addling rendering of Schema enums in JSONEditor. + * + * @param {import("vanilla-jsoneditor").RenderValueProps} props + * @return {import("vanilla-jsoneditor").RenderValueComponentDescription[]} + */ +function onRenderValue(props) { + return renderJSONSchemaEnum(props, schema) || renderValue(props); +} + +/** @type {Content} */ +const EXAMPLE_CONFIG = { + json: [ + { + $comment: "Sample memorystore autoscaler config", + projectId: "memorystore-cluster-project-id", + regionId: "us-central1", + clusterId: "autoscaler-target-memorystore-cluster", + scalingMethod: "STEPWISE", + units: "SHARDS", + maxSize: 10, + minSize: 3, + stepSize: 2, + scalerPubSubTopic: + "projects/memorystore-cluster-project-id/topics/scaler-topic", + stateDatabase: { + name: "firestore", + }, + }, + ], +}; + +/** Handles DOMContentLoaded event. */ +function onDOMContentLoaded() { + editor = new JSONEditor({ + target: document.getElementById("jsoneditor"), + props: { + content: EXAMPLE_CONFIG, + mode: "text", + schema, + indentation: 2, + validator: createAjvValidator({ schema }), + onChange: jsonEditorChangeHandler, + onRenderValue, + }, + }); + updateYamlWithJsonContent(EXAMPLE_CONFIG); + document.getElementById("loading").style.display = "none"; +} + +document.addEventListener("DOMContentLoaded", onDOMContentLoaded, false); diff --git a/contributing.md b/contributing.md new file mode 100644 index 0000000..654a071 --- /dev/null +++ b/contributing.md @@ -0,0 +1,28 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution; +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Community Guidelines + +This project follows [Google's Open Source Community +Guidelines](https://opensource.google/conduct/). diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..e62cbe2 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES6", + "checkJs": true, + "allowJs": true, + "noEmit": true, + "strict": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/kubernetes/unified/autoscaler-config/autoscaler-config.yaml.template b/kubernetes/unified/autoscaler-config/autoscaler-config.yaml.template new file mode 100644 index 0000000..576f6c2 --- /dev/null +++ b/kubernetes/unified/autoscaler-config/autoscaler-config.yaml.template @@ -0,0 +1,35 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: autoscaler-config + namespace: memorystore-cluster-autoscaler +data: + autoscaler-config.yaml: | + --- + - projectId: ${PROJECT_ID} + regionId: ${REGION} + clusterId: autoscaler-target-memorystore-cluster + # Delete this stanza if using Firestore for state + stateDatabase: + name: spanner + instanceId: memorystore-autoscaler-state + databaseId: memorystore-autoscaler-state + scalingProfile: CPU_AND_MEMORY + scalingMethod: STEPWISE + units: SHARDS + minSize: 3 + maxSize: 10 diff --git a/kubernetes/unified/autoscaler-config/otel-collector.yaml.template b/kubernetes/unified/autoscaler-config/otel-collector.yaml.template new file mode 100644 index 0000000..6d5d263 --- /dev/null +++ b/kubernetes/unified/autoscaler-config/otel-collector.yaml.template @@ -0,0 +1,70 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: otel-config + namespace: memorystore-cluster-autoscaler +data: + config.yaml: | + --- + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + + processors: + resourcedetection: + detectors: [gcp] + timeout: 10s + override: false + + batch: + # batch metrics before sending to reduce API usage + send_batch_max_size: 200 + send_batch_size: 200 + # NOTE: If batching timeout is greater than the frequency of which + # metrics from long running processes are pushed to the OTEL collector, + # Duplicate TimeSeries errors can occur as muliple metrics pushes + # are exported. + # NOTE: If using Google Cloud Monitoring exporter, then the minimum + # batching time is 5 seconds. + timeout: 10s + + memory_limiter: + # drop metrics if memory usage gets too high + check_interval: 1s + limit_percentage: 65 + spike_limit_percentage: 20 + + exporters: + googlecloud: + timeout: 45s + # Enable the debug exporter, and add to expoters pipeline to see the metrics being delivered + # debug: + # verbosity: detailed + + service: + pipelines: + metrics: + receivers: [otlp] + processors: [resourcedetection, batch, memory_limiter] + # If using the debug exporter, add it to the following list + exporters: [googlecloud] + telemetry: + logs: + # Change log level from "info" to "debug" to view detailed logs + level: "info" diff --git a/kubernetes/unified/autoscaler-pkg/Kptfile b/kubernetes/unified/autoscaler-pkg/Kptfile new file mode 100644 index 0000000..315f4ef --- /dev/null +++ b/kubernetes/unified/autoscaler-pkg/Kptfile @@ -0,0 +1,21 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: autoscaler-pkg + annotations: + config.kubernetes.io/local-config: "true" +info: + description: Config for Memorystore Cluster autoscaler diff --git a/kubernetes/unified/autoscaler-pkg/README.md b/kubernetes/unified/autoscaler-pkg/README.md new file mode 100644 index 0000000..ca0f3e8 --- /dev/null +++ b/kubernetes/unified/autoscaler-pkg/README.md @@ -0,0 +1,16 @@ +# autoscaler-pkg + +## Description + +Config for Memorystore Cluster Autoscaler + +### View package content + +`kpt pkg tree autoscaler-pkg` +[Details](https://kpt.dev/reference/cli/pkg/tree/) + +## Installation + +See [documentation][docs] for installation and configuration instructions. + +[docs]: ../../../terraform/gke/README.md diff --git a/kubernetes/unified/autoscaler-pkg/networkpolicy.yaml b/kubernetes/unified/autoscaler-pkg/networkpolicy.yaml new file mode 100644 index 0000000..c705889 --- /dev/null +++ b/kubernetes/unified/autoscaler-pkg/networkpolicy.yaml @@ -0,0 +1,43 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: memorystore-cluster-autoscaler # kpt-set: ${namespace} +spec: + podSelector: {} + policyTypes: + - Ingress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-otel-submitter-to-collector + namespace: memorystore-cluster-autoscaler # kpt-set: ${namespace} +spec: + podSelector: + matchLabels: + app: otel-collector + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + otel-submitter: "true" + ports: + - protocol: TCP + port: 4317 diff --git a/kubernetes/unified/autoscaler-pkg/otel-collector/Kptfile b/kubernetes/unified/autoscaler-pkg/otel-collector/Kptfile new file mode 100644 index 0000000..1e2a32e --- /dev/null +++ b/kubernetes/unified/autoscaler-pkg/otel-collector/Kptfile @@ -0,0 +1,21 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: otel-collector + annotations: + config.kubernetes.io/local-config: "true" +info: + description: Config for OpenTelemetry Collector component of Memorystore Cluster autoscaler diff --git a/kubernetes/unified/autoscaler-pkg/otel-collector/README.md b/kubernetes/unified/autoscaler-pkg/otel-collector/README.md new file mode 100644 index 0000000..436b824 --- /dev/null +++ b/kubernetes/unified/autoscaler-pkg/otel-collector/README.md @@ -0,0 +1,17 @@ +# Open Telemtry Collector + +## Description + +Pod config for [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) +component of Memorystore Cluster Autoscaler + +### View package content + +`kpt pkg tree otel-collector` +[Details](https://kpt.dev/reference/cli/pkg/tree/) + +## Installation + +See [documentation][docs] for installation and configuration instructions. + +[docs]: ../../../../terraform/gke/unified/README.md diff --git a/kubernetes/unified/autoscaler-pkg/otel-collector/otel-collector.yaml b/kubernetes/unified/autoscaler-pkg/otel-collector/otel-collector.yaml new file mode 100644 index 0000000..5f557b2 --- /dev/null +++ b/kubernetes/unified/autoscaler-pkg/otel-collector/otel-collector.yaml @@ -0,0 +1,75 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: otel-collector + namespace: memorystore-cluster-autoscaler # kpt-set: ${namespace} + labels: + app: otel-collector +spec: + replicas: 1 + selector: + matchLabels: + app: otel-collector + template: + metadata: + labels: + app: otel-collector + spec: + containers: + - name: otel-collector + image: otel/opentelemetry-collector-contrib:0.93.0 + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "512Mi" + args: + - --config + - /etc/otel/config.yaml + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - all + volumeMounts: + - mountPath: /etc/otel/ + name: otel-config + volumes: + - name: otel-config + configMap: + name: otel-config + nodeSelector: + iam.gke.io/gke-metadata-server-enabled: "true" + serviceAccountName: otel-collector-sa + automountServiceAccountToken: true +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-collector + namespace: memorystore-cluster-autoscaler # kpt-set: ${namespace} +spec: + type: ClusterIP + selector: + app: otel-collector + ports: + - protocol: TCP + port: 4317 + targetPort: 4317 diff --git a/kubernetes/unified/autoscaler-pkg/scaler/Kptfile b/kubernetes/unified/autoscaler-pkg/scaler/Kptfile new file mode 100644 index 0000000..2344dfa --- /dev/null +++ b/kubernetes/unified/autoscaler-pkg/scaler/Kptfile @@ -0,0 +1,21 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: scaler + annotations: + config.kubernetes.io/local-config: "true" +info: + description: Config for Memorystore Cluster autoscaler diff --git a/kubernetes/unified/autoscaler-pkg/scaler/README.md b/kubernetes/unified/autoscaler-pkg/scaler/README.md new file mode 100644 index 0000000..515d08d --- /dev/null +++ b/kubernetes/unified/autoscaler-pkg/scaler/README.md @@ -0,0 +1,16 @@ +# scaler + +## Description + +Config for Memorystore Cluster Autoscaler + +### View package content + +`kpt pkg tree scaler` +[Details](https://kpt.dev/reference/cli/pkg/tree/) + +## Installation + +See [documentation][docs] for installation and configuration instructions. + +[docs]: ../../../../terraform/gke/unified/README.md diff --git a/kubernetes/unified/autoscaler-pkg/scaler/scaler.yaml b/kubernetes/unified/autoscaler-pkg/scaler/scaler.yaml new file mode 100644 index 0000000..d11079e --- /dev/null +++ b/kubernetes/unified/autoscaler-pkg/scaler/scaler.yaml @@ -0,0 +1,65 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: scaler + namespace: memorystore-cluster-autoscaler # kpt-set: ${namespace} +spec: + concurrencyPolicy: Forbid + schedule: "*/2 * * * *" + jobTemplate: + spec: + template: + metadata: + labels: + app: scaler + otel-submitter: "true" + spec: + containers: + - name: scaler + image: scaler-image # kpt-set: ${scaler_image} + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "512Mi" + env: + - name: K8S_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: OTEL_COLLECTOR_URL + value: "http://otel-collector:4317/" + - name: OTEL_IS_LONG_RUNNING_PROCESS + value: "false" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - all + volumeMounts: + - name: config-volume + mountPath: /etc/autoscaler-config + volumes: + - name: config-volume + configMap: + name: autoscaler-config + nodeSelector: + iam.gke.io/gke-metadata-server-enabled: "true" + restartPolicy: Never + serviceAccountName: scaler-sa diff --git a/markdown-link-checker.json b/markdown-link-checker.json new file mode 100644 index 0000000..1f1e506 --- /dev/null +++ b/markdown-link-checker.json @@ -0,0 +1,16 @@ +{ + "ignorePatterns": [ + { + "pattern": "^https://console.cloud.google.com/" + }, + { + "pattern": "^https://example.org" + } + ], + "replacementPatterns": [ + { + "pattern": "^([./].*)/(#.*)?$", + "replacement": "$1/__LOCAL_URL_MUST_END_IN_FILENAME_NOT_RAW_PATH__$2" + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..20f2a15 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9616 @@ +{ + "name": "memorystore-cluster-autoscaler", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "memorystore-cluster-autoscaler", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/firestore": "^7.10.0", + "@google-cloud/functions-framework": "^3.4.2", + "@google-cloud/monitoring": "^4.1.0", + "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.19.0", + "@google-cloud/pubsub": "^4.7.2", + "@google-cloud/redis-cluster": "^0.5.0", + "@google-cloud/spanner": "^7.14.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.53.0", + "@opentelemetry/sdk-metrics": "^1.26.0", + "@opentelemetry/sdk-node": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "ajv": "^8.17.1", + "axios": "^1.7.7", + "eventid": "^2.0.1", + "express": "^4.21.0", + "googleapis": "^144.0.0", + "js-yaml": "^4.1.0", + "json-rules-engine": "^6.5.0", + "lodash": "^4.17.21", + "pino": "^9.4.0", + "sanitize-filename": "^1.6.3" + }, + "devDependencies": { + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", + "@sinonjs/referee": "^11.0.1", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4.17.7", + "@types/lodash.unionby": "^4.8.9", + "@types/mocha": "^10.0.8", + "@types/node": "^20.11.30", + "@types/rewire": "^2.5.30", + "@types/sinon": "17.0.3", + "eslint": "^8.57.1", + "eslint-config-google": "^0.14.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "husky": "^9.1.6", + "lodash.unionby": "^4.8.0", + "markdown-link-check": "^3.12.2", + "markdownlint-cli": "^0.41.0", + "mocha": "^10.7.3", + "nyc": "^17.0.0", + "prettier": "^3.3.3", + "rewire": "^7.0.0", + "should": "^13.2.3", + "sinon": "^19.0.2", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=18.0.0 || >=20.0.0", + "npm": ">=10.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "dependencies": { + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz", + "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", + "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.6", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz", + "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.24.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", + "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.6", + "@babel/parser": "^7.25.6", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/@babel/types": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@commitlint/cli": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.5.0.tgz", + "integrity": "sha512-gaGqSliGwB86MDmAAKAtV9SV1SHdmN8pnGq4EJU4+hLisQ7IFfx4jvU4s+pk6tl0+9bv6yT+CaZkufOinkSJIQ==", + "dev": true, + "dependencies": { + "@commitlint/format": "^19.5.0", + "@commitlint/lint": "^19.5.0", + "@commitlint/load": "^19.5.0", + "@commitlint/read": "^19.5.0", + "@commitlint/types": "^19.5.0", + "tinyexec": "^0.3.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.5.0.tgz", + "integrity": "sha512-OBhdtJyHNPryZKg0fFpZNOBM1ZDbntMvqMuSmpfyP86XSfwzGw4CaoYRG4RutUPg0BTK07VMRIkNJT6wi2zthg==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.5.0", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.5.0.tgz", + "integrity": "sha512-CHtj92H5rdhKt17RmgALhfQt95VayrUo2tSqY9g2w+laAXyk7K/Ef6uPm9tn5qSIwSmrLjKaXK9eiNuxmQrDBw==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.5.0", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.5.0.tgz", + "integrity": "sha512-Kv0pYZeMrdg48bHFEU5KKcccRfKmISSm9MvgIgkpI6m+ohFTB55qZlBW6eYqh/XDfRuIO0x4zSmvBjmOwWTwkg==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.5.0", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.5.0.tgz", + "integrity": "sha512-aqyGgytXhl2ejlk+/rfgtwpPexYyri4t8/n4ku6rRJoRhGZpLFMqrZ+YaubeGysCP6oz4mMA34YSTaSOKEeNrg==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.5.0.tgz", + "integrity": "sha512-yNy088miE52stCI3dhG/vvxFo9e4jFkU1Mj3xECfzp/bIS/JUay4491huAlVcffOoMK1cd296q0W92NlER6r3A==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.5.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.5.0.tgz", + "integrity": "sha512-0XQ7Llsf9iL/ANtwyZ6G0NGp5Y3EQ8eDQSxv/SRcfJ0awlBY4tHFAvwWbw66FVUaWICH7iE5en+FD9TQsokZ5w==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.5.0", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.5.0.tgz", + "integrity": "sha512-cAAQwJcRtiBxQWO0eprrAbOurtJz8U6MgYqLz+p9kLElirzSCc0vGMcyCaA1O7AqBuxo11l1XsY3FhOFowLAAg==", + "dev": true, + "dependencies": { + "@commitlint/is-ignored": "^19.5.0", + "@commitlint/parse": "^19.5.0", + "@commitlint/rules": "^19.5.0", + "@commitlint/types": "^19.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.5.0.tgz", + "integrity": "sha512-INOUhkL/qaKqwcTUvCE8iIUf5XHsEPCLY9looJ/ipzi7jtGhgmtH7OOFiNvwYgH7mA8osUWOUDV8t4E2HAi4xA==", + "dev": true, + "dependencies": { + "@commitlint/config-validator": "^19.5.0", + "@commitlint/execute-rule": "^19.5.0", + "@commitlint/resolve-extends": "^19.5.0", + "@commitlint/types": "^19.5.0", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^5.0.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.5.0.tgz", + "integrity": "sha512-R7AM4YnbxN1Joj1tMfCyBryOC5aNJBdxadTZkuqtWi3Xj0kMdutq16XQwuoGbIzL2Pk62TALV1fZDCv36+JhTQ==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.5.0.tgz", + "integrity": "sha512-cZ/IxfAlfWYhAQV0TwcbdR1Oc0/r0Ik1GEessDJ3Lbuma/MRO8FRQX76eurcXtmhJC//rj52ZSZuXUg0oIX0Fw==", + "dev": true, + "dependencies": { + "@commitlint/types": "^19.5.0", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.5.0.tgz", + "integrity": "sha512-TjS3HLPsLsxFPQj6jou8/CZFAmOP2y+6V4PGYt3ihbQKTY1Jnv0QG28WRKl/d1ha6zLODPZqsxLEov52dhR9BQ==", + "dev": true, + "dependencies": { + "@commitlint/top-level": "^19.5.0", + "@commitlint/types": "^19.5.0", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8", + "tinyexec": "^0.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.5.0.tgz", + "integrity": "sha512-CU/GscZhCUsJwcKTJS9Ndh3AKGZTNFIOoQB2n8CmFnizE0VnEuJoum+COW+C1lNABEeqk6ssfc1Kkalm4bDklA==", + "dev": true, + "dependencies": { + "@commitlint/config-validator": "^19.5.0", + "@commitlint/types": "^19.5.0", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.5.0.tgz", + "integrity": "sha512-hDW5TPyf/h1/EufSHEKSp6Hs+YVsDMHazfJ2azIk9tHPXS6UqSz1dIRs1gpqS3eMXgtkT7JH6TW4IShdqOwhAw==", + "dev": true, + "dependencies": { + "@commitlint/ensure": "^19.5.0", + "@commitlint/message": "^19.5.0", + "@commitlint/to-lines": "^19.5.0", + "@commitlint/types": "^19.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.5.0.tgz", + "integrity": "sha512-R772oj3NHPkodOSRZ9bBVNq224DOxQtNef5Pl8l2M8ZnkkzQfeSTr4uxawV2Sd3ui05dUVzvLNnzenDBO1KBeQ==", + "dev": true, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.5.0.tgz", + "integrity": "sha512-IP1YLmGAk0yWrImPRRc578I3dDUI5A2UBJx9FbSOjxe9sTlzFiwVJ+zeMLgAtHMtGZsC8LUnzmW1qRemkFU4ng==", + "dev": true, + "dependencies": { + "find-up": "^7.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "19.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.5.0.tgz", + "integrity": "sha512-DSHae2obMSMkAtTBSOulg5X7/z+rGLxcXQIkg3OmWvY6wifojge5uVMydfhUvs7yQj+V7jNmRZ2Xzl8GJyqRgg==", + "dev": true, + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.1.tgz", + "integrity": "sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@google-cloud/common": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz", + "integrity": "sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==", + "dependencies": { + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "arrify": "^2.0.1", + "duplexify": "^4.1.1", + "extend": "^3.0.2", + "google-auth-library": "^9.0.0", + "html-entities": "^2.5.2", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.10.0.tgz", + "integrity": "sha512-VFNhdHvfnmqcHHs6YhmSNHHxQqaaD64GwiL0c+e1qz85S8SWZPC2XFRf8p9yHRTF40Kow424s1KBU9f0fdQa+Q==", + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/functions-framework": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@google-cloud/functions-framework/-/functions-framework-3.4.2.tgz", + "integrity": "sha512-yJcxfVgjLoKFO3p6Wy6Fc+Gi6l3PFSwJg4m0mjebx/UHdLeXLYYxgKMP8RCODaApXEWXbSITIjXO0m5kSv2Ilw==", + "dependencies": { + "@types/express": "4.17.21", + "body-parser": "^1.18.3", + "cloudevents": "^8.0.0", + "express": "^4.16.4", + "minimist": "^1.2.7", + "on-finished": "^2.3.0", + "read-pkg-up": "^7.0.1", + "semver": "^7.3.5" + }, + "bin": { + "functions-framework": "build/src/main.js", + "functions-framework-nodejs": "build/src/main.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@google-cloud/monitoring": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@google-cloud/monitoring/-/monitoring-4.1.0.tgz", + "integrity": "sha512-LNEHl4bu4HrCS17BMABeV9C+BFupqW02Pgpeu2mncHYkvQdYMr/huseqKhO/RmPqzEkU11fApysRPpv6SWyZRQ==", + "dependencies": { + "google-gax": "^4.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/opentelemetry-cloud-monitoring-exporter": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-cloud-monitoring-exporter/-/opentelemetry-cloud-monitoring-exporter-0.19.0.tgz", + "integrity": "sha512-5SOPXwC6RET4ZvXxw5D97dp8fWpqWEunHrzrUUGXhG4UAeedQe1KvYV8CK+fnaAbN2l2ha6QDYspT6z40TVY0g==", + "dependencies": { + "@google-cloud/opentelemetry-resource-util": "^2.3.0", + "@google-cloud/precise-date": "^4.0.0", + "google-auth-library": "^9.0.0", + "googleapis": "^137.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@opentelemetry/core": "^1.0.0", + "@opentelemetry/resources": "^1.0.0", + "@opentelemetry/sdk-metrics": "^1.0.0" + } + }, + "node_modules/@google-cloud/opentelemetry-cloud-monitoring-exporter/node_modules/googleapis": { + "version": "137.1.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", + "integrity": "sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/opentelemetry-resource-util": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-resource-util/-/opentelemetry-resource-util-2.3.0.tgz", + "integrity": "sha512-3yyG2IiOWXy23IIGW4rRaqVf0efsgkUyXLvDpCxiZPPIgSAevYVdfcJ2cQSp4d1y+2NCpS2Wq0XLbTLzTw/j5Q==", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.22.0", + "gcp-metadata": "^6.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/resources": "^1.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/precise-date": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/precise-date/-/precise-date-4.0.0.tgz", + "integrity": "sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/pubsub": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@google-cloud/pubsub/-/pubsub-4.7.2.tgz", + "integrity": "sha512-N9Cziu5d7sju4gtHsbbjOXDMCewNwGaPZ/o+sBbWl9sBR7S+kHkD4BVg6hCi9SvH1sst0AGan8UAQAxbac8cRg==", + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/precise-date": "^4.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "@opentelemetry/api": "~1.9.0", + "@opentelemetry/semantic-conventions": "~1.26.0", + "arrify": "^2.0.0", + "extend": "^3.0.2", + "google-auth-library": "^9.3.0", + "google-gax": "^4.3.3", + "heap-js": "^2.2.0", + "is-stream-ended": "^0.1.4", + "lodash.snakecase": "^4.1.1", + "p-defer": "^3.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/pubsub/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.26.0.tgz", + "integrity": "sha512-U9PJlOswJPSgQVPI+XEuNLElyFWkb0hAiMg+DExD9V0St03X2lPHGMdxMY/LrVmoukuIpXJ12oyrOtEZ4uXFkw==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/redis-cluster": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@google-cloud/redis-cluster/-/redis-cluster-0.5.0.tgz", + "integrity": "sha512-6O7jJWa993fztAjYH+fr2tkOZUREiChOCSsIIn7maKt8E+1PNG8QRyoL7d7zoBumyHMA0buTpG5mcLHfgK1QRA==", + "dependencies": { + "google-gax": "^4.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/spanner": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/@google-cloud/spanner/-/spanner-7.14.0.tgz", + "integrity": "sha512-plj9dMKnumouh7klZR8iEeZiU1p7wCOlx2JTnFHDsLGgDqaG0TFa6vsFfEZ0LMqD8fbIf4ih/Sls635Xx1I7Zg==", + "dependencies": { + "@google-cloud/common": "^5.0.0", + "@google-cloud/precise-date": "^4.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "@grpc/proto-loader": "^0.7.0", + "@types/big.js": "^6.0.0", + "@types/stack-trace": "0.0.33", + "arrify": "^2.0.0", + "big.js": "^6.0.0", + "checkpoint-stream": "^0.1.1", + "duplexify": "^4.1.1", + "events-intercept": "^2.0.0", + "extend": "^3.0.2", + "google-auth-library": "^9.0.0", + "google-gax": "4.3.9", + "grpc-gcp": "^1.0.0", + "is": "^3.2.1", + "lodash.snakecase": "^4.1.1", + "merge-stream": "^2.0.0", + "p-queue": "^6.0.2", + "protobufjs": "^7.0.0", + "retry-request": "^7.0.0", + "split-array-stream": "^2.0.0", + "stack-trace": "0.0.10", + "stream-events": "^1.0.4", + "teeny-request": "^9.0.0", + "through2": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.10.9", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.10.9.tgz", + "integrity": "sha512-5tcgUctCG0qoNyfChZifz2tJqbRbXVO9J7X6duFcOjY3HUNCxg5D0ZCK7EP9vIcZ0zRpLU9bWkyCqVCLZ46IbQ==", + "dependencies": { + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", + "integrity": "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", + "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", + "dependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.26.0.tgz", + "integrity": "sha512-HedpXXYzzbaoutw6DFLWLDket2FwLkLpil4hGCZ1xYEIMTcivdfwEOISgdbLEWyG3HW52gTq2V9mOVJrONgiwg==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", + "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.53.0.tgz", + "integrity": "sha512-x5ygAQgWAQOI+UOhyV3z9eW7QU2dCfnfOuIBiyYmC2AWr74f6x/3JBnP27IAcEx6aihpqBYWKnpoUTztkVPAZw==", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/sdk-logs": "0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.53.0.tgz", + "integrity": "sha512-cSRKgD/n8rb+Yd+Cif6EnHEL/VZg1o8lEcEwFji1lwene6BdH51Zh3feAD9p2TyVoBKrl6Q9Zm2WltSp2k9gWQ==", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/sdk-logs": "0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.53.0.tgz", + "integrity": "sha512-jhEcVL1deeWNmTUP05UZMriZPSWUBcfg94ng7JuBb1q2NExgnADQFl1VQQ+xo62/JepK+MxQe4xAwlsDQFbISA==", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-logs": "0.53.0", + "@opentelemetry/sdk-trace-base": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.53.0.tgz", + "integrity": "sha512-2wjAccaG4yBxjfPqDeeXEYymwo1OYybUmBxUutDPeu0ColVkXyHIOxKSdHdn6vAn/v20m4w9E6SrSl4jtuZdiA==", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.53.0", + "@opentelemetry/otlp-exporter-base": "0.53.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-metrics": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.53.0.tgz", + "integrity": "sha512-nvZtOk23pZOrTW10Za2WPd9pk4tWDvL6ALlHRFfInpcTjtOgCrv+fQDxpzosa5PeXvYeFFUO5aYCTnwiCX4Dzg==", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-metrics": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.53.0.tgz", + "integrity": "sha512-m6KSh6OBDwfDjpzPVbuJbMgMbkoZfpxYH2r262KckgX9cMYvooWXEKzlJYsNDC6ADr28A1rtRoUVRwNfIN4tUg==", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.53.0.tgz", + "integrity": "sha512-m7F5ZTq+V9mKGWYpX8EnZ7NjoqAU7VemQ1E2HAG+W/u0wpY1x0OmbxAXfGKFHCspdJk8UKlwPGrpcB8nay3P8A==", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.53.0.tgz", + "integrity": "sha512-T/bdXslwRKj23S96qbvGtaYOdfyew3TjPEKOk5mHjkCmkVl1O9C/YMdejwSsdLdOq2YW30KjR9kVi0YMxZushQ==", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.26.0.tgz", + "integrity": "sha512-PW5R34n3SJHO4t0UetyHKiXL6LixIqWN6lWncg3eRXhKuT30x+b7m5sDJS0kEWRfHeS+kG7uCw2vBzmB2lk3Dw==", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.53.0.tgz", + "integrity": "sha512-DMwg0hy4wzf7K73JJtl95m/e0boSoWhH07rfvHvYzQtBD3Bmv0Wc1x733vyZBqmFm8OjJD0/pfiUg1W3JjFX0A==", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", + "integrity": "sha512-UCWPreGQEhD6FjBaeDuXhiMf6kkBODF0ZQzrk/tuQcaVDJ+dDQ/xhJp192H9yWnKxVpEjFrSSLnpqmX4VwX+eA==", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-transformer": "0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.53.0.tgz", + "integrity": "sha512-F7RCN8VN+lzSa4fGjewit8Z5fEUpY/lmMVy5EWn2ZpbAabg3EE3sCLuTNfOiooNGnmvzimUPruoeqeko/5/TzQ==", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.53.0.tgz", + "integrity": "sha512-rM0sDA9HD8dluwuBxLetUmoqGJKSAbWenwD65KY9iZhUxdBHRLrIdrABfNDP7aiTjcgK8XFyTn5fhDz7N+W6DA==", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-logs": "0.53.0", + "@opentelemetry/sdk-metrics": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/propagator-b3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.26.0.tgz", + "integrity": "sha512-vvVkQLQ/lGGyEy9GT8uFnI047pajSOVnZI2poJqVGD3nJ+B9sFGdlHNnQKophE3lHfnIH0pw2ubrCTjZCgIj+Q==", + "dependencies": { + "@opentelemetry/core": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.26.0.tgz", + "integrity": "sha512-DelFGkCdaxA1C/QA0Xilszfr0t4YbGd3DjxiCDPh34lfnFr+VkkrjV9S8ZTJvAzfdKERXhfOxIKBoGPJwoSz7Q==", + "dependencies": { + "@opentelemetry/core": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", + "integrity": "sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.53.0.tgz", + "integrity": "sha512-dhSisnEgIj/vJZXZV6f6KcTnyLDx/VuQ6l3ejuZpMpPlh9S1qMHiZU9NMmOkVkwwHkMy3G6mEBwdP23vUZVr4g==", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.26.0.tgz", + "integrity": "sha512-0SvDXmou/JjzSDOjUmetAAvcKQW6ZrvosU0rkbDGpXvvZN+pQF6JbK/Kd4hNdK4q/22yeruqvukXEJyySTzyTQ==", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.53.0.tgz", + "integrity": "sha512-0hsxfq3BKy05xGktwG8YdGdxV978++x40EAKyKr1CaHZRh8uqVlXnclnl7OMi9xLMJEcXUw7lGhiRlArFcovyg==", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/exporter-logs-otlp-grpc": "0.53.0", + "@opentelemetry/exporter-logs-otlp-http": "0.53.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.53.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.53.0", + "@opentelemetry/exporter-trace-otlp-http": "0.53.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.53.0", + "@opentelemetry/exporter-zipkin": "1.26.0", + "@opentelemetry/instrumentation": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-logs": "0.53.0", + "@opentelemetry/sdk-metrics": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0", + "@opentelemetry/sdk-trace-node": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.26.0.tgz", + "integrity": "sha512-olWQldtvbK4v22ymrKLbIcBi9L2SpMO84sCPY54IVsJhP9fRsxJT194C/AVaAuJzLE30EdhhM1VmvVYR7az+cw==", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.26.0.tgz", + "integrity": "sha512-Fj5IVKrj0yeUwlewCRwzOVcr5avTuNnMHWf7GPc1t6WaT78J6CJyF3saZ/0RkZfdeNO8IcBl/bNcWMVZBMRW8Q==", + "dependencies": { + "@opentelemetry/context-async-hooks": "1.26.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/propagator-b3": "1.26.0", + "@opentelemetry/propagator-jaeger": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz", + "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/referee": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/referee/-/referee-11.0.1.tgz", + "integrity": "sha512-slA8klGmJskx/A2CBB/c3yOm8+gBzK64b7hoa3v+j7+dV34F1UI+ABB9tPZOEKcMSrMGZe7hC2Ebq2tnpgUGNA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/samsam": "^8.0.0", + "event-emitter": "^0.3.5", + "lodash.isarguments": "^3.1.0", + "util": "^0.12.5" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true + }, + "node_modules/@types/big.js": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz", + "integrity": "sha512-e2cOW9YlVzFY2iScnGBBkplKsrn2CsObHQ2Hiw4V1sSyiGbgWL8IyqE3zFi1Pt5o1pdAtYkDAIsF3KKUPjdzaA==" + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==" + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/duplexify": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/duplexify/-/duplexify-3.6.4.tgz", + "integrity": "sha512-2eahVPsd+dy3CL6FugAzJcxoraWhUghZGEQJns1kTKfCXWKJ5iG/VkaB05wRVrDKHfOFKqb0X0kXh91eE99RZg==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.3.tgz", + "integrity": "sha512-KOzM7MhcBFlmnlr/fzISFF5vGWVSvN6fTd4T+ExOt08bA/dA5kpSzY52nMsI1KDFmUREpJelPYyuslLRSjjgCg==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true + }, + "node_modules/@types/lodash.unionby": { + "version": "4.8.9", + "resolved": "https://registry.npmjs.org/@types/lodash.unionby/-/lodash.unionby-4.8.9.tgz", + "integrity": "sha512-mhu+q4xOl7nwf1EJi0jknjrOMBbKip54kW6rlvu7/gna5zqOIdwJjBfiMa26n6oPb5Bjbcz08tC2oz62p6EqyQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, + "node_modules/@types/mocha": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.8.tgz", + "integrity": "sha512-HfMcUmy9hTMJh66VNcmeC9iVErIZJli2bszuXc6julh5YGuRb/W5OnkHjwLNYdFlMis0sY3If5SEAp+PktdJjw==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.14.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz", + "integrity": "sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==" + }, + "node_modules/@types/pumpify": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@types/pumpify/-/pumpify-1.4.4.tgz", + "integrity": "sha512-+cWbQUecD04MQYkjNBhPmcUIP368aloYmqm+ImdMKA8rMpxRNAhZAD6gIj+sAVTF1DliqrT/qUp6aGNi/9U3tw==", + "dependencies": { + "@types/duplexify": "*", + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "node_modules/@types/request": { + "version": "2.48.12", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz", + "integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@types/rewire": { + "version": "2.5.30", + "resolved": "https://registry.npmjs.org/@types/rewire/-/rewire-2.5.30.tgz", + "integrity": "sha512-CSyzr7TF1EUm85as2noToMtLaBBN/rKKlo5ZDdXedQ64cUiHT25LCNo1J1cI4QghBlGmTymElW/2h3TiWYOsZw==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==" + }, + "node_modules/@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, + "node_modules/@types/stack-trace": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/stack-trace/-/stack-trace-0.0.33.tgz", + "integrity": "sha512-O7in6531Bbvlb2KEsJ0dq0CHZvc3iWSR5ZYMtvGgnHA56VgriAN/AU2LorfmcvAl2xc9N5fbCTRyMRRl8nd74g==" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "dependencies": { + "default-require-extensions": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/big.js": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.1.tgz", + "integrity": "sha512-bCtHMwL9LeDIozFn+oNhhFoq+yQ3BNdnsLSASUxLciOb1vgvpHsIO1dsENiGMgbb4SkP5TrzWzRiLddn8ahVOQ==", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bigjs" + } + }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "node_modules/browserslist": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001660", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz", + "integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/checkpoint-stream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/checkpoint-stream/-/checkpoint-stream-0.1.2.tgz", + "integrity": "sha512-eYXIcydL3mPjjEVLxHdi1ISgTwmxGJZ8vyJ3lYVvFTDRyTOZMTbKZdRJqiA7Gi1rPcwOyyzcrZmGLL8ff7e69w==", + "dependencies": { + "@types/pumpify": "^1.4.1", + "events-intercept": "^2.0.0", + "pumpify": "^1.3.5", + "split-array-stream": "^1.0.0", + "through2": "^2.0.3" + } + }, + "node_modules/checkpoint-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/checkpoint-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/checkpoint-stream/node_modules/split-array-stream": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/split-array-stream/-/split-array-stream-1.0.3.tgz", + "integrity": "sha512-yGY35QmZFzZkWZ0eHE06RPBi63umym8m+pdtuC/dlO1ADhdKSfCj0uNn87BYCXBBDFxyTq4oTw0BgLYT0K5z/A==", + "dependencies": { + "async": "^2.4.0", + "is-stream-ended": "^0.1.0" + } + }, + "node_modules/checkpoint-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/checkpoint-stream/node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dev": true, + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==" + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/cliui/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cloudevents": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cloudevents/-/cloudevents-8.0.0.tgz", + "integrity": "sha512-G1Z/r8QMFAsP+F1PuZSHzx1ocPy4vrdQMTHD3orjDaM5kccmPU6nMmpVrF07b53aaxcrLbORUmRepY/DgvdhVw==", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "json-bigint": "^1.0.0", + "process": "^0.11.10", + "util": "^0.12.4", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=16 <=20" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.0.0.tgz", + "integrity": "sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==", + "dev": true, + "dependencies": { + "jiti": "^1.19.1" + }, + "engines": { + "node": ">=v16" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=8.2", + "typescript": ">=4" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "dev": true, + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/default-require-extensions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", + "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, + "dependencies": { + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.25", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.25.tgz", + "integrity": "sha512-kMb204zvK3PsSlgvvwzI3wBIcAw15tRkYk+NQdsjdDtcQWTp2RABbMQ9rUBy8KNEOM+/E6ep+XC3AykiWZld4g==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dev": true, + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "dev": true, + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-google": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz", + "integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "eslint": ">=5.16.0" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", + "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dev": true, + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dev": true, + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==" + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/eventid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/eventid/-/eventid-2.0.1.tgz", + "integrity": "sha512-sPNTqiMokAvV048P2c9+foqVJzk49o6d4e0D/sq5jog3pw+4kBgyR0gaM1FM7Mx6Kzd9dztesh9oYz1LWWOpzw==", + "dependencies": { + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-intercept": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/events-intercept/-/events-intercept-2.0.0.tgz", + "integrity": "sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==" + }, + "node_modules/express": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dev": true, + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-uri": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", + "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fromentries": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", + "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==" + }, + "node_modules/gaxios": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.6.0.tgz", + "integrity": "sha512-bpOZVQV5gthH/jVCSuYuokRo2bTKOcuBiVWpjmTn6C5Agl5zclGfTljuGsQZxwwDBkli+YhZhP4TdlqTnhOezQ==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stdin": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz", + "integrity": "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.3.tgz", + "integrity": "sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==", + "dev": true, + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4", + "fs-extra": "^11.2.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/get-uri/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "dev": true, + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.10.0.tgz", + "integrity": "sha512-ol+oSa5NbcGdDqA+gZ3G3mev59OHBZksBTxY/tYwjtcp1H/scAFwJfSQU9/1RALoyZ7FslNbke8j4i3ipwlyuQ==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.3.9.tgz", + "integrity": "sha512-tcjQr7sXVGMdlvcG25wSv98ap1dtF4Z6mcV0rztGIddOcezw4YMb/uTXg72JPrLep+kXcVjaJjg6oo3KLf4itQ==", + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/googleapis": { + "version": "144.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-144.0.0.tgz", + "integrity": "sha512-ELcWOXtJxjPX4vsKMh+7V+jZvgPwYMlEhQFiu2sa9Qmt5veX8nwXPksOWGGN6Zk4xCiLygUyaz7xGtcMO+Onxw==", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/grpc-gcp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/grpc-gcp/-/grpc-gcp-1.0.1.tgz", + "integrity": "sha512-06r73IoGaAIpzT+DRPnw7V5BXvZ5mjy1OcKqSPX+ZHOgbLxT+lJfz8IN83z/sbA3t55ZX88MfDaaCjDGdveVIA==", + "dependencies": { + "@grpc/grpc-js": "^1.7.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-it": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hash-it/-/hash-it-6.0.0.tgz", + "integrity": "sha512-KHzmSFx1KwyMPw0kXeeUD752q/Kfbzhy6dAZrjXV9kAIXGqzGvv8vhkUqj+2MGZldTo0IBpw6v7iWE7uxsvH0w==" + }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasha/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasha/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/heap-js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/heap-js/-/heap-js-2.5.0.tgz", + "integrity": "sha512-kUGoI3p7u6B41z/dp33G6OaL7J4DRqRYwVmeIlwLClx7yaaAy7hoDExnuejTKtuDwfcatGmddHDEOjf6EyIxtQ==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" + }, + "node_modules/html-entities": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", + "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/html-link-extractor": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/html-link-extractor/-/html-link-extractor-1.0.5.tgz", + "integrity": "sha512-ADd49pudM157uWHwHQPUSX4ssMsvR/yHIswOR5CUfBdK9g9ZYGMhVSE6KZVHJ6kCkR0gH4htsfzU6zECDNVwyw==", + "dev": true, + "dependencies": { + "cheerio": "^1.0.0-rc.10" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/husky": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz", + "integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==", + "dev": true, + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", + "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", + "dependencies": { + "acorn": "^8.8.2", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/is/-/is-3.3.0.tgz", + "integrity": "sha512-nW24QBoPcFGGHJGUwnfpI7Yc5CdqWNdsyHQszVE/z2pKHXzh7FZ5GWhJqSyaQ9wMkQnsTx+kAI8bHlCX4tKdbg==", + "engines": { + "node": "*" + } + }, + "node_modules/is-absolute-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-relative-url": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-relative-url/-/is-relative-url-4.0.0.tgz", + "integrity": "sha512-PkzoL1qKAYXNFct5IKdKRH/iBQou/oCC85QhXj6WKtUQBliZ4Yfd3Zk27RHu9KQG8r6zgvAA2AQKC9p+rqTszg==", + "dev": true, + "dependencies": { + "is-absolute-url": "^4.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream-ended": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-stream-ended/-/is-stream-ended-0.1.4.tgz", + "integrity": "sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==" + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/isemail": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz", + "integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==", + "dev": true, + "dependencies": { + "punycode": "2.x.x" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "dependencies": { + "append-transform": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-processinfo": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", + "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, + "dependencies": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-rules-engine": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/json-rules-engine/-/json-rules-engine-6.5.0.tgz", + "integrity": "sha512-W8SLmnfQRDNG1Nh3Agz3c9AZzhiZ/cUtjAhyfhujFzVFNBv7cSHm9WaLoRjOdRr/9je7RgLtmbYXFViL3CekPA==", + "dependencies": { + "clone": "^2.1.2", + "eventemitter2": "^6.4.4", + "hash-it": "^6.0.0", + "jsonpath-plus": "^7.2.0", + "lodash.isobjectlike": "^4.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ] + }, + "node_modules/jsonpath-plus": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz", + "integrity": "sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/link-check": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/link-check/-/link-check-5.3.0.tgz", + "integrity": "sha512-Jhb7xueDgQgBaZzkfOtAyOZEZAIMJQIjUpYD2QY/zEB+LKTY1tWiBwZg8QIDbzQdPBOcqzg7oLQDNcES/tQmXg==", + "dev": true, + "dependencies": { + "is-relative-url": "^4.0.0", + "isemail": "^3.2.0", + "ms": "^2.1.3", + "needle": "^3.3.1", + "proxy-agent": "^6.4.0" + } + }, + "node_modules/link-check/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "dev": true + }, + "node_modules/lodash.isobjectlike": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isobjectlike/-/lodash.isobjectlike-4.0.0.tgz", + "integrity": "sha512-bbRt0Dief0yqjkTgpvzisSxnsmY3ZgVJvokHL30UE+ytsvnpNfiNaCJL4XBEWek8koQmrwZidBHb7coXC5vXlA==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true + }, + "node_modules/lodash.unionby": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/lodash.unionby/-/lodash.unionby-4.8.0.tgz", + "integrity": "sha512-e60kn4GJIunNkw6v9MxRnUuLYI/Tyuanch7ozoCtk/1irJTYBj+qNTxr5B3qVflmJhwStJBv387Cb+9VOfABMg==", + "dev": true + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-link-check": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/markdown-link-check/-/markdown-link-check-3.12.2.tgz", + "integrity": "sha512-GWMwSvxuZn+uGGydi5yywnnDZy08SGps4I/63xqvWT7lxtH4cVLnhgZZYtEcPz/QvgPg9vbH2rvWpa29owMtHA==", + "dev": true, + "dependencies": { + "async": "^3.2.5", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "link-check": "^5.3.0", + "lodash": "^4.17.21", + "markdown-link-extractor": "^4.0.2", + "needle": "^3.3.1", + "progress": "^2.0.3", + "proxy-agent": "^6.4.0" + }, + "bin": { + "markdown-link-check": "markdown-link-check" + } + }, + "node_modules/markdown-link-check/node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", + "dev": true + }, + "node_modules/markdown-link-extractor": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/markdown-link-extractor/-/markdown-link-extractor-4.0.2.tgz", + "integrity": "sha512-5cUOu4Vwx1wenJgxaudsJ8xwLUMN7747yDJX3V/L7+gi3e4MsCm7w5nbrDQQy8nEfnl4r5NV3pDXMAjhGXYXAw==", + "dev": true, + "dependencies": { + "html-link-extractor": "^1.0.5", + "marked": "^12.0.1" + } + }, + "node_modules/markdownlint": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.34.0.tgz", + "integrity": "sha512-qwGyuyKwjkEMOJ10XN6OTKNOVYvOIi35RNvDLNxTof5s8UmyGHlCdpngRHoRGNvQVGuxO3BJ7uNSgdeX166WXw==", + "dev": true, + "dependencies": { + "markdown-it": "14.1.0", + "markdownlint-micromark": "0.1.9" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.41.0.tgz", + "integrity": "sha512-kp29tKrMKdn+xonfefjp3a/MsNzAd9c5ke0ydMEI9PR98bOjzglYN4nfMSaIs69msUf1DNkgevAIAPtK2SeX0Q==", + "dev": true, + "dependencies": { + "commander": "~12.1.0", + "get-stdin": "~9.0.0", + "glob": "~10.4.1", + "ignore": "~5.3.1", + "js-yaml": "^4.1.0", + "jsonc-parser": "~3.2.1", + "jsonpointer": "5.0.1", + "markdownlint": "~0.34.0", + "minimatch": "~9.0.4", + "run-con": "~1.3.2", + "smol-toml": "~1.2.0" + }, + "bin": { + "markdownlint": "markdownlint.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/markdownlint-cli/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/markdownlint-cli/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/markdownlint-micromark": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/markdownlint-micromark/-/markdownlint-micromark-0.1.9.tgz", + "integrity": "sha512-5hVs/DzAFa8XqYosbEAEg6ok6MF2smDj89ztn9pKkCtdKHVdPQuGMH7frFfYL9mLkvfFe4pTyAMffLbjf3/EyA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "dev": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.3.tgz", + "integrity": "sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A==", + "dev": true, + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/mocha/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/mocha/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/mocha/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/mocha/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mocha/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/mocha/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mocha/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/mocha/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", + "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/needle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", + "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", + "dev": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "dev": true + }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.1.0.tgz", + "integrity": "sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "dependencies": { + "process-on-spawn": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nyc": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.0.0.tgz", + "integrity": "sha512-ISp44nqNCaPugLLGGfknzQwSwt10SSS5IMoPR7GLoMAyS18Iw5js8U7ga2VF9lYuMZ42gOHr3UddZw4WZltxKg==", + "dev": true, + "dependencies": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^6.0.2", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "bin": { + "nyc": "bin/nyc.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nyc/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/nyc/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/nyc/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/nyc/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/nyc/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/nyc/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nyc/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nyc/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/nyc/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "node_modules/nyc/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-defer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-3.0.0.tgz", + "integrity": "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.1.tgz", + "integrity": "sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==", + "dev": true, + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "pac-resolver": "^7.0.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dev": true, + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.4.0.tgz", + "integrity": "sha512-nbkQb5+9YPhQRz/BeQmrWpEknAaqjpAqRK8NwJpmrX/JHu7JuZC5G1CeAwJDJfGes4h+YihC6in3Q2nGb+Y09w==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.2.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-abstract-transport/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==" + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "dependencies": { + "fromentries": "^1.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/process-warning": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", + "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", + "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.3", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/proxy-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pumpify": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", + "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", + "dependencies": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + }, + "node_modules/pumpify/node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/pumpify/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/pumpify/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/pumpify/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.4.0.tgz", + "integrity": "sha512-X34iHADNbNDfr6OTStIAHWSAvvKQRYgLO6duASaVf7J2VA3lvmNYboAHOuLC2huav1IwgZJtyEcJCKVzFxOSMQ==", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/require-in-the-middle/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/require-in-the-middle/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rewire": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rewire/-/rewire-7.0.0.tgz", + "integrity": "sha512-DyyNyzwMtGYgu0Zl/ya0PR/oaunM+VuCuBxCuhYJHHaV0V+YvYa3bBGxb5OZ71vndgmp1pYY8F4YOwQo1siRGw==", + "dev": true, + "dependencies": { + "eslint": "^8.47.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-con": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/run-con/-/run-con-1.3.2.tgz", + "integrity": "sha512-CcfE+mYiTcKEzg0IqS08+efdnH0oJ3zV0wSUFBNrMHMuxCtXvBCLzCJHatwuXDcu/RlhjTziTo/a1ruQik6/Yg==", + "dev": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~4.1.0", + "minimist": "^1.2.8", + "strip-json-comments": "~3.1.1" + }, + "bin": { + "run-con": "cli.js" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true + }, + "node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, + "node_modules/should": { + "version": "13.2.3", + "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", + "integrity": "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==", + "dev": true, + "dependencies": { + "should-equal": "^2.0.0", + "should-format": "^3.0.3", + "should-type": "^1.4.0", + "should-type-adaptors": "^1.0.1", + "should-util": "^1.0.0" + } + }, + "node_modules/should-equal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", + "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", + "dev": true, + "dependencies": { + "should-type": "^1.4.0" + } + }, + "node_modules/should-format": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", + "integrity": "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==", + "dev": true, + "dependencies": { + "should-type": "^1.3.0", + "should-type-adaptors": "^1.0.1" + } + }, + "node_modules/should-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", + "integrity": "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==", + "dev": true + }, + "node_modules/should-type-adaptors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", + "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", + "dev": true, + "dependencies": { + "should-type": "^1.3.0", + "should-util": "^1.0.0" + } + }, + "node_modules/should-util": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", + "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==", + "dev": true + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smol-toml": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.2.2.tgz", + "integrity": "sha512-fVEjX2ybKdJKzFL46VshQbj9PuA4IUKivalgp48/3zwS9vXzyykzQ6AX92UxHSvWJagziMRLeHMgEzoGO7A8hQ==", + "dev": true, + "engines": { + "node": ">= 18" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "dev": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz", + "integrity": "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.1", + "debug": "^4.3.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/sonic-boom": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.0.1.tgz", + "integrity": "sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "dependencies": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/spawn-wrap/node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/spawn-wrap/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==" + }, + "node_modules/split-array-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/split-array-stream/-/split-array-stream-2.0.0.tgz", + "integrity": "sha512-hmMswlVY91WvGMxs0k8MRgq8zb2mSen4FmDNc5AFiTWtrBpdZN6nwD6kROVe4vNL+ywrvbCKsWVCnEd4riELIg==", + "dependencies": { + "is-stream-ended": "^0.1.4" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", + "integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/thread-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.0.2.tgz", + "integrity": "sha512-cBL4xF2A3lSINV4rD5tyqnKH4z/TgWPvT+NaVhJDSwK962oo/Ye7cHSMbDzwcu7tAE1SfU6Q4XtV6Hucmi6Hlw==", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==" + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==" + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs-unparser/node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..41b7c3f --- /dev/null +++ b/package.json @@ -0,0 +1,89 @@ +{ + "name": "memorystore-cluster-autoscaler", + "version": "0.1.0", + "Description": "Autoscaling for Memorystore Cluster", + "homepage": "https://github.com/GoogleCloudPlatform/memorystore-cluster-autoscaler", + "license": "Apache-2.0", + "author": "Google Inc.", + "main": "src/functions.js", + "scripts": { + "check-format": "npm run prettier-check && npm run terraform-fmt-check", + "debug-poller-function": "node --inspect node_modules/.bin/functions-framework --target=checkMemorystoreClusterScaleMetricsHTTP", + "debug-scaler-function": "node --inspect node_modules/.bin/functions-framework --target=scaleMemorystoreClusterHTTP --port=8081", + "eslint": "eslint .", + "eslint-fix": "eslint --fix .", + "format": "npm run prettier && npm run terraform-fmt", + "prettier": "prettier --write .", + "prettier-check": "prettier --check .", + "install-all": "npm install --save", + "markdown-link-check": "find . -name '*.md' -not \\( -path './node_modules/*' -o -path './configeditor/build/*' -o -path '*/.terraform/*' -prune \\) -print0 | xargs --null markdown-link-check --config markdown-link-checker.json --quiet", + "mdlint": "markdownlint '**/*.md' --config .mdl.json --ignore '**/build/**' --ignore '**/node_modules/**' --ignore 'code-of-conduct.md'", + "prepare": "{ git rev-parse --is-inside-work-tree >/dev/null 2>/dev/null && test \"$NODE_ENV\" != production -a \"$CI\" != true && husky ; } || true", + "start-poller-function": "functions-framework --target=checkMemorystoreClusterScaleMetricsHTTP", + "start-scaler-function": "functions-framework --target=scaleMemorystoreClusterHTTP --port=8081", + "terraform-fmt": "echo 'Running Terraform fmt'; find . -name '*.tf' -print0 | xargs -0 terraform fmt", + "terraform-fmt-check": "echo 'Checking Terraform format'; if ! find . -name '*.tf' -print0 | xargs -0 terraform fmt -check; then echo 'Files need reformatting: npm run terraform-fmt'; exit 1; fi", + "terraform-validate": "set -e ; package_root=\"$(pwd)\"; for x in gke/unified cloud-functions/distributed/app-project cloud-functions/distributed/autoscaler-project cloud-functions/per-project ; do cd \"$package_root/terraform/$x\" ; echo \"\n\nValidating Terraform in $PWD\" ; terraform init >/dev/null ; terraform validate; done", + "test": "NODE_ENV=test nyc --reporter=text mocha --recursive src --extension=.test.js --timeout 10000", + "test-e2e": "pushd terraform/cloud-functions/per-project/test && go test -run . -timeout 60m --tags=e2e && popd", + "typecheck": "tsc --project jsconfig.json --maxNodeModuleJsDepth 0 --noEmit", + "unified-job": "node -e \"require('./src/unified-scaler').main()\"", + "validate-config-file": "node -e \"require('./src/poller/poller-core/config-validator').main()\" -- ", + "start-configeditor-server": "cd configeditor && ./build-configeditor.sh --quiet && npm exec http-server -- -a 127.0.0.1 " + }, + "dependencies": { + "@google-cloud/firestore": "^7.10.0", + "@google-cloud/functions-framework": "^3.4.2", + "@google-cloud/monitoring": "^4.1.0", + "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.19.0", + "@google-cloud/pubsub": "^4.7.2", + "@google-cloud/redis-cluster": "^0.5.0", + "@google-cloud/spanner": "^7.14.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.53.0", + "@opentelemetry/sdk-metrics": "^1.26.0", + "@opentelemetry/sdk-node": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "ajv": "^8.17.1", + "axios": "^1.7.7", + "eventid": "^2.0.1", + "express": "^4.21.0", + "googleapis": "^144.0.0", + "js-yaml": "^4.1.0", + "json-rules-engine": "^6.5.0", + "lodash": "^4.17.21", + "pino": "^9.4.0", + "sanitize-filename": "^1.6.3" + }, + "devDependencies": { + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", + "@sinonjs/referee": "^11.0.1", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4.17.7", + "@types/lodash.unionby": "^4.8.9", + "@types/mocha": "^10.0.8", + "@types/node": "^20.11.30", + "@types/rewire": "^2.5.30", + "@types/sinon": "17.0.3", + "eslint": "^8.57.1", + "eslint-config-google": "^0.14.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "husky": "^9.1.6", + "lodash.unionby": "^4.8.0", + "markdown-link-check": "^3.12.2", + "markdownlint-cli": "^0.41.0", + "mocha": "^10.7.3", + "nyc": "^17.0.0", + "prettier": "^3.3.3", + "rewire": "^7.0.0", + "should": "^13.2.3", + "sinon": "^19.0.2", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=18.0.0 || >=20.0.0", + "npm": ">=10.0.0" + } +} diff --git a/renovate.json5 b/renovate.json5 new file mode 100644 index 0000000..0fc7a27 --- /dev/null +++ b/renovate.json5 @@ -0,0 +1,31 @@ +{ + $schema: "https://docs.renovatebot.com/renovate-schema.json", + extends: [ + "config:recommended", + ":semanticCommits", + ":enableVulnerabilityAlertsWithLabel(security)", + ], + packageRules: [ + { + // Only allow minor updates for all NPM packages except googleapis + matchDatasources: ["npm"], + excludePackageNames: ["googleapis"], + major: { enabled: false }, + }, + ], + customManagers: [ + { + customType: "regex", + description: "Update _VERSION variables in Dockerfiles, shell scripts", + fileMatch: [ + "(^|/|\\.)([Dd]ocker|[Cc]ontainer)file$", + "(^|/)([Dd]ocker|[Cc]ontainer)file[^/]*$", + "(^|/)*.sh", + ], + matchStrings: [ + '# renovate: datasource=(?[a-z-]+?)(?: depName=(?.+?))? packageName=(?.+?)(?: versioning=(?[a-z-]+?))?\\s(?:ENV|ARG)?\\s*.+?_VERSION="?(?.+?)"?\\s', + ], + }, + ], + rangeStrategy: "bump", +} diff --git a/resources/architecture-abstract.png b/resources/architecture-abstract.png new file mode 100644 index 0000000..5e7a997 Binary files /dev/null and b/resources/architecture-abstract.png differ diff --git a/resources/architecture-centralized.png b/resources/architecture-centralized.png new file mode 100644 index 0000000..a78dc31 Binary files /dev/null and b/resources/architecture-centralized.png differ diff --git a/resources/architecture-distributed.png b/resources/architecture-distributed.png new file mode 100644 index 0000000..01fb54c Binary files /dev/null and b/resources/architecture-distributed.png differ diff --git a/resources/architecture-forwarder.png b/resources/architecture-forwarder.png new file mode 100644 index 0000000..99ca064 Binary files /dev/null and b/resources/architecture-forwarder.png differ diff --git a/resources/architecture-gke-unified.png b/resources/architecture-gke-unified.png new file mode 100644 index 0000000..d580eb6 Binary files /dev/null and b/resources/architecture-gke-unified.png differ diff --git a/resources/architecture-per-project.png b/resources/architecture-per-project.png new file mode 100644 index 0000000..8ac3acc Binary files /dev/null and b/resources/architecture-per-project.png differ diff --git a/resources/hero-image.jpg b/resources/hero-image.jpg new file mode 100644 index 0000000..b7e2658 Binary files /dev/null and b/resources/hero-image.jpg differ diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..2df6848 --- /dev/null +++ b/src/README.md @@ -0,0 +1,36 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Automatically increase or reduce the size of Memorystore clusters. +
+ Home + · + Poller component + · + Scaler component +

+

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) + +## Overview + +This directory contains the source code for the two main components of the +autoscaler: the Poller and the Scaler: + +* [Poller](poller/README.md) +* [Scaler](scaler/README.md) + +As well as the Forwarder, which is used in the +[distributed deployment model][distributed-docs]: + +* [Forwarder](forwarder/README.md) + +[distributed-docs]: ../terraform/cloud-functions/distributed/README.md diff --git a/src/autoscaler-common/assert-defined.js b/src/autoscaler-common/assert-defined.js new file mode 100644 index 0000000..9b7eabe --- /dev/null +++ b/src/autoscaler-common/assert-defined.js @@ -0,0 +1,33 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/** + * Asserts that given value is not null or undefined + * + * @template T + * @param {T|null|undefined} value + * @param {string} [valueName=''] + * @return {!T} + */ +function assertDefined(value, valueName = '') { + if (value == null) { + throw new Error( + `Fatal error: value ${valueName} must not be null/undefined.`, + ); + } + return value; +} + +module.exports = assertDefined; diff --git a/src/autoscaler-common/config-parameters.js b/src/autoscaler-common/config-parameters.js new file mode 100644 index 0000000..f26d752 --- /dev/null +++ b/src/autoscaler-common/config-parameters.js @@ -0,0 +1,24 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/** @fileoverview Provides common constants regarding Autoscaler setup. */ + +const CLUSTER_SIZE_MIN = 3; +const CLUSTER_SIZE_INVALID = 4; + +module.exports = { + CLUSTER_SIZE_MIN, + CLUSTER_SIZE_INVALID, +}; diff --git a/src/autoscaler-common/counters-base.js b/src/autoscaler-common/counters-base.js new file mode 100644 index 0000000..2f70f60 --- /dev/null +++ b/src/autoscaler-common/counters-base.js @@ -0,0 +1,517 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Autoscaler Base Counters module + * + * Provides basic counters functionality to poller and scaler counters + * packages + * + */ +const { + MeterProvider, + PeriodicExportingMetricReader, +} = require('@opentelemetry/sdk-metrics'); +const {Resource} = require('@opentelemetry/resources'); +const { + MetricExporter: GcpMetricExporter, +} = require('@google-cloud/opentelemetry-cloud-monitoring-exporter'); +const { + OTLPMetricExporter, +} = require('@opentelemetry/exporter-metrics-otlp-grpc'); +const {GcpDetectorSync} = require('@google-cloud/opentelemetry-resource-util'); +const Semconv = require('@opentelemetry/semantic-conventions'); +const OpenTelemetryApi = require('@opentelemetry/api'); +const OpenTelemetryCore = require('@opentelemetry/core'); +const {setTimeout} = require('timers/promises'); +const {logger} = require('./logger.js'); +const PromiseWithResolvers = require('./promiseWithResolvers.js'); +const {version: packageVersion} = require('../../package.json'); + +/** + * @typedef {{ + * counterName: string, + * counterDesc: string, + * counterType?: "CUMULATIVE" | "HISTOGRAM" // default=COUNTER + * counterUnits?: string + * counterHistogramBuckets?: number[] + * }} CounterDefinition + */ + +/** + * @typedef {{ + * [x: string]: string, + * }} CounterAttributes + */ +/** @type {CounterAttributes} */ +const RESOURCE_ATTRIBUTES = { + [Semconv.SEMRESATTRS_SERVICE_NAMESPACE]: 'googlecloudplatform', + [Semconv.SEMRESATTRS_SERVICE_NAME]: 'memorystore-cluster-autoscaler', + [Semconv.SEMRESATTRS_SERVICE_VERSION]: packageVersion, +}; + +const COUNTER_ATTRIBUTE_NAMES = { + CLUSTER_PROJECT_ID: 'memorystore_cluster_project_id', + CLUSTER_INSTANCE_ID: 'memorystore_cluster_instance_id', +}; + +/** + * The prefix to use for any autoscaler counters. + */ +const COUNTERS_PREFIX = + RESOURCE_ATTRIBUTES[Semconv.SEMRESATTRS_SERVICE_NAMESPACE] + + '/' + + RESOURCE_ATTRIBUTES[Semconv.SEMRESATTRS_SERVICE_NAME] + + '/'; + +/** @enum{string} */ +const ExporterMode = { + GCM_ONLY_FLUSHING: 'GCM_ONLY_FLUSHING', + OTEL_PERIODIC: 'OTEL_PERIODIC', + OTEL_ONLY_FLUSHING: 'OTEL_ONLY_FLUSHING', +}; + +const EXPORTER_PARAMETERS = { + // GCM direct pushing is only done in Cloud Run functions deployments, where + // we only flush directly. + // + [ExporterMode.GCM_ONLY_FLUSHING]: { + PERIODIC_EXPORT_INTERVAL: 0x7fffffff, // approx 24 days in milliseconds + FLUSH_MIN_INTERVAL: 10_000, + FLUSH_MAX_ATTEMPTS: 6, + FLUSH_ENABLED: true, + }, + + // OTEL collector cannot handle receiving metrics from a single process + // more frequently than its batching timeout, as it does not aggregate + // them and reports the multiple metrics to the upstream metrics management + // interface (eg GCM) which will then cause Duplicate TimeSeries errors. + // + // So when using flushing, disable periodic export, and when using periodic + // export, disable flushing! + // + // OTEL collector mode is set by specifying the environment variable + // OTEL_COLLECTOR_URL which is the address of the collector, + // and whether to use flushing or periodic export is determined + // by the environment variable OTEL_IS_LONG_RUNNING_PROCESS + [ExporterMode.OTEL_ONLY_FLUSHING]: { + PERIODIC_EXPORT_INTERVAL: 0x7fffffff, // approx 24 days in milliseconds + FLUSH_MIN_INTERVAL: 15_000, + FLUSH_MAX_ATTEMPTS: 6, + FLUSH_ENABLED: true, + }, + [ExporterMode.OTEL_PERIODIC]: { + PERIODIC_EXPORT_INTERVAL: 20_000, // OTEL collector batches every 10s + FLUSH_MIN_INTERVAL: 0, + FLUSH_MAX_ATTEMPTS: 0, + FLUSH_ENABLED: false, + }, +}; + +/** @type {ExporterMode} */ +let exporterMode; + +/** + * Global counters object, populated by createCounters. + * + * @type {Map< + * String, + * { + * cumulative?: OpenTelemetryApi.Counter, + * histogram?: OpenTelemetryApi.Histogram + * } + * >} counter Name to counter instance + */ +const COUNTERS = new Map(); + +/** + * @type {MeterProvider} + */ +let meterProvider; + +/** @type {PromiseWithResolvers.PromiseWithResolvers?} */ +let pendingInit; + +/** + * Wrapper class for OpenTelemetry DiagLogger to convert to Bunyan log levels + * + * @extends {OpenTelemetryApi.DiagLogger} + */ +class DiagToBunyanLogger { + /** @constructor */ + constructor() { + // In some cases where errors may be expected, we want to be able to supress + // them. + this.suppressErrors = false; + } + + /** + * @param {string} message + * @param {any[]} args + */ + verbose(message, ...args) { + logger.trace('otel: ' + message, args); + } + + /** + * @param {string} message + * @param {any[]} args + */ + debug(message, ...args) { + logger.debug('otel: ' + message, args); + } + /** + * @param {string} message + * @param {any[]} args + */ + info(message, ...args) { + logger.info('otel: ' + message, args); + } + /** + * @param {string} message + * @param {any[]} args + */ + warn(message, ...args) { + logger.warn('otel: ' + message, args); + } + /** + * @param {string} message + * @param {any[]} args + */ + error(message, ...args) { + if (!this.suppressErrors) { + logger.error('otel: ' + message, args); + } + } +} + +const DIAG_BUNYAN_LOGGER = new DiagToBunyanLogger(); + +/** + * Number of errors reported by OpenTelemetry. Used by tryFlush() to detect + * if the flush suceeded or not. + */ +let openTelemetryErrorCount = 0; + +/** @typedef {import('@opentelemetry/api').Exception} Exception */ + +/** + * Global Error hander function for open Telemetry. Keeps a track of the + * number of errors reported. + * + * @param {Exception} err + */ +function openTelemetryGlobalErrorHandler(err) { + openTelemetryErrorCount++; + // delegate to Otel's own error handler for stringification + OpenTelemetryCore.loggingErrorHandler()(err); +} + +// Setup OpenTelemetry client libraries logging. +OpenTelemetryApi.default.diag.setLogger(DIAG_BUNYAN_LOGGER, { + logLevel: OpenTelemetryApi.DiagLogLevel.INFO, + suppressOverrideMessage: true, +}); +OpenTelemetryCore.setGlobalErrorHandler(openTelemetryGlobalErrorHandler); + +/** + * Initialize the OpenTelemetry metrics, set up logging + * + * If called more than once, will wait for the first call to complete. + * + * @return {!Promise} + */ +async function initMetrics() { + // check to see if someone else has started to init counters before + // so that this function only runs once. + if (pendingInit) { + return await pendingInit.promise; + } + pendingInit = PromiseWithResolvers.create(); + + try { + logger.debug('initializing metrics'); + + if (process.env.KUBERNETES_SERVICE_HOST) { + // In K8s. We need to set the Pod Name to prevent duplicate + // timeseries errors. + if (process.env.K8S_POD_NAME) { + RESOURCE_ATTRIBUTES[Semconv.SEMRESATTRS_K8S_POD_NAME] = + process.env.K8S_POD_NAME; + } else { + logger.warn( + 'WARNING: running under Kubernetes, but K8S_POD_NAME ' + + 'environment variable is not set. ' + + 'This may lead to Send TimeSeries errors', + ); + } + } + + const resources = new GcpDetectorSync() + .detect() + .merge(new Resource(RESOURCE_ATTRIBUTES)); + if (resources.waitForAsyncAttributes) { + await resources.waitForAsyncAttributes(); + } + + let exporter; + if (process.env.OTEL_COLLECTOR_URL) { + switch (process.env.OTEL_IS_LONG_RUNNING_PROCESS) { + case 'true': + exporterMode = ExporterMode.OTEL_PERIODIC; + break; + case 'false': + exporterMode = ExporterMode.OTEL_ONLY_FLUSHING; + break; + default: + throw new Error( + `Invalid value for env var OTEL_IS_LONG_RUNNING_PROCESS: "${process.env.OTEL_IS_LONG_RUNNING_PROCESS}"`, + ); + } + logger.info( + `Counters mode: ${exporterMode} OTEL collector: ${process.env.OTEL_COLLECTOR_URL}`, + ); + exporter = new OTLPMetricExporter({ + url: process.env.OTEL_COLLECTOR_URL, + // @ts-ignore -- CompressionAlgorithm.NONE (='none') is not exported. + compression: 'none', + }); + } else { + exporterMode = ExporterMode.GCM_ONLY_FLUSHING; + logger.info(`Counters mode: ${exporterMode} using GCP monitoring`); + exporter = new GcpMetricExporter({prefix: 'workload.googleapis.com'}); + } + + meterProvider = new MeterProvider({ + resource: resources, + readers: [ + new PeriodicExportingMetricReader({ + exportIntervalMillis: + EXPORTER_PARAMETERS[exporterMode].PERIODIC_EXPORT_INTERVAL, + exportTimeoutMillis: + EXPORTER_PARAMETERS[exporterMode].PERIODIC_EXPORT_INTERVAL, + exporter: exporter, + }), + ], + }); + } catch (e) { + // report failures to other waiters. + pendingInit.reject(e); + throw e; + } + pendingInit.resolve(null); +} + +/** + * Initialize metrics with cloud monitoring + * + * Note: counterName must be unique. + * + * @param {CounterDefinition[]} counterDefinitions + * + * @return {!Promise} + */ +async function createCounters(counterDefinitions) { + await initMetrics(); + + const meter = meterProvider.getMeter(COUNTERS_PREFIX); + + for (const counterDef of counterDefinitions) { + if (!counterDef.counterName || !counterDef.counterDesc) { + throw new Error( + 'invalid counter definition: ' + JSON.stringify(counterDef), + ); + } + if (COUNTERS.get(counterDef.counterName)) { + throw new Error('Counter already created: ' + counterDef.counterName); + } + switch (counterDef.counterType || 'CUMULATIVE') { + case 'CUMULATIVE': + COUNTERS.set(counterDef.counterName, { + cumulative: meter.createCounter( + COUNTERS_PREFIX + counterDef.counterName, + { + description: counterDef.counterDesc, + unit: counterDef.counterUnits, + }, + ), + }); + break; + case 'HISTOGRAM': + COUNTERS.set(counterDef.counterName, { + histogram: meter.createHistogram( + COUNTERS_PREFIX + counterDef.counterName, + { + description: counterDef.counterDesc, + unit: counterDef.counterUnits, + advice: { + explicitBucketBoundaries: counterDef.counterHistogramBuckets, + }, + }, + ), + }); + break; + default: + throw new Error( + `Invalid counter type for ${counterDef.counterName}: ${counterDef.counterType}`, + ); + } + } +} + +/** + * Increment a cumulative counter. + * + * @param {string} counterName + * @param {OpenTelemetryApi.Attributes} [counterAttributes] + */ +function incCounter(counterName, counterAttributes) { + const counter = COUNTERS.get(counterName); + if (!counter?.cumulative) { + throw new Error('Unknown counter: ' + counterName); + } + counter.cumulative.add(1, counterAttributes); +} + +/** + * Record a histogram counter value. + * + * @param {string} counterName + * @param {number} value + * @param {OpenTelemetryApi.Attributes} [counterAttributes] + */ +function recordValue(counterName, value, counterAttributes) { + const counter = COUNTERS.get(counterName); + if (!counter?.histogram) { + throw new Error('Unknown counter: ' + counterName); + } + counter.histogram.record(value, counterAttributes); +} + +let lastForceFlushTime = 0; +/** @type {PromiseWithResolvers.PromiseWithResolvers?} */ +let flushInProgress = null; +let tryFlushEnabled = true; + +/** + * Try to flush any as-yet-unsent counters to cloud montioring. + * if setTryFlushEnabled(false) has been called, this function is a no-op. + * + * Will only actually call forceFlush once every MIN_FORCE_FLUSH_INTERVAL + * seconds. It will retry if an error is detected during flushing. + * + * (Note on transient errors: in a long running process, these are not an + * issue as periodic export will succeed next time, but in short-lived + * processes there is not a 'next time', so we need to check for errors + * and retry) + */ +async function tryFlush() { + // check for if we are initialised + await pendingInit?.promise; + + if (!tryFlushEnabled || !EXPORTER_PARAMETERS[exporterMode].FLUSH_ENABLED) { + // flushing disabled, do nothing! + return; + } + + // Avoid simultaneous flushing! + if (flushInProgress) { + return await flushInProgress.promise; + } + + flushInProgress = PromiseWithResolvers.create(); + + try { + // If flushed recently, wait for the min interval to pass. + const millisUntilNextForceFlush = + lastForceFlushTime + + EXPORTER_PARAMETERS[exporterMode].FLUSH_MIN_INTERVAL - + Date.now(); + + if (millisUntilNextForceFlush > 0) { + // wait until we can force flush again! + logger.debug('Counters.tryFlush() waiting until flushing again'); + await setTimeout(millisUntilNextForceFlush); + } + + // OpenTelemetry's forceFlush() will always succeed, even if the backend + // fails and reports an error... + // + // So we use the OpenTelemetry Global Error Handler installed above + // to keep a count of the number of errors reported, and if an error + // is reported during a flush, we wait a while and try again. + // Not perfect, but the best we can do. + // + // To avoid end-users seeing these errors, we supress error messages + // until the very last flush attempt. + // + // Note that if the OpenTelemetry metrics are exported to Google Cloud + // Monitoring, the first time a counter is used, it will fail to be + // exported and will need to be retried. + let attempts = EXPORTER_PARAMETERS[exporterMode].FLUSH_MAX_ATTEMPTS; + while (attempts > 0) { + const oldOpenTelemetryErrorCount = openTelemetryErrorCount; + + // Suppress OTEL Diag error messages on all but the last flush attempt. + DIAG_BUNYAN_LOGGER.suppressErrors = attempts > 1; + await meterProvider.forceFlush(); + DIAG_BUNYAN_LOGGER.suppressErrors = false; + + lastForceFlushTime = Date.now(); + + if (oldOpenTelemetryErrorCount === openTelemetryErrorCount) { + // success! + return; + } else { + logger.warn('Opentelemetry reported errors during flushing, retrying.'); + } + await setTimeout(EXPORTER_PARAMETERS[exporterMode].FLUSH_MIN_INTERVAL); + attempts--; + } + if (attempts <= 0) { + logger.error( + `Failed to flush counters after ${EXPORTER_PARAMETERS[exporterMode].FLUSH_MAX_ATTEMPTS} attempts - see OpenTelemetry logging`, + ); + } + } catch (err) { + logger.error({err: err, message: `Error while flushing counters: ${err}`}); + } finally { + // Release any waiters... + flushInProgress.resolve(null); + flushInProgress = null; + } +} + +/** + * Specify whether the tryFlush function should try to flush or not. + * + * In long-running processes, disabling flushing will give better results + * while in short-lived processes, without flushing, counters may not + * be reported to cloud monitoring. + * + * @param {boolean} newTryFlushEnabled + */ +function setTryFlushEnabled(newTryFlushEnabled) { + tryFlushEnabled = !!newTryFlushEnabled; +} + +module.exports = { + COUNTER_ATTRIBUTE_NAMES, + createCounters, + incCounter, + recordValue, + tryFlush, + setTryFlushEnabled, +}; diff --git a/src/autoscaler-common/logger.js b/src/autoscaler-common/logger.js new file mode 100644 index 0000000..a3b7d9f --- /dev/null +++ b/src/autoscaler-common/logger.js @@ -0,0 +1,158 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Create a Pino Logger using structured logging to stdout. + */ + +const {pino} = require('pino'); +const process = require('node:process'); +const packageJson = require('../../package.json'); +const {EventId} = require('eventid'); + +// JavaScript date has a very coarse granularity (millisecond), which makes +// it quite likely that multiple log entries would have the same timestamp. +// The Logging API doesn't guarantee to preserve insertion order for entries +// with the same timestamp. The service does use `insertId` as a secondary +// ordering for entries with the same timestamp. `insertId` needs to be +// globally unique (within the project) however. +// +// We use a globally unique monotonically increasing EventId as the +// insertId. +// @see https://github.com/googleapis/nodejs-logging/blob/main/src/entry.ts#L198 +const eventId = new EventId(); + +/** + * Convert Pino log level to GCP log level + * + * @see https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity + * @type {Map} + */ +const PinoLevelToGcpSeverity = new Map([ + ['trace', 'DEBUG'], + ['debug', 'DEBUG'], + ['info', 'INFO'], + ['warn', 'WARNING'], + ['error', 'ERROR'], + ['fatal', 'CRITICAL'], +]); + +const serviceContext = { + service: packageJson.name, + version: packageJson.version, +}; + +/** @type {pino.Level} */ +const DEFAULT_LOG_LEVEL = 'debug'; +/** @type {pino.Level} */ +const DEFAULT_TEST_LOG_LEVEL = 'fatal'; + +/** + * Return a pino log level based on environment variable LOG_LEVEL which can be + * either a GCP or a pino log level. + * + * If not defined, use DEBUG normally, or CRITICAL/fatal in NODE_ENV=test mode + * + * @return {pino.Level} + */ +function getLogLevel() { + const envLogLevel = process.env.LOG_LEVEL + ? process.env.LOG_LEVEL.toLowerCase() + : null; + + // Convert the env varable to the pino level, or undefined. + const pinoLevel = + envLogLevel != null + ? [...PinoLevelToGcpSeverity.entries()].filter( + (e) => + e[0].toLowerCase() === envLogLevel || + e[1].toLowerCase() === envLogLevel, + )[0]?.[0] + : undefined; + + if (pinoLevel) { + return pinoLevel; + } else if (process.env.NODE_ENV?.toLowerCase() === 'test') { + return DEFAULT_TEST_LOG_LEVEL; + } else { + return DEFAULT_LOG_LEVEL; + } +} + +/** + * Convert pino log level to Google severity + * @param {string} label + * @param {number} number + * @return {Object} + */ +function pinoLevelToGcpSeverity(label, number) { + const pinoLevel = /** @type {pino.Level} */ (label); + const gcpLevel = PinoLevelToGcpSeverity.get(pinoLevel) ?? 'DEBUG'; + return { + severity: gcpLevel, + level: number, + // ...errorTypeProperty, + }; +} + +/** + * Create a JSON fragment string containing the timestamp in Stackdriver format: + *
+ * "timestamp": {
+ *   "seconds": nnnnn,
+ *   "nanos": nnnnn
+ * }`
+ * 
+ * @return {string} + */ +function getGcpLoggingTimestamp() { + const millis = Date.now(); + return `,"timestamp":{"seconds":${Math.floor(millis / 1000)},"nanos": ${millis % 1000}000000}`; +} + +/** + * Add stack traces. insertId and serviceContext to logging + * + * @param {Record} entry + * @return {Record} + */ +function formatLogObject(entry) { + if (entry.err instanceof Error && entry.err.stack) { + entry.stack_trace = entry.err.stack; + } + entry.serviceContext = serviceContext; + // Sequential insertId + entry['logging.googleapis.com/insertId'] = eventId.new(); + return entry; +} + +/** + * Config object for Pino. + * + * @type {pino.LoggerOptions} + */ +const pinoConfig = { + level: getLogLevel(), + // Use 'message' instead of 'msg' as error message key. + messageKey: 'message', + formatters: { + level: pinoLevelToGcpSeverity, + log: formatLogObject, + }, + timestamp: getGcpLoggingTimestamp, +}; + +module.exports = { + logger: pino(pinoConfig), +}; diff --git a/src/autoscaler-common/promiseWithResolvers.js b/src/autoscaler-common/promiseWithResolvers.js new file mode 100644 index 0000000..d3640cc --- /dev/null +++ b/src/autoscaler-common/promiseWithResolvers.js @@ -0,0 +1,43 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/** @typedef {{ + * promise: Promise; + * resolve: (value: any) => void; + * reject: (reason: any) => void; + * }} PromiseWithResolvers */ + +/** + * Node version of ECMA262's Promise.withResolvers() + * @see https://tc39.es/proposal-promise-with-resolvers/#sec-promise.withResolvers + * + * @return {PromiseWithResolvers} + */ +function promiseWithResolvers() { + /** @type { (value: any) => void} */ + let resolve; + /** @type { (reason: any) => void} */ + let reject; + const promise = new Promise(function (res, rej) { + resolve = res; + reject = rej; + }); + // @ts-ignore used-before-assigned + return {promise, resolve, reject}; +} + +module.exports = { + create: promiseWithResolvers, +}; diff --git a/src/autoscaler-common/types.js b/src/autoscaler-common/types.js new file mode 100644 index 0000000..c0e7c6f --- /dev/null +++ b/src/autoscaler-common/types.js @@ -0,0 +1,146 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Common types for the autoscaler. + * + * Any changes to the AutoscalerMemorystoreCluster types also need to be + * reflected in autoscaler-config.schema.json, and in + * autoscaler-common/types.js. + */ + +/** + * @enum {string} + */ +const AutoscalerUnits = { + SHARDS: 'SHARDS', +}; + +/** + * @enum {string} + */ +const AutoscalerDirection = { + IN: 'IN', + OUT: 'OUT', + NONE: 'NONE', +}; + +/** + * @typedef {{ + * currentSize: number, + * shardCount: number, + * sizeGb: number, + * }} MemorystoreClusterMetadata + */ + +/** + * @typedef {{ + * name: string, + * filter: string, + * reducer: string, + * aligner: string, + * period: number, + * }} MemorystoreClusterMetric + */ + +/** + * @typedef {{ + * name: string, + * value: number, + * threshold?: number, + * }} MemorystoreClusterMetricValue + */ + +/** + * @typedef {{ + * name: string, + * instanceId?: string, + * databaseId?: string + * }} StateDatabaseConfig + */ + +/** + * @typedef {import('json-rules-engine').RuleProperties} Rule + */ + +/** + * @typedef {{ + * projectId: string, + * regionId: string, + * clusterId: string, + * units: AutoscalerUnits, + * minSize: number, + * maxSize: number, + * scalingProfile: string, + * scalingMethod: string, + * stepSize: number, + * scaleInLimit?: number, + * scaleOutLimit?: number, + * minFreeMemoryPercent: number, + * scaleOutCoolingMinutes: number, + * scaleInCoolingMinutes: number, + * stateProjectId?: string, + * stateDatabase?: StateDatabaseConfig, + * scalerPubSubTopic?: string, + * downstreamPubSubTopic?: string, + * metrics: (MemorystoreClusterMetric | MemorystoreClusterMetricValue)[], + * scalingRules?: Rule[] + * }} MemorystoreClusterConfig; + */ + +/** + * @typedef {MemorystoreClusterConfig & MemorystoreClusterMetadata + * } AutoscalerMemorystoreCluster; + */ + +/** + * @typedef {MemorystoreClusterMetricValue[]} ScalingMetricList + */ + +/** + * @typedef {{[x:string]: import('json-rules-engine').RuleProperties}} RuleSet + */ + +/** + * @typedef {import('json-rules-engine').ConditionProperties} + * ConditionProperties + */ + +/** + * Extends ConditionProperty with the facts and fact results. + * Workaround because json-rules-engine typing does not match the actual + * signature nor exports the Condition class directly. + * @link https://github.com/CacheControl/json-rules-engine/issues/253 + * @typedef {{ + * factResult?: number, + * result?: boolean, + * }} AdditionalConditionProperties + */ + +/** + * @typedef {ConditionProperties & AdditionalConditionProperties} Condition + */ + +/** + * @typedef {{ + * firingRuleCount: !Object, + * matchedConditions: !Object, + * scalingMetrics: !Object> + * }} RuleEngineAnalysis + */ + +module.exports = { + AutoscalerUnits, + AutoscalerDirection, +}; diff --git a/src/forwarder/README.md b/src/forwarder/README.md new file mode 100644 index 0000000..db526b8 --- /dev/null +++ b/src/forwarder/README.md @@ -0,0 +1,72 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Forward messages from Cloud Scheduler to the Poller function topic. +
+ Home + · + Poller component + · + Scaler component + · + Forwarder component + · + Terraform configuration + · + Monitoring +

+

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Architecture](#architecture) +* [Configuration parameters](#configuration-parameters) + * [Required](#required) + +## Overview + +The Forwarder function takes messages published to PubSub from Cloud Scheduler, +checks their JSON syntax and forwards them to the Poller PubSub topic. The topic +can belong to a different project from the Scheduler. + +## Architecture + +![architecture-forwarder](../../resources/architecture-forwarder.png) + +The Memorystore Cluster instances reside in a given application project. + +1. Cloud Scheduler lives in the same project as the Memorystore Cluster + instances. + +2. Cloud Scheduler publishes its messages to the Forwarder topic in the same project. + +3. The Forwarder Cloud Function reads messages from the Forwarder topic, and + +4. Forwards them to the Polling topic. The Polling topic resides in a + different project. + +5. The Poller function reads the messages from the polling topic and + further continues the process as described in + the [main architecture section](../../terraform/cloud-functions/README.md#architecture). + +It is important to note that Autoscaler infrastructure is now distributed across +several projects. *The core components reside in the Autoscaler project* An +instance of Cloud Scheduler, the Forwarder topic and the Forwarder Function +reside in each of the application projects. + +## Configuration parameters + +Using the Forward function forwards to the PubSub specified in the environment +variable `POLLER_TOPIC`. + +### Required + +| Key | Description | +| -------------- | ------------------------------------------- | +| `POLLER_TOPIC` | PubSub topic the Poller function listens on | diff --git a/src/forwarder/index.js b/src/forwarder/index.js new file mode 100644 index 0000000..43c3514 --- /dev/null +++ b/src/forwarder/index.js @@ -0,0 +1,110 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Autoscaler Forwarder function + * + * * Forwards PubSub messages from the Scheduler topic to the Poller topic. + */ +// eslint-disable-next-line no-unused-vars -- for type checking only. +const express = require('express'); +const {PubSub} = require('@google-cloud/pubsub'); +const {logger} = require('../autoscaler-common/logger'); +const assertDefined = require('../autoscaler-common/assert-defined'); + +// GCP service clients +const pubSub = new PubSub(); + +/** + * Handle the forwarder request from HTTP + * + * For testing purposes - uses a fixed message. + * + * @param {express.Request} req + * @param {express.Response} res + */ +async function forwardFromHTTP(req, res) { + const payloadString = + '[{ ' + + ' "projectId": "memorystore-cluster-autoscaler", ' + + ' "instanceId": "my-memorystore-cluster", ' + + ' "scalerPubSubTopic": ' + + '"projects/memorystore-cluster-autoscaler/topics/my-scaling-topic", ' + + ' "minSize": 1, ' + + ' "maxSize": 3, ' + + ' "stateProjectId" : "memorystore-cluster-autoscaler" ' + + '}]'; + try { + const payload = Buffer.from(payloadString, 'utf8'); + + JSON.parse(payload.toString()); // Log exception in App project if payload + // cannot be parsed + + const pollerTopicName = assertDefined( + process.env.POLLER_TOPIC, + 'POLLER_TOPIC environment variable', + ); + + const pollerTopic = pubSub.topic(pollerTopicName); + pollerTopic.publishMessage({data: payload}); + logger.debug({ + message: `Poll request forwarded to PubSub Topic ${pollerTopicName}`, + }); + res.status(200).end(); + } catch (err) { + logger.error({ + message: `An error occurred in the Autoscaler forwarder (HTTP): ${err}`, + err: err, + payload: payloadString, + }); + res.status(500).end('An exception occurred'); + } +} + +/** + * Handle the Forwarder request from PubSub + * + * @param {any} pubSubEvent + * @param {*} context + */ +async function forwardFromPubSub(pubSubEvent, context) { + let payload; + try { + payload = Buffer.from(pubSubEvent.data, 'base64'); + JSON.parse(payload.toString()); // Log exception in App project if payload + // cannot be parsed + + const pollerTopicName = assertDefined( + process.env.POLLER_TOPIC, + 'POLLER_TOPIC environment variable', + ); + const pollerTopic = pubSub.topic(pollerTopicName); + pollerTopic.publishMessage({data: payload}); + logger.debug({ + message: `Poll request forwarded to PubSub Topic ${pollerTopicName}`, + }); + } catch (err) { + logger.error({ + message: `An error occurred in the Autoscaler forwarder (PubSub): ${err}`, + err: err, + payload: payload, + }); + } +} + +module.exports = { + forwardFromHTTP, + forwardFromPubSub, +}; diff --git a/src/functions.js b/src/functions.js new file mode 100644 index 0000000..93730bd --- /dev/null +++ b/src/functions.js @@ -0,0 +1,42 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/** + * @fileoverview + * Cloud Memorystore Cluster Autoscaler. + * + * Entry points for Cloud Run functions invocations. + */ + +const poller = require('./poller/poller-core'); +const scaler = require('./scaler/scaler-core'); +const forwarder = require('./forwarder'); +const {logger} = require('./autoscaler-common/logger'); +const {version: packageVersion} = require('../package.json'); + +logger.info(`Cloud Memorystore Cluster autoscaler v${packageVersion} started`); + +module.exports = { + checkMemorystoreClusterScaleMetricsPubSub: + poller.checkMemorystoreClusterScaleMetricsPubSub, + checkMemorystoreClusterScaleMetricsHTTP: + poller.checkMemorystoreClusterScaleMetricsHTTP, + + scaleMemorystoreClusterPubSub: scaler.scaleMemorystoreClusterPubSub, + scaleMemorystoreClusterHTTP: scaler.scaleMemorystoreClusterHTTP, + + forwardFromPubSub: forwarder.forwardFromPubSub, + forwardFromHTTP: forwarder.forwardFromHTTP, +}; diff --git a/src/poller/README.md b/src/poller/README.md new file mode 100644 index 0000000..bae71ca --- /dev/null +++ b/src/poller/README.md @@ -0,0 +1,219 @@ +
+

+

OSS Memorystore Autoscaler

+ Autoscaler + +

+ + Retrieve metrics for one or more Memorystore Cluster Instances +
+ Home + · + Poller component + · + Scaler component + · + Forwarder component + · + Terraform configuration + · + Monitoring +

+

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Configuration parameters](#configuration-parameters) + * [Required](#required) + * [Optional](#optional) +* [Example JSON configuration for Cloud Run functions](#example-json-configuration-for-cloud-run-functions) +* [Example YAML ConfigMap for Kubernetes deployment](#example-yaml-configmap-for-kubernetes-deployment) + +## Overview + +The Poller component takes an array of Memorystore Cluster instances and obtains +load metrics for each of them from [Cloud Monitoring][cloud-monitoring]. This +array may come from the payload of a Cloud PubSub message or from configuration +held in a [Kubernetes ConfigMap][configmap], depending on configuration. + +Then for each Memorystore cluster instance it publishes a message via the +specified Cloud PubSub topic or via API call (in a Kubernetes configuration), which +includes the metrics and part of the configuration for the scaling operation. + +The Scaler component receives the message, compares the metric values with the +recommended thresholds. If any of the values fall outside this range, the Scaler +component will adjust the number of nodes in the Memorystore cluster +accordingly. + +## Configuration parameters + +The following are the configuration parameters consumed by the Poller component. +Some of these parameters are forwarded to the Scaler component as well. + +In the case of the +[Cloud Run functions](../../terraform/cloud-functions/README.md#configuration) +deployment, the parameters are defined using the JSON payload of the PubSub +message that is published by the Cloud Scheduler job. + +In the case of the +[Kubernetes deployment](../../terraform/gke/README.md#configuration), the +parameters are defined using a [Kubernetes ConfigMap][configmap] that is loaded +by the Kubernetes Cron job. + +The Autoscaler JSON (for Cloud Run functions) or YAML (for GKE) configuration +can be validated by running the command: + +```shell +npm install +npm run validate-config-file -- path/to/config_file +``` + + + +### Required + +| Key | Description | +| ------------------- | ----------- | +| `projectId` | Project ID of the Memorystore Cluster to be monitored by the Autoscaler | +| `regionId` | Region ID of the Memorystore Cluster to be monitored by the Autoscaler | +| `instanceId` | Instance ID of the Memorystore Cluster to be monitored by the Autoscaler | + +### Required for a Cloud Run functions deployment + +| Key | Description | +| ------------------- | ----------- | +| `scalerPubSubTopic` | PubSub topic for the Poller function to publish messages for the Scaler function. The topic must be in the format `projects/{projects}/topics/{topicId}`. | + +### Optional + +| Key | Default Value | Description | +| ------------------------ | ---------------- | ----------- | +| `units` | `SHARDS` | Specifies the units for capacity. Currently `SHARDS` is the only valid unit. | +| `minSize` | 3 | Minimum number of Memorystore Cluster shards that the instance can be scaled IN to. | +| `maxSize` | 10 | Maximum number of Memorystore Cluster shards that the instance can be scaled OUT to. | +| `scalingProfile` | `CPU_AND_MEMORY` | Scaling profiles that should be used. Options are: `CPU_AND_MEMORY`, `CPU`, `MEMORY`, or `CUSTOM`. See the [scaling profiles section][autoscaler-scaling-profiles] in the Scaler component page for more information. | +| `scalingMethod` | `STEPWISE` | Scaling method that should be used. Options are: `STEPWISE`, `DIRECT` and `LINEAR`. See the [scaling methods section][autoscaler-scaler-methods] in the Scaler component page for more information. | +| `scalingRules` | `undefined` | Scaling rules to be used when the `CUSTOM` scaling profile is supplied. See the [scaling profiles section][autoscaler-scaling-profiles] in the Scaler component page for more information. | +| `stepSize` | 1 | Number of shards that should be added or removed when scaling with the `STEPWISE` method. | +| `scaleInLimit` | `undefined` | Maximum number of shards that can be removed on a single step when scaling with the `LINEAR` method. If `undefined` or `0`, it will not limit the number of shards. | +| `scaleOutLimit` | `undefined` | Maximum number of shards that can be added on a single step when scaling with the `LINEAR` method. If `undefined` or `0`, it will not limit the number of shards. | +| `minFreeMemoryPercent` | 30 | Percentage of total memory to maintain as safety (i.e. free, unused) headroom. | +| `scaleOutCoolingMinutes` | 5 | Minutes to wait after scaling IN or OUT before a scale OUT event can be processed. | +| `scaleInCoolingMinutes` | 30 | Minutes to wait after scaling IN or OUT before a scale IN event can be processed. | +| `stateProjectId` | `${projectId}` | The project ID where the Autoscaler state will be persisted. By default it is persisted using [Cloud Firestore][cloud-firestore] in the same project as the Memorystore Cluster instance. | +| `stateDatabase` | Object | An Object that can override the database for managing the state of the Autoscaler. The default database is Firestore. Refer to the [state database](#state-database) for details. | +| `downstreamPubSubTopic` | `undefined` | Set this parameter to `projects/${projectId}/topics/downstream-topic` if you want the the Autoscaler to publish events that can be consumed by downstream applications. See [Downstream messaging](../scaler/README.md#downstream-messaging) for more information. | + +## State Database + +The table describes the objects used to specify the database +for managing the state of the Autoscaler. + +| Key | Default | Description | +| -------------------------- | ------------ | ----------- | +| `name` | `firestore` | Name of the database for managing the state of the Autoscaler. By default, Firestore is used. The currently supported values are `firestore` and `spanner`. | + +### State Management in Cloud Spanner + +If the value of `name` is `spanner`, the following values are required. + +| Key | Description | +| -------------------------- | ----------- | +| `instanceId` | The instance id of Memorystore Cluster which you want to manage the state. | +| `databaseId` | The database id of Memorystore Cluster instance which you want to manage the state. | + +When using Cloud Spanner to manage the state, a table with the following +DDL is created at runtime. + +```sql +CREATE TABLE memorystoreClusterAutoscaler ( + id STRING(MAX), + lastScalingTimestamp TIMESTAMP, + createdOn TIMESTAMP, + updatedOn TIMESTAMP, + lastScalingCompleteTimestamp TIMESTAMP, + scalingOperationId STRING(MAX), + scalingRequestedSize INT64, + scalingPreviousSize INT64, + scalingMethod STRING(MAX), +) PRIMARY KEY (id) +``` + +## Example JSON configuration for Cloud Run functions + +```json +[ + { + "projectId": "memorystore-cluster-project-id", + "regionId": "us-central1", + "clusterId": "autoscaler-target-memorystore-cluster", + "scalingMethod": "STEPWISE", + "units": "SHARDS", + "maxSize": 10, + "minSize": 3, + "scalerPubSubTopic": "projects/memorystore-cluster-project-id/topics/scaler-topic", + "stateDatabase": { + "name": "firestore" + } + } +] +``` + +By default, the JSON configuration is managed by Terraform, in the +[autoscaler-scheduler][autoscaler-scheduler-tf] module. If you would +like to manage this configuration directly (i.e. outside Terraform), +then you will need to configure the Terraform lifecycle `ignore_changes` +meta-argument by uncommenting the appropriate line as described in the above +file. This is so that subsequent Terraform operations do not reset your +configuration to the supplied defaults. + +To retrieve the current configuration, run the following command: + +```sh +gcloud scheduler jobs describe poll-cluster-metrics --location=${REGION} \ + --format="value(pubsubTarget.data)" | base64 -d | python3 -m json.tool | tee autoscaler-config.json +``` + +To directly update the configuration using the contents of the +`autoscaler-config.json` file, run the following command: + +```sh +gcloud scheduler jobs update pubsub poll-cluster-metrics --location=${REGION} \ + --message-body-from-file=autoscaler-config.json +``` + +## Example YAML ConfigMap for Kubernetes deployment + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: autoscaler-config + namespace: memorystore-cluster-autoscaler +data: + autoscaler-config.yaml: | + --- + - projectId: memorystore-cluster-project-id + regionId: us-central1 + clusterId: autoscaler-target-memorystore-cluster + scalingMethod: STEPWISE + units: SHARDS + minSize: 3 + maxSize: 10 + stateDatabase: + name: firestore +``` + + + +[autoscaler-scaler-methods]: ../scaler/README.md#scaling-methods +[autoscaler-scaling-profiles]: ../scaler/README.md#scaling-profiles +[autoscaler-scheduler-tf]: ../../terraform/modules/autoscaler-scheduler/main.tf +[cloud-firestore]: https://cloud.google.com/firestore +[cloud-monitoring]: https://cloud.google.com/monitoring +[configmap]: https://kubernetes.io/docs/concepts/configuration/configmap diff --git a/src/poller/poller-core/config-validator.js b/src/poller/poller-core/config-validator.js new file mode 100644 index 0000000..744cd4b --- /dev/null +++ b/src/poller/poller-core/config-validator.js @@ -0,0 +1,241 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/** + * @fileoverview Validates a given configuration against the JSON schema. + */ + +const Ajv = require('ajv').default; +const fs = require('fs/promises'); +const yaml = require('js-yaml'); + +/** + * @typedef {import('../../autoscaler-common/types').AutoscalerMemorystoreCluster + * } AutoscalerMemorystoreCluster + * + * @typedef {import('ajv').ValidateFunction} ValidateFunction + */ + +/** + * Error thrown when validation fails. + */ +class ValidationError extends Error { + /** + * @param {string} errors Human readable string with all errors listed. + */ + constructor(errors) { + super(errors); + } +} + +/** + * Encapsulates the Ajv validator initialzation and checks. + */ +class ConfigValidator { + /** Creates the class launches async initialization. */ + constructor() { + /** @type {ValidateFunction} */ + this.ajvConfigValidator; + + /** @type {Ajv} */ + this.ajv; + + this.pendingInit = this.initAsync(); + } + + /** + * Performs asynchronous initialization. + * + * @return {Promise} + */ + async initAsync() { + const tmpAjv = new Ajv({allErrors: true}); + + const configSchema = await fs.readFile( + 'autoscaler-config.schema.json', + 'utf-8', + ); + + const schema = JSON.parse(configSchema); + this.ajvConfigValidator = tmpAjv.compile(schema); + this.ajv = tmpAjv; + } + + /** + * Validates the given object against the Memorystore Config schema. + * + * @param {AutoscalerMemorystoreCluster[]} clusters + */ + async assertValidConfig(clusters) { + await this.pendingInit; + const valid = this.ajvConfigValidator(clusters); + if (!valid) { + throw new ValidationError( + 'Invalid Autoscaler Configuration parameters:\n' + + this.ajv.errorsText(this.ajvConfigValidator.errors, { + separator: '\n', + dataVar: 'MemorystoreConfig', + }), + ); + } + } + + /** + * Parses the given string as JSON and validate it against the SannerConfig + * schema. Throws an Error if the config is not valid. + * + * @param {string} jsonString + * @return {Promise} + */ + async parseAndAssertValidConfig(jsonString) { + let configJson; + try { + configJson = JSON.parse(jsonString); + } catch (e) { + throw new Error(`Invalid JSON in Autoscaler configuration: ${e}`); + } + await this.assertValidConfig(configJson); + return configJson; + } +} + +/** + * Validates the specified Memorystore Autoscaler JSON configuration file. + * Throws an Error and reports to console if the config is not valid. + * + * @param {ConfigValidator} configValidator + * @param {string} filename + */ +async function assertValidJsonFile(configValidator, filename) { + try { + const configText = await fs.readFile(filename, 'utf-8'); + await configValidator.parseAndAssertValidConfig(configText); + } catch (e) { + if (e instanceof ValidationError) { + console.error( + `Validation of config in file ${filename} failed:\n${e.message}`, + ); + } else { + console.error(`Processing of config in file ${filename} failed: ${e}`); + } + throw new Error(`${filename} Failed validation`); + } +} + +/** + * Validates all the Memorystore Autoscaler YAML config files specified in the + * GKE configMap. Throws an Error and reports + * to console if any of the configmaps do not pass validation. + * + * @param {ConfigValidator} configValidator + * @param {string} filename + */ +async function assertValidGkeConfigMapFile(configValidator, filename) { + let configMap; + + try { + const configText = await fs.readFile(filename, 'utf-8'); + configMap = /** @type {any} */ (yaml.load(configText)); + } catch (e) { + console.error(`Could not parse YAML from ${filename}: ${e}`); + throw e; + } + + if (configMap.kind !== 'ConfigMap') { + console.error(`${filename} is not a GKE ConfigMap`); + throw new Error(`${filename} is not a GKE ConfigMap`); + } + + let success = true; + for (const configMapFile of Object.keys(configMap.data)) { + const configMapData = configMap.data[configMapFile]; + try { + const memorystoreConfig = yaml.load(configMapData); + await configValidator.parseAndAssertValidConfig( + JSON.stringify(memorystoreConfig), + ); + } catch (e) { + if (e instanceof ValidationError) { + console.error( + `Validation of configMap entry data.${configMapFile} in file ${filename} failed:\n${e.message}`, + ); + } else if (e instanceof yaml.YAMLException) { + console.error( + `Could not parse YAML from value data.${configMapFile} in ${filename}: ${e}`, + ); + } else { + console.error( + `Processing of configMap entry data.${configMapFile} in file ${filename} failed: ${e}`, + ); + } + success = false; + } + } + if (!success) { + throw new Error(`${filename} Failed validation`); + } +} + +/** + * Validates a configuration file passed in on the command line. + */ +function main() { + if ( + process.argv.length <= 1 || + process.argv[1] === '-h' || + process.argv[1] === '--help' + ) { + console.log('Usage: validate-config-file CONFIG_FILE_NAME'); + console.log( + 'Validates that the specified Autoscaler JSON config is defined correctly', + ); + process.exit(1); + } + + const configValidator = new ConfigValidator(); + + if (process.argv[1].toLowerCase().endsWith('.yaml')) { + assertValidGkeConfigMapFile(configValidator, process.argv[1]).then( + () => process.exit(0), + (e) => { + console.error(e); + process.exit(1); + }, + ); + } else if (process.argv[1].toLowerCase().endsWith('.json')) { + assertValidJsonFile(configValidator, process.argv[1]).then( + () => process.exit(0), + (e) => { + console.error(e); + process.exit(1); + }, + ); + } else { + console.log( + `filename ${process.argv[1]} must either be JSON (.json) or a YAML configmap (.yaml) file`, + ); + process.exit(1); + } +} + +module.exports = { + ConfigValidator, + ValidationError, + main, + TEST_ONLY: { + assertValidGkeConfigMapFile, + assertValidJsonFile, + }, +}; diff --git a/src/poller/poller-core/counters.js b/src/poller/poller-core/counters.js new file mode 100644 index 0000000..1398f5b --- /dev/null +++ b/src/poller/poller-core/counters.js @@ -0,0 +1,131 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Autoscaler Counters package + * + * Publishes Counters to Cloud Monitoring + * + */ +const CountersBase = require('../../autoscaler-common/counters-base.js'); + +const COUNTERS_PREFIX = 'poller/'; + +const COUNTER_NAMES = { + POLLING_SUCCESS: COUNTERS_PREFIX + 'polling-success', + POLLING_FAILED: COUNTERS_PREFIX + 'polling-failed', + REQUESTS_SUCCESS: COUNTERS_PREFIX + 'requests-success', + REQUESTS_FAILED: COUNTERS_PREFIX + 'requests-failed', +}; + +/** + * @typedef {import('../../autoscaler-common/types.js') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + */ +/** + * @typedef {import('@opentelemetry/api').Attributes} Attributes + */ + +/** + * @type {import('../../autoscaler-common/counters-base.js') + * .CounterDefinition[]} + */ +const COUNTERS = [ + { + counterName: COUNTER_NAMES.POLLING_SUCCESS, + counterDesc: + 'The number of Memorystore Cluster polling events that succeeded', + }, + { + counterName: COUNTER_NAMES.POLLING_FAILED, + counterDesc: 'The number of Memorystore Cluster polling events that failed', + }, + { + counterName: COUNTER_NAMES.REQUESTS_SUCCESS, + counterDesc: 'The number of polling request messages handled successfully', + }, + { + counterName: COUNTER_NAMES.REQUESTS_FAILED, + counterDesc: 'The number of polling request messages that failed', + }, +]; + +const pendingInit = CountersBase.createCounters(COUNTERS); + +/** + * Build an attribute object for the counter + * + * @private + * @param {AutoscalerMemorystoreCluster} cluster config object + * @return {Attributes} + */ +function _getCounterAttributes(cluster) { + return { + [CountersBase.COUNTER_ATTRIBUTE_NAMES.CLUSTER_PROJECT_ID]: + cluster.projectId, + [CountersBase.COUNTER_ATTRIBUTE_NAMES.CLUSTER_INSTANCE_ID]: + cluster.clusterId, + }; +} + +/** + * Increment polling success counter + * + * @param {AutoscalerMemorystoreCluster} cluster config object + */ +async function incPollingSuccessCounter(cluster) { + await pendingInit; + CountersBase.incCounter( + COUNTER_NAMES.POLLING_SUCCESS, + _getCounterAttributes(cluster), + ); +} + +/** + * Increment polling failed counter + * + * @param {AutoscalerMemorystoreCluster} cluster config object + */ +async function incPollingFailedCounter(cluster) { + await pendingInit; + CountersBase.incCounter( + COUNTER_NAMES.POLLING_FAILED, + _getCounterAttributes(cluster), + ); +} + +/** + * Increment messages success counter + */ +async function incRequestsSuccessCounter() { + await pendingInit; + CountersBase.incCounter(COUNTER_NAMES.REQUESTS_SUCCESS); +} + +/** + * Increment messages failed counter + */ +async function incRequestsFailedCounter() { + await pendingInit; + CountersBase.incCounter(COUNTER_NAMES.REQUESTS_FAILED); +} + +module.exports = { + incPollingSuccessCounter, + incPollingFailedCounter, + incRequestsSuccessCounter, + incRequestsFailedCounter, + tryFlush: CountersBase.tryFlush, +}; diff --git a/src/poller/poller-core/index.js b/src/poller/poller-core/index.js new file mode 100644 index 0000000..0205cd8 --- /dev/null +++ b/src/poller/poller-core/index.js @@ -0,0 +1,617 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Autoscaler Poller function + * + * * Polls one or more Memorystore Cluster instances for metrics. + * * Sends metrics to Scaler to determine if an instance needs to be autoscaled + */ + +// eslint-disable-next-line no-unused-vars -- for type checking only. +const express = require('express'); +const monitoring = require('@google-cloud/monitoring'); +const {logger} = require('../../autoscaler-common/logger'); +const {PubSub} = require('@google-cloud/pubsub'); +const {CloudRedisClusterClient} = require('@google-cloud/redis-cluster'); +const Counters = require('./counters.js'); +const {AutoscalerUnits} = require('../../autoscaler-common/types'); +const { + CLUSTER_SIZE_MIN, + CLUSTER_SIZE_INVALID, +} = require('../../autoscaler-common/config-parameters'); +const assertDefined = require('../../autoscaler-common/assert-defined'); +const {version: packageVersion} = require('../../../package.json'); +const {ConfigValidator} = require('./config-validator'); + +/** + * @typedef {import('../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + * @typedef {import('../../autoscaler-common/types').MemorystoreClusterConfig + * } MemorystoreClusterConfig + * @typedef {import('../../autoscaler-common/types').MemorystoreClusterMetadata + * } MemorystoreClusterMetadata + * @typedef {import('../../autoscaler-common/types') + * .MemorystoreClusterMetricValue} MemorystoreClusterMetricValue + * @typedef {import('../../autoscaler-common/types').MemorystoreClusterMetric + * } MemorystoreClusterMetric + */ + +const metricsClient = new monitoring.MetricServiceClient(); +const pubSub = new PubSub(); +const memorystoreClusterClient = new CloudRedisClusterClient({ + libName: 'cloud-solutions', + libVersion: `memorystore-cluster-autoscaler-poller-usage-v${packageVersion}`, +}); +const configValidator = new ConfigValidator(); + +const baseDefaults = { + scaleOutCoolingMinutes: 10, + scaleInCoolingMinutes: 10, + minFreeMemoryPercent: 30, + scalingProfile: 'CPU_AND_MEMORY', + scalingMethod: 'STEPWISE', +}; + +const shardDefaults = { + units: AutoscalerUnits.SHARDS, + minSize: 3, + maxSize: 10, + stepSize: 1, +}; + +/** + * Get metadata for Memorystore cluster + * + * @param {string} projectId + * @param {string} regionId + * @param {string} clusterId + * @param {AutoscalerUnits} units SHARDS + * @return {Promise} + */ +async function getMemorystoreClusterMetadata( + projectId, + regionId, + clusterId, + units, +) { + logger.info({ + message: `----- ${projectId}/${regionId}/${clusterId}: Metadata -----`, + projectId: projectId, + regionId: regionId, + clusterId: clusterId, + }); + + const request = { + name: `projects/${projectId}/locations/${regionId}/clusters/${clusterId}`, + }; + + const [metadata] = await memorystoreClusterClient.getCluster(request); + + logger.debug({ + message: `shardCount: ${metadata['shardCount']}`, + projectId: projectId, + regionID: regionId, + clusterId: clusterId, + }); + logger.debug({ + message: `sizeGb: ${metadata['sizeGb']}`, + projectId: projectId, + regionID: regionId, + clusterId: clusterId, + }); + + const clusterMetadata = { + currentSize: + units === AutoscalerUnits.SHARDS + ? assertDefined(metadata['shardCount']) + : assertDefined(metadata['sizeGb']), + shardCount: assertDefined(metadata['shardCount']), + sizeGb: assertDefined(metadata['sizeGb']), + }; + + return clusterMetadata; +} + +/** + * Post a message to PubSub with the Memorystore cluster and metrics. + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {MemorystoreClusterMetricValue[]} metrics + * @return {Promise} + */ +async function postPubSubMessage(cluster, metrics) { + const topic = pubSub.topic(assertDefined(cluster.scalerPubSubTopic)); + + cluster.metrics = metrics; + const messageBuffer = Buffer.from(JSON.stringify(cluster), 'utf8'); + + return topic + .publishMessage({data: messageBuffer}) + .then(() => + logger.info({ + message: `----- Published message to topic: ${cluster.scalerPubSubTopic}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }), + ) + .catch((err) => { + logger.error({ + message: `An error occurred when publishing the message to \ + ${cluster.scalerPubSubTopic}: ${err}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + err: err, + }); + }); +} + +/** + * Creates the base filter that should be prepended to all metric filters + * @param {string} projectId + * @param {string} regionId + * @param {string} clusterId + * @return {string} filter + */ +function createBaseFilter(projectId, regionId, clusterId) { + return ( + 'resource.type="redis.googleapis.com/Cluster" AND ' + + 'project="' + + projectId + + '" AND ' + + 'resource.labels.location="' + + regionId + + '" AND ' + + 'resource.labels.cluster_id="' + + clusterId + + '" AND ' + ); +} + +/** + * Build the list of metrics to request + * + * @param {string} projectId + * @param {string} regionId + * @param {string} clusterId + * @return {MemorystoreClusterMetric[]} metrics to request + */ +function buildMetrics(projectId, regionId, clusterId) { + const metrics = [ + { + name: 'cpu_maximum_utilization', + filter: + createBaseFilter(projectId, regionId, clusterId) + + 'metric.type="redis.googleapis.com/cluster/cpu/maximum_utilization" ' + + 'AND metric.labels.role="primary"', // Only present for CPU metrics + reducer: 'REDUCE_MEAN', + aligner: 'ALIGN_MAX', + period: 60, + }, + { + name: 'cpu_average_utilization', + filter: + createBaseFilter(projectId, regionId, clusterId) + + 'metric.type="redis.googleapis.com/cluster/cpu/average_utilization" ' + + 'AND metric.labels.role="primary"', // Only present for CPU metrics + reducer: 'REDUCE_MEAN', + aligner: 'ALIGN_MAX', + period: 60, + }, + { + name: 'memory_maximum_utilization', + filter: + createBaseFilter(projectId, regionId, clusterId) + + 'metric.type="redis.googleapis.com/cluster/memory/maximum_utilization"', + reducer: 'REDUCE_MEAN', + aligner: 'ALIGN_MAX', + period: 60, + }, + { + name: 'memory_average_utilization', + filter: + createBaseFilter(projectId, regionId, clusterId) + + 'metric.type="redis.googleapis.com/cluster/memory/average_utilization"', + reducer: 'REDUCE_MEAN', + aligner: 'ALIGN_MAX', + period: 60, + }, + { + name: 'maximum_evicted_keys', + filter: + createBaseFilter(projectId, regionId, clusterId) + + 'metric.type="redis.googleapis.com/cluster/stats/maximum_evicted_keys"', + reducer: 'REDUCE_MEAN', + aligner: 'ALIGN_MAX', + period: 60, + }, + { + name: 'average_evicted_keys', + filter: + createBaseFilter(projectId, regionId, clusterId) + + 'metric.type="redis.googleapis.com/cluster/stats/average_evicted_keys"', + reducer: 'REDUCE_MEAN', + aligner: 'ALIGN_MAX', + period: 60, + }, + ]; + return metrics; +} + +/** + * Get max value of metric over a window + * + * @param {string} projectId + * @param {string} regionId + * @param {string} clusterId + * @param {MemorystoreClusterMetric} metric + * @return {Promise<[number,string]>} + */ +function getMaxMetricValue(projectId, regionId, clusterId, metric) { + const metricWindow = 5; + logger.debug({ + message: `Get max ${metric.name} from ${projectId}/${regionId}/${clusterId} over ${metricWindow} minutes.`, + projectId: projectId, + regionId: regionId, + clusterId: clusterId, + }); + + /** @type {monitoring.protos.google.monitoring.v3.IListTimeSeriesRequest} */ + const request = { + name: 'projects/' + projectId, + filter: metric.filter, + interval: { + startTime: { + seconds: Date.now() / 1000 - metric.period * metricWindow, + }, + endTime: { + seconds: Date.now() / 1000, + }, + }, + aggregation: { + alignmentPeriod: { + seconds: metric.period, + }, + // @ts-ignore + crossSeriesReducer: metric.reducer, + // @ts-ignore + perSeriesAligner: metric.aligner, + groupByFields: ['resource.location'], + }, + view: 'FULL', + }; + + return metricsClient.listTimeSeries(request).then((metricResponses) => { + const resources = metricResponses[0]; + let maxValue = 0.0; + let maxLocation = 'global'; + + for (const resource of resources) { + for (const point of resource.points || []) { + const value = assertDefined(point.value?.doubleValue) * 100; + if (value > maxValue) { + maxValue = value; + if (resource.resource?.labels?.location) { + maxLocation = resource.resource.labels.location; + } + } + } + } + + return [maxValue, maxLocation]; + }); +} + +/** + * Retrive the metrics for a cluster instance + * + * @param {AutoscalerMemorystoreCluster} cluster + * @return {Promise} metric values + */ +async function getMetrics(cluster) { + logger.info({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: Getting Metrics -----`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + + /** @type {MemorystoreClusterMetricValue[]} */ + const metrics = []; + for (const m of cluster.metrics) { + const metric = /** @type {MemorystoreClusterMetric} */ (m); + const [maxMetricValue, maxLocation] = await getMaxMetricValue( + cluster.projectId, + cluster.regionId, + cluster.clusterId, + metric, + ); + + logger.debug({ + message: ` ${metric.name} = ${maxMetricValue}, period = ${metric.period}, location = ${maxLocation}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + + /** @type {MemorystoreClusterMetricValue} */ + const metricsObject = { + name: metric.name, + value: maxMetricValue, + }; + metrics.push(metricsObject); + } + return metrics; +} + +/** + * Enrich the paylod by adding information from the config. + * + * @param {string} payload + * @return {Promise} enriched payload + */ +async function parseAndEnrichPayload(payload) { + /** @type {AutoscalerMemorystoreCluster[]} */ + const clusters = await configValidator.parseAndAssertValidConfig(payload); + const clustersFound = []; + + for (let idx = 0; idx < clusters.length; idx++) { + // Merge in the defaults + clusters[idx] = {...baseDefaults, ...clusters[idx]}; + + if (clusters[idx].minSize < CLUSTER_SIZE_MIN) { + throw new Error( + `INVALID CONFIG: minSize (${clusters[idx].minSize}) is below the ` + + `minimum cluster size of ${CLUSTER_SIZE_MIN}.`, + ); + } + + if (clusters[idx].minSize === CLUSTER_SIZE_INVALID) { + throw new Error( + `INVALID CONFIG: minSize is ${clusters[idx].minSize} which is an ` + + `invalid cluster configuration. Read more: ` + + `https://cloud.google.com/memorystore/docs/cluster/cluster-node-specification#unsupported_cluster_shape`, + ); + } + + if (clusters[idx].maxSize === CLUSTER_SIZE_INVALID) { + throw new Error( + `INVALID CONFIG: maxSize is ${clusters[idx].maxSize} which is an ` + + `invalid cluster configuration. Read more: ` + + `https://cloud.google.com/memorystore/docs/cluster/cluster-node-specification#unsupported_cluster_shape`, + ); + } + + if (clusters[idx].minSize > clusters[idx].maxSize) { + throw new Error( + `INVALID CONFIG: minSize (${clusters[idx].minSize}) is larger than ` + + `maxSize (${clusters[idx].maxSize}).`, + ); + } + + if (clusters[idx].units === undefined) { + clusters[idx].units = AutoscalerUnits.SHARDS; + logger.debug({ + message: ` Defaulted units to ${clusters[idx].units}`, + projectId: clusters[idx].projectId, + regionId: clusters[idx].regionId, + clusterId: clusters[idx].clusterId, + }); + } + + if (clusters[idx].units.toUpperCase() == AutoscalerUnits.SHARDS) { + clusters[idx].units = clusters[idx].units.toUpperCase(); + clusters[idx] = {...shardDefaults, ...clusters[idx]}; + } else { + throw new Error( + `INVALID CONFIG: ${clusters[idx].units} is invalid. Valid: ${AutoscalerUnits.SHARDS}`, + ); + } + + // Assemble the metrics + clusters[idx].metrics = buildMetrics( + clusters[idx].projectId, + clusters[idx].regionId, + clusters[idx].clusterId, + ); + + try { + clusters[idx] = { + ...clusters[idx], + ...(await getMemorystoreClusterMetadata( + clusters[idx].projectId, + clusters[idx].regionId, + clusters[idx].clusterId, + clusters[idx].units.toUpperCase(), + )), + }; + clustersFound.push(clusters[idx]); + } catch (err) { + logger.error({ + message: `Unable to retrieve metadata for ${clusters[idx].projectId}/${clusters[idx].regionId}/${clusters[idx].clusterId}: ${err}`, + projectId: clusters[idx].projectId, + regionId: clusters[idx].regionId, + clusterId: clusters[idx].clusterId, + err: err, + }); + } + } + + return clustersFound; +} + +/** + * Forwards the metrics + * @param {function( + * AutoscalerMemorystoreCluster, + * MemorystoreClusterMetricValue[]): Promise} forwarderFunction + * @param {AutoscalerMemorystoreCluster[]} clusters config objects + * @return {Promise} + */ +async function forwardMetrics(forwarderFunction, clusters) { + for (const cluster of clusters) { + try { + const metrics = await getMetrics(cluster); + await forwarderFunction(cluster, metrics); // Handles exceptions + await Counters.incPollingSuccessCounter(cluster); + } catch (err) { + logger.error({ + message: `Unable to retrieve metrics for ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: ${err}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + err: err, + }); + await Counters.incPollingFailedCounter(cluster); + } + } +} + +/** + * Aggregate metrics for a list of clusters + * + * @param {AutoscalerMemorystoreCluster[]} clusters + * @return {Promise} aggregatedMetrics + */ +async function aggregateMetrics(clusters) { + const aggregatedMetrics = []; + for (const cluster of clusters) { + try { + cluster.metrics = await getMetrics(cluster); + aggregatedMetrics.push(cluster); + await Counters.incPollingSuccessCounter(cluster); + } catch (err) { + logger.error({ + message: `Unable to retrieve metrics for ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: ${err}`, + projectId: cluster.projectId, + instanceId: cluster.clusterId, + cluster: cluster, + err: err, + }); + await Counters.incPollingFailedCounter(cluster); + } + } + return aggregatedMetrics; +} + +/** + * Handle a PubSub message and check if scaling is required + * + * @param {{data: string}} pubSubEvent + * @param {*} context + */ +async function checkMemorystoreClusterScaleMetricsPubSub(pubSubEvent, context) { + try { + const payload = Buffer.from(pubSubEvent.data, 'base64').toString(); + try { + const clusters = await parseAndEnrichPayload(payload); + logger.debug({ + message: 'Autoscaler poller started (PubSub).', + payload: clusters, + }); + await forwardMetrics(postPubSubMessage, clusters); + await Counters.incRequestsSuccessCounter(); + } catch (err) { + logger.error({ + message: `An error occurred in the Autoscaler poller function (PubSub): ${err}`, + err: err, + payload: payload, + }); + await Counters.incRequestsFailedCounter(); + } + } catch (err) { + logger.error({ + message: `An error occurred in the Autoscaler poller function (PubSub): ${err}`, + err: err, + payload: pubSubEvent.data, + }); + await Counters.incRequestsFailedCounter(); + } finally { + await Counters.tryFlush(); + } +} + +/** + * For testing with: https://cloud.google.com/functions/docs/functions-framework + * @param {express.Request} req + * @param {express.Response} res + */ +async function checkMemorystoreClusterScaleMetricsHTTP(req, res) { + const payload = JSON.stringify([ + /** @type {AutoscalerMemorystoreCluster} */ ({ + projectId: 'memorystore-cluster-scaler', + regionId: 'us-central1', + clusterId: 'autoscale-test', + scalerPubSubTopic: + 'projects/memorystore-cluster-scaler/topics/test-scaling', + minSize: 3, + maxSize: 10, + stateProjectId: 'state-project-id', + units: AutoscalerUnits.SHARDS, + }), + ]); + try { + const clusters = await parseAndEnrichPayload(payload); + await forwardMetrics(postPubSubMessage, clusters); + res.status(200).end(); + await Counters.incRequestsSuccessCounter(); + } catch (err) { + logger.error({ + err: err, + payload: payload, + message: `An error occurred in the Autoscaler poller function (HTTP): ${err}`, + }); + res.status(500).contentType('text/plain').end('An exception occurred'); + await Counters.incRequestsFailedCounter(); + } +} + +/** + * Entrypoint for local config (unified Poller/Scaler) + * + * @param {string} payload + * @return {Promise} + */ +async function checkMemorystoreClusterScaleMetricsLocal(payload) { + try { + const spanners = await parseAndEnrichPayload(payload); + logger.debug({ + message: 'Autoscaler Poller started (JSON/local).', + payload: spanners, + }); + const metrics = await aggregateMetrics(spanners); + await Counters.incRequestsSuccessCounter(); + return metrics; + } catch (err) { + logger.error({ + message: `An error occurred in the Autoscaler Poller function (JSON/Local): ${err}`, + payload: payload, + err: err, + }); + await Counters.incRequestsFailedCounter(); + return []; + } finally { + await Counters.tryFlush(); + } +} + +module.exports = { + checkMemorystoreClusterScaleMetricsHTTP, + checkMemorystoreClusterScaleMetricsPubSub, + checkMemorystoreClusterScaleMetricsLocal, +}; diff --git a/src/poller/poller-core/test/config-validator.test.js b/src/poller/poller-core/test/config-validator.test.js new file mode 100644 index 0000000..8bd75bd --- /dev/null +++ b/src/poller/poller-core/test/config-validator.test.js @@ -0,0 +1,193 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * ESLINT: Ignore max line length errors on lines starting with 'it(' + * (test descriptions) + */ +/* eslint max-len: ["error", { "ignorePattern": "^\\s*it\\(" }] */ + +// eslint-disable-next-line no-unused-vars +const should = require('should'); +const { + ConfigValidator, + ValidationError, + TEST_ONLY, +} = require('../config-validator'); +const fs = require('node:fs'); +const path = require('node:path'); + +/** + * @typedef {import('../../../autoscaler-common/types').MemorystoreClusterConfig + * } MemorystoreClusterConfig + */ + +describe('validateConfig', () => { + const configValidator = new ConfigValidator(); + + describe('#parseAndValidateConfig', () => { + it('fails when given an empty config', async () => { + await configValidator + .parseAndAssertValidConfig('') + .should.be.rejectedWith( + new Error( + 'Invalid JSON in Autoscaler configuration:' + + ' SyntaxError: Unexpected end of JSON input', + ), + ); + }); + + it('fails when not given an array', async () => { + await configValidator + .parseAndAssertValidConfig('{}') + .should.be.rejectedWith( + new ValidationError( + 'Invalid Autoscaler Configuration parameters:\n' + + 'MemorystoreConfig must be array', + ), + ); + }); + + it('fails when given an empty array', async () => { + await configValidator + .parseAndAssertValidConfig('[]') + .should.be.rejectedWith( + new ValidationError( + 'Invalid Autoscaler Configuration parameters:\n' + + 'MemorystoreConfig must NOT have fewer than 1 items', + ), + ); + }); + + it('fails when config does not contain required params', async () => { + await configValidator + .parseAndAssertValidConfig('[{}]') + .should.be.rejectedWith( + new ValidationError( + 'Invalid Autoscaler Configuration parameters:\n' + + "MemorystoreConfig/0 must have required property 'projectId'\n" + + "MemorystoreConfig/0 must have required property 'regionId'\n" + + "MemorystoreConfig/0 must have required property 'clusterId'", + ), + ); + }); + + it('fails with an invalid property ', async () => { + await configValidator + .parseAndAssertValidConfig( + `[{ + "projectId": "my-project", + "clusterId": "my-instance", + "regionId": "us-central1", + "invalidProp": "nothing" + }]`, + ) + .should.be.rejectedWith( + new ValidationError( + 'Invalid Autoscaler Configuration parameters:\n' + + 'MemorystoreConfig/0 must NOT have additional properties', + ), + ); + }); + + it('fails when a property is not valid', async () => { + await configValidator + .parseAndAssertValidConfig( + `[{ + "projectId": "my-project", + "clusterId": "my-instance", + "regionId": "us-central1", + "minSize": "1" + }]`, + ) + .should.be.rejectedWith( + new ValidationError( + 'Invalid Autoscaler Configuration parameters:\n' + + 'MemorystoreConfig/0/minSize must be number', + ), + ); + }); + + it('passes with valid config', async () => { + const config = [ + { + '$comment': 'Sample autoscaler config', + 'projectId': 'my-project', + 'regionId': 'us-central1', + 'clusterId': 'my-instance', + 'scalerPubSubTopic': 'projects/my-project/topics/scaler-topic', + 'units': 'SHARDS', + 'minSize': 3, + 'maxSize': 10, + }, + ]; + + const parsedConfig = await configValidator.parseAndAssertValidConfig( + JSON.stringify(config), + ); + parsedConfig.should.deepEqual(config); + }); + }); + + describe('#validateTestFiles', async () => { + const dir = 'src/poller/poller-core/test/resources'; + const files = fs.readdirSync(dir, { + withFileTypes: true, + }); + + const yamlFiles = files.filter((f) => f.name.endsWith('.yaml')); + const goodYamlFiles = yamlFiles.filter((f) => f.name.startsWith('good-')); + const badYamlFiles = yamlFiles.filter((f) => f.name.startsWith('bad-')); + const jsonFiles = files.filter((f) => f.name.endsWith('.json')); + const goodJsonFiles = jsonFiles.filter((f) => f.name.startsWith('good-')); + const badJsonFiles = jsonFiles.filter((f) => f.name.startsWith('bad-')); + + goodYamlFiles.forEach((file) => { + it(`validates file ${file.name} successfully`, async () => { + await TEST_ONLY.assertValidGkeConfigMapFile( + configValidator, + path.join(dir, file.name), + ).should.be.resolved(); + }); + }); + + badYamlFiles.forEach((file) => { + it(`invalid file ${file.name} correctly fails validation`, async () => { + await TEST_ONLY.assertValidGkeConfigMapFile( + configValidator, + path.join(dir, file.name), + ).should.be.rejected(); + }); + }); + + goodJsonFiles.forEach((file) => { + it(`validates file ${file.name} successfully`, async () => { + await TEST_ONLY.assertValidJsonFile( + configValidator, + path.join(dir, file.name), + ).should.be.resolved(); + }); + }); + + badJsonFiles.forEach((file) => { + it(`invalid file ${file.name} correctly fails validation`, async () => { + await TEST_ONLY.assertValidJsonFile( + configValidator, + path.join(dir, file.name), + ).should.be.rejected(); + }); + }); + }); +}); diff --git a/src/poller/poller-core/test/index.test.js b/src/poller/poller-core/test/index.test.js new file mode 100644 index 0000000..c9005ea --- /dev/null +++ b/src/poller/poller-core/test/index.test.js @@ -0,0 +1,272 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * ESLINT: Ignore max line length errors on lines starting with 'it(' + * (test descriptions) + */ +/* eslint max-len: ["error", { "ignorePattern": "^\\s*it\\(" }] */ + +const rewire = require('rewire'); +// eslint-disable-next-line no-unused-vars +const should = require('should'); +const sinon = require('sinon'); + +const app = rewire('../index.js'); + +const buildMetrics = app.__get__('buildMetrics'); +const parseAndEnrichPayload = app.__get__('parseAndEnrichPayload'); + +describe('#buildMetrics', () => { + it('should return 6 metrics', () => { + buildMetrics( + 'fakeProjectId', + 'fakeRegionId', + 'fakeClusterId', + ).should.have.length(6); + }); + + it('should insert the projectId', () => { + buildMetrics( + 'fakeProjectId', + 'fakeRegionId', + 'fakeClusterId', + )[0].filter.should.have.match(/fakeProjectId/); + }); + + it('should insert the regionId', () => { + buildMetrics( + 'fakeRegionId', + 'fakeRegionId', + 'fakeClusterId', + )[0].filter.should.have.match(/fakeRegionId/); + }); + + it('should insert the clusterId', () => { + buildMetrics( + 'fakeProjectId', + 'fakeRegionId', + 'fakeClusterId', + )[0].filter.should.have.match(/fakeClusterId/); + }); +}); + +describe('#parseAndEnrichPayload', () => { + it('should return the default for stepSize', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'cluster1', + units: 'SHARDS', + scalerPubSubTopic: 'projects/myproject/topics/scaler-topic', + }, + ]); + + const stub = sinon.stub().resolves({currentSize: 5, shardCount: 5}); + const unset = app.__set__('getMemorystoreClusterMetadata', stub); + + const mergedConfig = await parseAndEnrichPayload(payload); + mergedConfig[0].stepSize.should.equal(1); + + unset(); + }); + + it('should override the default for minSize', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'spanner1', + units: 'SHARDS', + scalerPubSubTopic: 'projects/myproject/topics/scaler-topic', + minSize: 6, + }, + ]); + + const stub = sinon.stub().resolves({currentSize: 5, shardCount: 5}); + const unset = app.__set__('getMemorystoreClusterMetadata', stub); + + const mergedConfig = await parseAndEnrichPayload(payload); + mergedConfig[0].units.should.equal('SHARDS'); + mergedConfig[0].minSize.should.equal(6); + + unset(); + }); + + it('should return the default for minFreeMemoryPercent', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'cluster1', + units: 'SHARDS', + scalerPubSubTopic: 'projects/myproject/topics/scaler-topic', + }, + ]); + + const stub = sinon.stub().resolves({currentSize: 5, shardCount: 5}); + const unset = app.__set__('getMemorystoreClusterMetadata', stub); + + const mergedConfig = await parseAndEnrichPayload(payload); + mergedConfig[0].minFreeMemoryPercent.should.equal(30); + + unset(); + }); + + it('should override the default for minFreeMemoryPercent', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'spanner1', + minFreeMemoryPercent: 20, + }, + ]); + + const stub = sinon.stub().resolves({currentSize: 5, shardCount: 5}); + const unset = app.__set__('getMemorystoreClusterMetadata', stub); + + const mergedConfig = await parseAndEnrichPayload(payload); + mergedConfig[0].minFreeMemoryPercent.should.equal(20); + + unset(); + }); + + it('should merge in defaults for SHARDS', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'spanner1', + scalerPubSubTopic: 'projects/myproject/topics/scaler-topic', + units: 'SHARDS', + minSize: 5, + }, + ]); + + const stub = sinon.stub().resolves({currentSize: 5, shardCount: 5}); + const unset = app.__set__('getMemorystoreClusterMetadata', stub); + + const mergedConfig = await parseAndEnrichPayload(payload); + mergedConfig[0].minSize.should.equal(5); + mergedConfig[0].maxSize.should.equal(10); + mergedConfig[0].stepSize.should.equal(1); + + unset(); + }); + + it('should throw if units are set to anything other than SHARDS', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'spanner1', + scalerPubSubTopic: 'projects/myproject/topics/scaler-topic', + units: 'INVALID_UNITS', + minSize: 5, + }, + ]); + + const stub = sinon.stub().resolves({currentSize: 5, shardCount: 5}); + const unset = app.__set__('getMemorystoreClusterMetadata', stub); + + await parseAndEnrichPayload(payload).should.be.rejectedWith(Error, { + message: + 'Invalid Autoscaler Configuration parameters:\n' + + 'MemorystoreConfig/0/units must be equal to one of the allowed values', + }); + + unset(); + }); + + it('should throw if minSize is below allowed minimum', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'spanner1', + scalerPubSubTopic: 'projects/myproject/topics/scaler-topic', + units: 'SHARDS', + minSize: 2, + }, + ]); + + await parseAndEnrichPayload(payload).should.be.rejectedWith(Error, { + message: + 'INVALID CONFIG: minSize (2) is below the minimum cluster size of 3.', + }); + }); + + it('should throw if minSize is invalid cluster configuration', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'spanner1', + scalerPubSubTopic: 'projects/myproject/topics/scaler-topic', + units: 'SHARDS', + minSize: 4, + }, + ]); + + await parseAndEnrichPayload(payload).should.be.rejectedWith(Error, { + message: + 'INVALID CONFIG: minSize is 4 which is an invalid cluster ' + + 'configuration. Read more: ' + + 'https://cloud.google.com/memorystore/docs/cluster/' + + 'cluster-node-specification#unsupported_cluster_shape', + }); + }); + + it('should throw if maxSize is invalid cluster configuration', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'spanner1', + scalerPubSubTopic: 'projects/myproject/topics/scaler-topic', + units: 'SHARDS', + maxSize: 4, + }, + ]); + + await parseAndEnrichPayload(payload).should.be.rejectedWith(Error, { + message: + 'INVALID CONFIG: maxSize is 4 which is an invalid cluster ' + + 'configuration. Read more: ' + + 'https://cloud.google.com/memorystore/docs/cluster/' + + 'cluster-node-specification#unsupported_cluster_shape', + }); + }); + + it('should throw if minSize is larger than maxSize', async () => { + const payload = JSON.stringify([ + { + projectId: 'project1', + regionId: 'region1', + clusterId: 'spanner1', + scalerPubSubTopic: 'projects/myproject/topics/scaler-topic', + units: 'SHARDS', + minSize: 10, + maxSize: 5, + }, + ]); + + await parseAndEnrichPayload(payload).should.be.rejectedWith(Error, { + message: 'INVALID CONFIG: minSize (10) is larger than maxSize (5).', + }); + }); +}); diff --git a/src/poller/poller-core/test/resources/bad-data-contents.yaml b/src/poller/poller-core/test/resources/bad-data-contents.yaml new file mode 100644 index 0000000..301e68d --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-data-contents.yaml @@ -0,0 +1,22 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: dsdssfdfdsa +metadata: + name: autoscaler-config + namespace: memorystore-autoscaler +data: + autoscaler-config.yaml: | + hello world diff --git a/src/poller/poller-core/test/resources/bad-empty-array.json b/src/poller/poller-core/test/resources/bad-empty-array.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-empty-array.json @@ -0,0 +1 @@ +[] diff --git a/src/poller/poller-core/test/resources/bad-empty.json b/src/poller/poller-core/test/resources/bad-empty.json new file mode 100644 index 0000000..e69de29 diff --git a/src/poller/poller-core/test/resources/bad-empty.yaml b/src/poller/poller-core/test/resources/bad-empty.yaml new file mode 100644 index 0000000..3e8dc5e --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-empty.yaml @@ -0,0 +1,21 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: dsdssfdfdsa +metadata: + name: autoscaler-config + namespace: memorystore-autoscaler +data: + autoscaler-config.yaml: "" diff --git a/src/poller/poller-core/test/resources/bad-invalid-props.json b/src/poller/poller-core/test/resources/bad-invalid-props.json new file mode 100644 index 0000000..0082f38 --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-invalid-props.json @@ -0,0 +1,13 @@ +[ + { + "projectId": "basic-configuration", + "regionId": "us-central1", + "clusterId": "another-memorystore1", + "scalerPubSubTopic": "projects/my-memorystore-project/topics/memorystore-scaling", + "units": "SHARDS", + "minSize": 5, + "maxSize": 30, + "scalingMethod": "DIRECT", + "garbage": "value" + } +] diff --git a/src/poller/poller-core/test/resources/bad-invalid-props.yaml b/src/poller/poller-core/test/resources/bad-invalid-props.yaml new file mode 100644 index 0000000..01bc67d --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-invalid-props.yaml @@ -0,0 +1,30 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: autoscaler-config + namespace: memorystore-autoscaler +data: + autoscaler-config.yaml: | + --- + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-direct + units: SHARDS + minSize: 5 + maxSize: 30 + scalingMethod: DIRECT + garbage: value diff --git a/src/poller/poller-core/test/resources/bad-invalid-value.json b/src/poller/poller-core/test/resources/bad-invalid-value.json new file mode 100644 index 0000000..c98e9fa --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-invalid-value.json @@ -0,0 +1,12 @@ +[ + { + "projectId": "basic-configuration", + "regionId": "us-central1", + "clusterId": "another-memorystore1", + "scalerPubSubTopic": "projects/my-memorystore-project/topics/memorystore-scaling", + "units": "SHARDS", + "minSize": 5, + "maxSize": "30", + "scalingMethod": "DIRECT" + } +] diff --git a/src/poller/poller-core/test/resources/bad-invalid-value.yaml b/src/poller/poller-core/test/resources/bad-invalid-value.yaml new file mode 100644 index 0000000..bd7ab46 --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-invalid-value.yaml @@ -0,0 +1,29 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: autoscaler-config + namespace: memorystore-autoscaler +data: + autoscaler-config.yaml: | + --- + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-direct + units: SHARDS + minSize: 5 + maxSize: rubbish + scalingMethod: DIRECT diff --git a/src/poller/poller-core/test/resources/bad-missing-props.json b/src/poller/poller-core/test/resources/bad-missing-props.json new file mode 100644 index 0000000..93d5140 --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-missing-props.json @@ -0,0 +1 @@ +[{}] diff --git a/src/poller/poller-core/test/resources/bad-missing-props.yaml b/src/poller/poller-core/test/resources/bad-missing-props.yaml new file mode 100644 index 0000000..f2d702d --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-missing-props.yaml @@ -0,0 +1,23 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: autoscaler-config + namespace: memorystore-autoscaler +data: + autoscaler-config.yaml: | + --- + - projectId: memorystore-autoscaler-test diff --git a/src/poller/poller-core/test/resources/bad-not-configmap.yaml b/src/poller/poller-core/test/resources/bad-not-configmap.yaml new file mode 100644 index 0000000..08a3503 --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-not-configmap.yaml @@ -0,0 +1,47 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: dsdssfdfdsa +metadata: + name: autoscaler-config + namespace: memorystore-autoscaler +data: + autoscaler-config.yaml: | + --- + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-direct + units: SHARDS + minSize: 5 + maxSize: 30 + scalingMethod: DIRECT + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-threshold + units: SHARDS + minSize: 100 + maxSize: 3000 + metrics: + - name: high_priority_cpu + regional_threshold: 40 + regional_margin: 3 + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-custom + units: SHARDS + minSize: 5 + maxSize: 30 + scalingMethod: STEPWISE + scaleInLimit: 25 diff --git a/src/poller/poller-core/test/resources/bad-not-yaml.yaml b/src/poller/poller-core/test/resources/bad-not-yaml.yaml new file mode 100644 index 0000000..098b8ab --- /dev/null +++ b/src/poller/poller-core/test/resources/bad-not-yaml.yaml @@ -0,0 +1,15 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +some garbage... diff --git a/src/poller/poller-core/test/resources/good-config.json b/src/poller/poller-core/test/resources/good-config.json new file mode 100644 index 0000000..d36084c --- /dev/null +++ b/src/poller/poller-core/test/resources/good-config.json @@ -0,0 +1,33 @@ +[ + { + "$comment": "test data", + "projectId": "basic-configuration", + "regionId": "us-central1", + "clusterId": "another-memorystore1", + "scalerPubSubTopic": "projects/my-memorystore-project/topics/memorystore-scaling", + "units": "SHARDS", + "minSize": 5, + "maxSize": 30, + "scalingMethod": "DIRECT" + }, + { + "projectId": "custom-threshold", + "regionId": "us-central1", + "clusterId": "memorystore1", + "scalerPubSubTopic": "projects/my-memorystore-project/topics/memorystore-scaling", + "units": "SHARDS", + "minSize": 3, + "maxSize": 5, + "scalingProfile": "CPU" + }, + { + "projectId": "custom-metric", + "regionId": "us-central1", + "clusterId": "another-memorystore1", + "scalerPubSubTopic": "projects/my-memorystore-project/topics/memorystore-scaling", + "units": "SHARDS", + "minSize": 5, + "maxSize": 30, + "scalingMethod": "STEPWISE" + } +] diff --git a/src/poller/poller-core/test/resources/good-config.yaml b/src/poller/poller-core/test/resources/good-config.yaml new file mode 100644 index 0000000..e3d2417 --- /dev/null +++ b/src/poller/poller-core/test/resources/good-config.yaml @@ -0,0 +1,43 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: autoscaler-config + namespace: memorystore-autoscaler +data: + autoscaler-config.yaml: | + --- + - $comment: test data + projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-direct + units: SHARDS + minSize: 5 + maxSize: 30 + scalingMethod: DIRECT + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-threshold + units: SHARDS + minSize: 1 + maxSize: 5 + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-custom + units: SHARDS + minSize: 5 + maxSize: 30 + scalingMethod: STEPWISE diff --git a/src/poller/poller-core/test/resources/good-multi-config.yaml b/src/poller/poller-core/test/resources/good-multi-config.yaml new file mode 100644 index 0000000..26ebdcc --- /dev/null +++ b/src/poller/poller-core/test/resources/good-multi-config.yaml @@ -0,0 +1,44 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: autoscaler-config + namespace: memorystore-autoscaler +data: + autoscaler-config.yaml: | + --- + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-threshold + units: SHARDS + minSize: 100 + maxSize: 3000 + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-custom + units: SHARDS + minSize: 5 + maxSize: 30 + scalingMethod: STEPWISE + autoscaler-config-direct.yaml: | + --- + - projectId: memorystore-autoscaler-test + regionId: us-central1 + clusterId: memorystore-scaling-direct + units: SHARDS + minSize: 5 + maxSize: 30 + scalingMethod: DIRECT diff --git a/src/scaler/README.md b/src/scaler/README.md new file mode 100644 index 0000000..9086c1c --- /dev/null +++ b/src/scaler/README.md @@ -0,0 +1,472 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Automatically increase or reduce the size of a Memorystore cluster +
+ Home + · + Poller component + · + Scaler component + · + Forwarder component + · + Terraform configuration + · + Monitoring +

+

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Scaling parameters](#scaling-parameters) +* [Scaling profiles](#scaling-profiles) +* [Scaling rules](#scaling-rules) +* [Scaling methods](#scaling-methods) +* [Scaling adjustments](#scaling-adjustments) +* [Downstream messaging](#downstream-messaging) + +## Overview + +The Scaler component receives a message from the Poller component that includes +the configuration parameters and the utilization metrics for a single +Memorystore cluster. It compares the metric values with the recommended +thresholds and determines if the instance should be scaled, the number of +shards/nodes to which it should be scaled, and adjusts the size of the cluster +accordingly. + +The sequence of operations is as follows: + +1. [Scaling parameters](#scaling-parameters) are received from the Poller + component. +2. [Scaling rules](#scaling-rules) are evaluated with these parameters to + establish whether scaling is needed. +3. A calculation according to one of the [scaling methods](#scaling-methods) + is applied to establish by how much the cluster should scale. +4. [Scaling adjustments](#scaling-adjustments) are made to ensure the cluster + remains within valid, configured, and/or recommended limits. +5. Optionally, a [downstream message](#downstream-messaging) is sent via + Pub/Sub to enable integration with other systems or platforms. + +## Scaling parameters + +As opposed to the Poller component, the Scaler component does not need any user +configuration. The parameters that the Scaler receives are a combination of the +[configuration parameters][autoscaler-poller-parameters] used by the Poller +component, the Memorystore cluster metrics, and a number of other +characteristics of the Memorystore cluster instance. + +The following is an example of the message sent from the Poller to the Scaler. + +```json +{ + "projectId": "memorystore-cluster-project-id", + "regionId": "us-central1", + "clusterId": "autoscaler-target-memorystore-cluster", + "scalingProfile": "CPU", + "scalingMethod": "STEPWISE", + "units": "SHARDS", + "currentSize": 3, + "sizeGb": 39, + "shardCount": 3, + "stepSize": 1, + "minSize": 3, + "maxSize": 10, + "scaleOutCoolingMinutes": 10, + "scaleInCoolingMinutes": 10, + "stateDatabase": { + "name": "firestore" + }, + "scalerPubSubTopic": "projects/memorystore-cluster-project-id/topics/scaler-topic", + "metrics": [ + { + "value": 0.1916827415703537, + "name": "cpu_maximum_utilization" + }, + { + "value": 0.1768677270076482, + "name": "cpu_average_utilization" + }, + { + "value": 0.029637693214174952, + "name": "memory_maximum_utilization" + }, + { + "value": 0.029490114406189493, + "name": "memory_average_utilization" + }, + { + "name": "maximum_evicted_keys", + "value": 0 + }, + { + "name": "average_evicted_keys", + "value": 0 + } + ] +} +``` + +Notice the `scalingProfile` parameter, which is described in more detail in +the following section, [scaling-profiles](#scaling-profiles). + +## Scaling profiles + +A scaling profile consists of a combination of scaling rules that, +when grouped together, define the metrics that will be evaluated to reach +a scaling decsion. One of the following scaling profiles may be provided: + +* `CPU` +* `MEMORY` +* `CPU_AND_MEMORY` (default, used if no other profile is specified) +* `CUSTOM` (see section [custom-scaling](#custom-scaling) + +The `CPU_AND_MEMORY` profile includes rules for scaling on CPU as well as memory +utilization. Please see the following section for more details on how these +[scaling rules](#scaling-rules) are evaluated. + +You can create a new scaling profile by copying one of the existing scaling +profiles in the [profiles directory](./scaler-core/scaling-profiles/profiles/README.md) +and adapting it to suit your needs. This profile will be loaded if you specify +its name using the `scalingProfile` parameter in your configuration. + +### Custom scaling + +You can configure custom scaling by using the scaling profile `CUSTOM`, and supplying +an array of scaling rules in the user-supplied configuration. An example of rules +supplied in JSON as part of a custom scaling profile is as follows: + +```json +[ + { + "clusterId": "cluster-1", + "maxSize": 10, + "minSize": 3, + "projectId": "project-1", + "regionId": "us-central1", + "scalerPubSubTopic": "projects/project-1/topics/scaler-topic", + "scalingMethod": "STEPWISE", + "scalingProfile": "CUSTOM", + "stateDatabase": { + "name": "firestore" + }, + "units": "SHARDS", + "scalingRules": [ + { + "name": "custom_memory_scale_out", + "conditions": { + "all": [ + { + "fact": "memory_average_utilization", + "operator": "greaterThan", + "value": 70 + } + ] + }, + "event": { + "type": "OUT", + "params": { + "message": "High average memory utilization", + "scalingMetrics": ["memory_average_utilization"] + } + } + }, + { + "name": "custom_memory_scale_in", + "conditions": { + "all": [ + { + "fact": "memory_average_utilization", + "operator": "lessThan", + "value": 60 + } + ] + }, + "event": { + "type": "IN", + "params": { + "message": "Low average memory utilization", + "scalingMetrics": ["memory_average_utilization"] + } + } + }, + // Additional rules may be added here + ] + } +] +``` + +These rules will be passed from the Poller to the Scaler and evaluated to +inform scaling decisions. + +## Scaling rules + +The Scaler component uses a [Rules Engine][rules-engine] to evaluate a set of +parameterized rules according to a set of metrics and other parameters +that it receives from the Poller. Each rule is evaluated, and the results +of these evaluations are combined to form a scaling decision, which may +be `IN`, `OUT`, or `NONE`. + +The rules are represented as JavaScript Objects within the Autoscaler +codebase, and can be found [here](./scaler-core/scaling-profiles/rules/README.md). + +The rules are grouped into the following categories: + +* [CPU Utilization](./scaler-core/scaling-profiles/rules/cpu/README.md) +* [Memory Utilization](./scaler-core/scaling-profiles/rules/memory/README.md) + +The following is an annotated example of one of the included rules. This +rule triggers a scale-out if the average CPU utilization (i.e. across all +primary shards) is greater than 80%. + +```javascript +module.exports = { + name: basename(__filename, '.js'), + conditions: { + all: [ + { + // The Cloud Monitoring metric name + fact: 'cpu_average_utilization', + // The comparison operator + operator: 'greaterThan', + // The threshold for the rule to evaluate as TRUE + value: 80, + }, + ], + }, + event: { + // The scaling decision should this rule evaluate as TRUE + type: 'OUT', + params: { + // The string to use in the Cloud Logging message when the rule fires + message: 'high average CPU utilization', + // The metrics to use in scaling calculations + scalingMetrics: ['cpu_average_utilization'], + }, + }, +}; +``` + +The values in these files may be modified to alter the behaviour of the +Autoscaler. Thorough testing is recommended. + +## Scaling methods + +The Scaler component supports two scaling methods out of the box: + +* [STEPWISE](scaler-core/scaling-methods/stepwise.js): This is the default + method used by the Scaler. It suggests adding or removing shards + using a fixed step amount defined by the parameter `stepSize`. + + Note: If `stepSize` is set to 1, then it will never scale in below 5 shards. + This is because [clusters of 4 shards are not supported][invalid-cluster-size]. + +* [DIRECT](scaler-core/scaling-methods/direct.js): This method suggests + scaling to the number of shards specified by the `maxSize` parameter. It + does NOT take in account the current utilization metrics. It is useful + to scale an instance in preparation for a batch job and and to scale + it back after the job is finished. + +* [LINEAR](scaler-core/scaling-methods/linear.js): This method suggests + scaling to the number of shards calculated with a simple linear + cross-multiplication between the threshold metric and its current + utilization. In other words, the new number of shards divided by the current + number of shards is equal to the scaling metric value divided by the scaling + metric threshold value. Using this method, the new number of shards or + processing units is [directly proportional][directly-proportional] to the + current resource utilization and the threshold. The proposed change size can + be limited using `scaleInLimit` and `scaleOutLimit`, where the variation + in the shard count in a single iteration will not exceed by these limits + when scaling in or out respectively. + + > **NOTE** + > Scaling to 4 shards is not possible. This is because + > [clusters of 4 shards are not supported][invalid-cluster-size]. + +The selected scaling method will produce a suggested size to which the +cluster should be scaled. This suggested size then undergoes some final checks +and may be adjusted prior to the actual scaling request. These are detailed in +the following section. + +## Scaling adjustments + +Before issuing a Memorystore API request to scale in or out, the suggested +size generated by evaluating the appropriate scaling method is checked as +follows: + +1. For a suggested scale-in operation, ensure the suggested size is + large enough to accommodate the current stored data set, plus a default or + custom-configured percentage headroom + (see [minFreeMemoryPercent](../poller/README.md#configuration-parameters)). +2. Ensure it is within the configured minimum and maximum cluster sizes. +3. Ensure it is a [valid cluster size][invalid-cluster-size]. + +As a result of the above checks, the suggested size may be adjusted before +the final scaling request is made to the Memorystore API. + +## Downstream messaging + +A downstream application is a system that receives information from the +Autoscaler. + +When certain events happens, the Autoscaler can publish messages to a +PubSub topic. Downstream applications can +[create a subscription][pub-sub-create-subscription] to that topic +and [pull the messages][pub-sub-receive] to process them further. + +This feature is disabled by default. To enable it, specify `projects/${projectId}/topics/downstream-topic` +as the value of the `downstreamPubSubTopic` parameter in the [Poller configuration](../poller/README.md#configuration-parameters). +Make sure you replace the placeholder `${projectId}` with your actual project ID. + +The topic is created at deployment time as specified in the +[base module Terraform config](../../terraform/modules/autoscaler-base/main.tf). + +### Message structure + +The following is an example of a message published by the Autoscaler. + +```json +[ + { + "ackId": "U0RQBhYsXUZIUTcZCGhRDk9eIz81IChFEQMIFAV8fXFDRXVeXhoHUQ0ZcnxpfT5TQlUBEVN-VVsRDXptXG3VzfqNRF9BfW5ZFAgGQ1V7Vl0dDmFeWF3SjJ3whoivS3BmK9OessdIf77en9luZiA9XxJLLD5-LSNFQV5AEkwmFkRJUytDCypYEU4EISE-MD5F", + "ackStatus": "SUCCESS", + "message": { + "attributes": { + "event": "SCALING", + "googclient_schemaencoding": "JSON", + "googclient_schemaname": "projects/memorystore-cluster-project/schemas/downstream-schema", + "googclient_schemarevisionid": "207c0c97" + }, + "data": "eyJwcm9qZWN0SWQiOiJyZWRpcy1jbHVzdGVyLXByb2plY3QiLCJyZWdpb25JZCI6InVzLWNlbnRyYWwxIiwiY3VycmVudFNpemUiOjUsInN1Z2dlc3RlZFNpemUiOjYsInVuaXRzIjowLCJtZXRyaWNzIjpbeyJuYW1lIjoiY3B1X21heGltdW1fdXRpbGl6YXRpb24iLCJ2YWx1ZSI6MC4yMjUxNDk3OTAyNDY4MTYxN30seyJuYW1lIjoiY3B1X2F2ZXJhZ2VfdXRpbGl6YXRpb24iLCJ2YWx1ZSI6MC4xNzQ1OTEzMTcwOTE1MjI3Nn0seyJuYW1lIjoibWVtb3J5X21heGltdW1fdXRpbGl6YXRpb24iLCJ2YWx1ZSI6MC4wMzYyMTE3NTU5ODgzNDI3NDZ9LHsibmFtZSI6Im1lbW9yeV9hdmVyYWdlX3V0aWxpemF0aW9uIiwidmFsdWUiOjAuMDM0OTUxMDYwNDM5MTE3MDZ9LHsibmFtZSI6Im1heGltdW1fZXZpY3RlZF9rZXlzIiwidmFsdWUiOjB9LHsibmFtZSI6ImF2ZXJhZ2VfZXZpY3RlZF9rZXlzIiwidmFsdWUiOjB9XX0=", + "messageId": "8437946659663924", + "publishTime": "2024-02-16T16:39:49.252Z" + } + } +] +``` + +Notable attributes are: + +* **message.attributes.event:** the name of the event for which this message + was triggered. The Autoscaler publishes a message when it scales a + Memorystore cluster. The name of that event is `'SCALING'`. You can define + [custom messages](#custom-messages) for your own event types. +* **message.attributes.googclient_schemaname:** the + [Pub/Sub schema][pub-sub-schema] defining the format that the data field + must follow. The schema represents the contract between the message + producer (Autoscaler) and the message consumers (downstream applications). + Pub/Sub enforces the format. The default schema is defined as a Protocol + Buffer in the file + [downstream.schema.proto](scaler-core/downstream.schema.proto). +* **message.attributes.googclient_schemaencoding:** consumers will receive + the data in the messages encoded as Base64 containing JSON. +* **message.publishTime:** timestamp when the message was published +* **message.data:** the message payload encoded as Base64 containing a JSON + string. In the example, the [decoded][base-64-decode] string contains the + following data: + +```json +{ + "projectId": "memorystore-cluster-project", + "regionId": "us-central1", + "currentSize": 5, + "suggestedSize": 6, + "units": "SHARDS", + "metrics": [ + { + "name": "cpu_maximum_utilization", + "value": 0.22514979024681617 + }, + { + "name": "cpu_average_utilization", + "value": 0.17459131709152276 + }, + { + "name": "memory_maximum_utilization", + "value": 0.036211755988342746 + }, + { + "name": "memory_average_utilization", + "value": 0.03495106043911706 + }, + { + "name": "maximum_evicted_keys", + "value": 0 + }, + { + "name": "average_evicted_keys", + "value": 0 + } + ] +} +``` + +### Custom messages + +Before defining a custom message, consider if your use case can be solved by +[log-based metrics][log-based-metrics]. + +The Memorystore Cluster Autoscaler produces verbose structured logging for all +its actions. These logs can be used through log-based metrics to create +[charts and alerts in Cloud Monitoring][charts-and-alerts]. In turn, alerts can +be notified through several different [channels][notification-channels] including +Pub/Sub, and managed through [incidents][alert-incidents]. + +If your use case can be better solved by a custom downstream message, then this +section explains how to define one, which implies modifying the Scaler code. + +To publish a new event as a downstream message: + +* Choose a unique name for your event. The convention is an all-caps + alphanumeric + underscores ID with a verb. e.g. `'SCALING'` +* Call the Scaler function `publishDownstreamEvent`. + For an example, look at the [Scaler](scaler-core/index.js) + function `processScalingRequest`. + +In case you need to add fields to the message payload: + +1. Add your custom fields to the [Pub/Sub schema protobuf](scaler-core/downstream.schema.proto). + Your custom fields must use [field numbers][proto-field-numbers] over 1000. + Field numbers from 1 to 1000 are [reserved][proto-reserved] for future + Autoscaler enhancements. Make sure field numbers are unique within your org + and not reused if previously deleted. + +2. Run `terraform apply` to update the downstream Pub/Sub topic with the new schema. + +3. Create and call a function similar to the [Scaler](scaler-core/index.js) + `publishDownstreamEvent()`. In this function you populate the message + payload with the default fields and your new custom fields, and then call + `publishProtoMsgDownstream()`. + +### Consuming messages + +The payload of messages sent downstream from the Autoscaler is plain JSON encoded +with Base64, so you do not need to use the protobuf library for receiving messages. +See [this article][pub-sub-receive] for an example. + +However, if you want to validate the received message against the Protobuf schema, +you can follow [this example][pub-sub-receive-proto]. + + + +[alert-incidents]: https://cloud.google.com/monitoring/alerts/log-based-incidents +[autoscaler-poller-parameters]: ../poller/README.md#configuration-parameters +[base-64-decode]: https://www.base64decode.org/ +[charts-and-alerts]: https://cloud.google.com/logging/docs/logs-based-metrics#monitoring +[directly-proportional]: https://en.wikipedia.org/wiki/Proportionality_(mathematics)#Direct_proportionality +[invalid-cluster-size]: https://cloud.google.com/memorystore/docs/cluster/cluster-node-specification#unsupported_cluster_shap +[log-based-metrics]: https://cloud.google.com/logging/docs/logs-based-metrics +[notification-channels]: https://cloud.google.com/monitoring/support/notification-options +[proto-field-numbers]: https://protobuf.dev/programming-guides/proto3/#assigning +[proto-reserved]: https://protobuf.dev/programming-guides/proto3/#fieldreserved +[pub-sub-create-subscription]: https://cloud.google.com/pubsub/docs/create-subscription#pubsub_create_push_subscription-nodejs +[pub-sub-receive-proto]: https://cloud.google.com/pubsub/docs/samples/pubsub-subscribe-proto-messages#pubsub_subscribe_proto_messages-nodejs_javascript +[pub-sub-receive]: https://cloud.google.com/pubsub/docs/publish-receive-messages-client-library#receive_messages +[pub-sub-schema]: https://cloud.google.com/pubsub/docs/schemas +[rules-engine]: https://github.com/CacheControl/json-rules-engine diff --git a/src/scaler/scaler-core/counters.js b/src/scaler/scaler-core/counters.js new file mode 100644 index 0000000..a699b5b --- /dev/null +++ b/src/scaler/scaler-core/counters.js @@ -0,0 +1,236 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Autoscaler Counters package + * + * Publishes Counters to Cloud Monitoring + * + */ +const CountersBase = require('../../autoscaler-common/counters-base.js'); + +const COUNTERS_PREFIX = 'scaler/'; + +const COUNTER_NAMES = { + SCALING_SUCCESS: COUNTERS_PREFIX + 'scaling-success', + SCALING_DENIED: COUNTERS_PREFIX + 'scaling-denied', + SCALING_FAILED: COUNTERS_PREFIX + 'scaling-failed', + REQUESTS_SUCCESS: COUNTERS_PREFIX + 'requests-success', + REQUESTS_FAILED: COUNTERS_PREFIX + 'requests-failed', + SCALING_DURATION: COUNTERS_PREFIX + 'scaling-duration', +}; + +const ATTRIBUTE_NAMES = { + ...CountersBase.COUNTER_ATTRIBUTE_NAMES, + SCALING_DENIED_REASON: 'scaling_denied_reason', + SCALING_METHOD: 'scaling_method', + SCALING_DIRECTION: 'scaling_direction', +}; + +/** + * @typedef {import('../../autoscaler-common/types.js') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + */ +/** + * @typedef {import('@opentelemetry/api').Attributes} Attributes + */ + +/** + * @type {import('../../autoscaler-common/counters-base.js') + * .CounterDefinition[]} + */ +const COUNTERS = [ + { + counterName: COUNTER_NAMES.SCALING_SUCCESS, + counterDesc: + 'The number of Memorystore Cluster scaling events that succeeded', + }, + { + counterName: COUNTER_NAMES.SCALING_DENIED, + counterDesc: 'The number of Memorystore Cluster scaling events denied', + }, + { + counterName: COUNTER_NAMES.SCALING_FAILED, + counterDesc: 'The number of Memorystore Cluster scaling events that failed', + }, + { + counterName: COUNTER_NAMES.REQUESTS_SUCCESS, + counterDesc: 'The number of scaling request messages handled successfully', + }, + { + counterName: COUNTER_NAMES.REQUESTS_FAILED, + counterDesc: 'The number of scaling request messages that failed', + }, + { + counterName: COUNTER_NAMES.SCALING_DURATION, + counterDesc: 'The time taken to complete the scaling operation', + counterType: 'HISTOGRAM', + counterUnits: 'ms', // milliseconds + // This creates a set of 25 buckets with exponential growth + // starting at 0s, 22s, 49s, 81s increasing to 7560s ~= 126mins + counterHistogramBuckets: [...Array(25).keys()].map((n) => + Math.floor(60_000 * (2 ** (n / 4) - 1)), + ), + }, +]; + +const pendingInit = CountersBase.createCounters(COUNTERS); + +/** + * Build an attribute object for the counter + * + * @private + * @param {AutoscalerMemorystoreCluster} cluster config object + * @param {number} [requestedSize] + * @param {number?} [previousSize] overrides currentSize in cluster object + * @param {string?} [scalingMethod] overrides scalingMethod in cluster object + * @return {Attributes} + */ +function _getCounterAttributes( + cluster, + requestedSize, + previousSize, + scalingMethod, +) { + if (previousSize == null) { + previousSize = cluster.currentSize; + } + if (scalingMethod == null) { + scalingMethod = cluster.scalingMethod; + } + + const ret = { + [ATTRIBUTE_NAMES.CLUSTER_PROJECT_ID]: cluster.projectId, + [ATTRIBUTE_NAMES.CLUSTER_INSTANCE_ID]: cluster.clusterId, + [ATTRIBUTE_NAMES.SCALING_METHOD]: scalingMethod, + }; + + if (requestedSize) { + ret[ATTRIBUTE_NAMES.SCALING_DIRECTION] = + requestedSize > previousSize + ? 'SCALE_UP' + : requestedSize < previousSize + ? 'SCALE_DOWN' + : 'SCALE_SAME'; + } + return ret; +} + +/** + * Increment scaling success counter + * + * @param {AutoscalerMemorystoreCluster} cluster config object + * @param {number} requestedSize + * @param {number?} [previousSize] overrides currentSize in cluster object + * @param {string?} [scalingMethod] overrides scalingMethod in cluster object + */ +async function incScalingSuccessCounter( + cluster, + requestedSize, + previousSize, + scalingMethod, +) { + await pendingInit; + CountersBase.incCounter( + COUNTER_NAMES.SCALING_SUCCESS, + _getCounterAttributes(cluster, requestedSize, previousSize, scalingMethod), + ); +} + +/** + * Increment scaling failed counter + * + * @param {AutoscalerMemorystoreCluster} cluster config object + * @param {number} requestedSize + * @param {number?} [previousSize] overrides currentSize in cluster object + * @param {string?} [scalingMethod] overrides scalingMethod in cluster object + */ +async function incScalingFailedCounter( + cluster, + requestedSize, + previousSize, + scalingMethod, +) { + await pendingInit; + CountersBase.incCounter( + COUNTER_NAMES.SCALING_FAILED, + _getCounterAttributes(cluster, requestedSize, previousSize, scalingMethod), + ); +} + +/** + * Increment scaling denied counter + * + * @param {AutoscalerMemorystoreCluster} cluster config object + * @param {number} requestedSize + * @param {string} reason + */ +async function incScalingDeniedCounter(cluster, requestedSize, reason) { + await pendingInit; + CountersBase.incCounter(COUNTER_NAMES.SCALING_DENIED, { + ..._getCounterAttributes(cluster, requestedSize), + [ATTRIBUTE_NAMES.SCALING_DENIED_REASON]: reason, + }); +} + +/** + * Increment messages success counter + */ +async function incRequestsSuccessCounter() { + await pendingInit; + CountersBase.incCounter(COUNTER_NAMES.REQUESTS_SUCCESS); +} + +/** + * Increment messages failed counter + */ +async function incRequestsFailedCounter() { + await pendingInit; + CountersBase.incCounter(COUNTER_NAMES.REQUESTS_FAILED); +} + +/** + * Record scaling duration to the distribution. + * + * @param {number} durationMillis + * @param {AutoscalerMemorystoreCluster} cluster config object + * @param {number} requestedSize + * @param {number?} [previousSize] overrides currentSize in cluster object + * @param {string?} [scalingMethod] overrides scalingMethod in cluster object + */ +async function recordScalingDuration( + durationMillis, + cluster, + requestedSize, + previousSize, + scalingMethod, +) { + await pendingInit; + CountersBase.recordValue( + COUNTER_NAMES.SCALING_DURATION, + Math.floor(durationMillis), + _getCounterAttributes(cluster, requestedSize, previousSize, scalingMethod), + ); +} + +module.exports = { + incScalingSuccessCounter, + incScalingFailedCounter, + incScalingDeniedCounter, + incRequestsSuccessCounter, + incRequestsFailedCounter, + recordScalingDuration, + tryFlush: CountersBase.tryFlush, +}; diff --git a/src/scaler/scaler-core/downstream.schema.proto b/src/scaler/scaler-core/downstream.schema.proto new file mode 100644 index 0000000..79e5a73 --- /dev/null +++ b/src/scaler/scaler-core/downstream.schema.proto @@ -0,0 +1,39 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +message DownstreamEvent { + + message Metric { + reserved 5 to 1000; + string name = 1; + float threshold = 2; + float value = 3; + float margin = 4; + } + + reserved 8 to 1000; + string project_id = 1; + string region_id = 2; + string instance_id = 3; + optional int32 current_size = 4; + optional int32 suggested_size = 5; + optional Units units = 6; + repeated Metric metrics = 7; +} + +enum Units { + SHARDS = 0; +} diff --git a/src/scaler/scaler-core/index.js b/src/scaler/scaler-core/index.js new file mode 100644 index 0000000..f8abfea --- /dev/null +++ b/src/scaler/scaler-core/index.js @@ -0,0 +1,732 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Autoscaler Scaler function + * + * * Receives metrics from the Autoscaler Poller pertaining to a single cluster + * * Determines if the cluster can be autoscaled + * * Selects a scaling method, and gets a number of suggested units + * * Autoscales the Memorystore cluster by the number of suggested units + */ +// eslint-disable-next-line no-unused-vars -- for type checking only. +const express = require('express'); +const {convertMillisecToHumanReadable} = require('./utils.js'); +const {logger} = require('../../autoscaler-common/logger'); +const Counters = require('./counters.js'); +const {publishProtoMsgDownstream} = require('./utils.js'); +const {CloudRedisClusterClient} = require('@google-cloud/redis-cluster'); +const sanitize = require('sanitize-filename'); +const State = require('./state.js'); +const fs = require('fs'); +const {version: packageVersion} = require('../../../package.json'); +const {google: GoogleApis} = require('googleapis'); + +/** + * @typedef {import('../../autoscaler-common/types').AutoscalerMemorystoreCluster + * } AutoscalerMemorystoreCluster + * @typedef {import('../../autoscaler-common/types.js').RuleSet} RuleSet + * @typedef {import('./state.js').StateData} StateData + */ + +const memorystoreClusterClient = new CloudRedisClusterClient({ + libName: 'cloud-solutions', + libVersion: `memorystore-cluster-autoscaler-scaler-usage-v${packageVersion}`, +}); + +// Set up REST API for LRO checking. +const redisApi = GoogleApis.redis({ + version: 'v1', + auth: new GoogleApis.auth.GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }), + userAgentDirectives: [ + { + product: 'cloud-solutions', + version: `memorystore-cluster-autoscaler-scaler-usage-v${packageVersion}`, + }, + ], +}); + +/** + * Get ruleSet by profile name. + * + * @param {AutoscalerMemorystoreCluster} cluster + * @return {RuleSet} + */ +function getScalingRuleSet(cluster) { + const SCALING_PROFILES_FOLDER = './scaling-profiles/profiles/'; + const DEFAULT_PROFILE_NAME = 'CPU_AND_MEMORY'; + const CUSTOM_PROFILE_NAME = 'CUSTOM'; + + /* + * Sanitize the profile name before using + * to prevent risk of directory traversal. + */ + const profileName = sanitize(cluster.scalingProfile); + let /** @type {RuleSet} **/ scalingRuleSet; + if (profileName === CUSTOM_PROFILE_NAME && cluster.scalingRules) { + scalingRuleSet = cluster.scalingRules.reduce((acc, current) => { + logger.info({ + message: ` Custom scaling rule: ${current.name}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + // @ts-ignore + acc[current.name] = current; + return acc; + }, {}); + } else { + try { + scalingRuleSet = require( + SCALING_PROFILES_FOLDER + profileName.toLowerCase(), + ).ruleSet; + } catch (err) { + logger.warn({ + message: `Unknown scaling profile '${profileName}'`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + scalingRuleSet = require( + SCALING_PROFILES_FOLDER + DEFAULT_PROFILE_NAME.toLowerCase(), + ).ruleSet; + cluster.scalingProfile = DEFAULT_PROFILE_NAME; + } + } + logger.info({ + message: `Using scaling profile: ${cluster.scalingProfile}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + return scalingRuleSet; +} + +/** + * Get scaling method function by name. + * + * @param {AutoscalerMemorystoreCluster} cluster + * @return {{ + * calculateSize: function(AutoscalerMemorystoreCluster,RuleSet):Promise, + * }} + */ +function getScalingMethod(cluster) { + const SCALING_METHODS_FOLDER = './scaling-methods/'; + const DEFAULT_METHOD_NAME = 'STEPWISE'; + + // sanitize the method name before using + // to prevent risk of directory traversal. + const methodName = sanitize(cluster.scalingMethod); + let scalingMethod; + try { + scalingMethod = require(SCALING_METHODS_FOLDER + methodName.toLowerCase()); + } catch (err) { + logger.warn({ + message: `Unknown scaling method '${methodName}'`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + scalingMethod = require( + SCALING_METHODS_FOLDER + DEFAULT_METHOD_NAME.toLowerCase(), + ); + cluster.scalingMethod = DEFAULT_METHOD_NAME; + } + logger.info({ + message: `Using scaling method: ${cluster.scalingMethod}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + return scalingMethod; +} + +/** + * Publish scaling PubSub event. + * + * @param {string} eventName + * @param {AutoscalerMemorystoreCluster} cluster + * @param {number} suggestedSize + * @return {Promise} + */ +async function publishDownstreamEvent(eventName, cluster, suggestedSize) { + const message = { + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + currentSize: cluster.currentSize, + suggestedSize: suggestedSize, + units: cluster.units, + metrics: cluster.metrics, + }; + + return publishProtoMsgDownstream( + eventName, + message, + cluster.downstreamPubSubTopic, + ); +} + +/** + * Test to see if we are in post-scale cooldown. + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {number} suggestedSize + * @param {StateData} autoscalerState + * @param {number} now timestamp in millis since epoch + * @return {boolean} + */ +function withinCooldownPeriod(cluster, suggestedSize, autoscalerState, now) { + const MS_IN_1_MIN = 60000; + const scaleOutSuggested = suggestedSize - cluster.currentSize > 0; + let cooldownPeriodOver; + + logger.debug({ + message: `----- ${cluster.projectId}/${cluster.clusterId}: Verifying if scaling is allowed -----`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + + const lastScalingMillisec = autoscalerState.lastScalingCompleteTimestamp + ? autoscalerState.lastScalingCompleteTimestamp + : autoscalerState.lastScalingTimestamp; + + const operation = scaleOutSuggested + ? { + description: 'scale out', + lastScalingMillisec: lastScalingMillisec, + coolingMillisec: cluster.scaleOutCoolingMinutes * MS_IN_1_MIN, + } + : { + description: 'scale in', + lastScalingMillisec: lastScalingMillisec, + coolingMillisec: cluster.scaleInCoolingMinutes * MS_IN_1_MIN, + }; + + if (operation.lastScalingMillisec == 0) { + cooldownPeriodOver = true; + logger.debug({ + message: `\tNo previous scaling operation found for this cluster`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + } else { + const elapsedMillisec = now - operation.lastScalingMillisec; + cooldownPeriodOver = elapsedMillisec >= operation.coolingMillisec; + logger.debug({ + message: `\tLast scaling operation was ${convertMillisecToHumanReadable( + now - operation.lastScalingMillisec, + )} ago.`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + logger.debug({ + message: `\tCooldown period for ${operation.description} is ${convertMillisecToHumanReadable( + operation.coolingMillisec, + )}.`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + } + + if (cooldownPeriodOver) { + logger.info({ + message: `\t=> Autoscale allowed`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + return false; + } else { + logger.info({ + message: `\t=> Autoscale NOT allowed yet`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + return true; + } +} + +/** + * Get Suggested size from config using scalingMethod + * @param {AutoscalerMemorystoreCluster} cluster + * @return {Promise} + */ +async function getSuggestedSize(cluster) { + const scalingRuleSet = getScalingRuleSet(cluster); + const scalingMethod = getScalingMethod(cluster); + + if (scalingMethod.calculateSize) { + const size = await scalingMethod.calculateSize(cluster, scalingRuleSet); + return size; + } else { + throw new Error( + `no calculateSize() in scaling method ${cluster.scalingMethod}`, + ); + } +} + +/** + * Scale the specified cluster to the specified size + * + * The api returns an Operation object containing the LRO ID which is returned by this + * function. + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {number} suggestedSize + * @return {Promise} operationID. + */ +async function scaleMemorystoreCluster(cluster, suggestedSize) { + logger.info({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: Scaling Memorystore cluster to ${suggestedSize} ${cluster.units} -----`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }); + + const request = { + cluster: { + name: `projects/${cluster.projectId}/locations/${cluster.regionId}/clusters/${cluster.clusterId}`, + shardCount: suggestedSize, + }, + updateMask: { + paths: ['shard_count'], + }, + }; + + const [operation] = await memorystoreClusterClient.updateCluster(request); + logger.debug({ + message: `Started the scaling operation: ${operation.name}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + return operation.name || null; +} + +/** + * Process the request to check a cluster for scaling + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {State} autoscalerState + */ +async function processScalingRequest(cluster, autoscalerState) { + logger.info({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: Scaling request received`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }); + + // Check for ongoing LRO + const savedState = await readStateCheckOngoingLRO(cluster, autoscalerState); + const suggestedSize = await getSuggestedSize(cluster); + + if (!savedState.scalingOperationId) { + // no ongoing LRO, lets see if scaling is required. + if ( + suggestedSize === cluster.currentSize && + cluster.currentSize === cluster.maxSize + ) { + logger.info({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: has ${cluster.currentSize} ${cluster.units}, no scaling possible - at maxSize`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }); + await Counters.incScalingDeniedCounter( + cluster, + suggestedSize, + 'MAX_SIZE', + ); + return; + } else if (suggestedSize == cluster.currentSize) { + logger.info({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: has ${cluster.currentSize} ${cluster.units}, no scaling needed at the moment`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }); + await Counters.incScalingDeniedCounter( + cluster, + suggestedSize, + 'CURRENT_SIZE', + ); + return; + } + + if ( + !withinCooldownPeriod( + cluster, + suggestedSize, + savedState, + autoscalerState.now, + ) + ) { + let eventType; + try { + const operationId = await scaleMemorystoreCluster( + cluster, + suggestedSize, + ); + await autoscalerState.updateState({ + ...savedState, + scalingOperationId: operationId, + scalingRequestedSize: suggestedSize, + lastScalingTimestamp: autoscalerState.now, + lastScalingCompleteTimestamp: null, + scalingPreviousSize: cluster.currentSize, + scalingMethod: cluster.scalingMethod, + }); + eventType = 'SCALING'; + } catch (err) { + logger.error({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: Unsuccessful scaling attempt: ${err}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + err: err, + }); + eventType = 'SCALING_FAILURE'; + await Counters.incScalingFailedCounter(cluster, suggestedSize); + } + await publishDownstreamEvent(eventType, cluster, suggestedSize); + } else { + logger.info({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: has ${cluster.currentSize} ${cluster.units}, no scaling possible - within cooldown period`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }); + await Counters.incScalingDeniedCounter( + cluster, + suggestedSize, + 'WITHIN_COOLDOWN', + ); + } + } else { + logger.info({ + message: + `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: has ${cluster.currentSize} ${cluster.units}, no scaling possible ` + + `- last scaling operation (${savedState.scalingMethod} to ${savedState.scalingRequestedSize}) is still in progress. Started: ${convertMillisecToHumanReadable( + autoscalerState.now - savedState.lastScalingTimestamp, + )} ago).`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }); + await Counters.incScalingDeniedCounter( + cluster, + suggestedSize, + 'IN_PROGRESS', + ); + } +} + +/** + * Handle scale request from a PubSub event. + * + * @param {{data:string}} pubSubEvent -- a CloudEvent object. + * @param {*} context + */ +async function scaleMemorystoreClusterPubSub(pubSubEvent, context) { + try { + const payload = Buffer.from(pubSubEvent.data, 'base64').toString(); + const cluster = JSON.parse(payload); + try { + const state = State.buildFor(cluster); + await processScalingRequest(cluster, state); + await state.close(); + await Counters.incRequestsSuccessCounter(); + } catch (err) { + logger.error({ + message: `Failed to process scaling request: ${err}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + err: err, + }); + await Counters.incRequestsFailedCounter(); + } + } catch (err) { + logger.error({ + message: `Failed to parse pubSub scaling request: ${err}`, + payload: pubSubEvent.data, + err: err, + }); + await Counters.incRequestsFailedCounter(); + } finally { + await Counters.tryFlush(); + } +} + +/** + * Test to handle scale request from a HTTP call with fixed payload + * For testing with: https://cloud.google.com/functions/docs/functions-framework + * @param {express.Request} req + * @param {express.Response} res + */ +async function scaleMemorystoreClusterHTTP(req, res) { + try { + const payload = fs.readFileSync( + 'src/scaler/scaler-core/test/samples/parameters.json', + 'utf-8', + ); + const cluster = JSON.parse(payload); + const state = State.buildFor(cluster); + await processScalingRequest(cluster, state); + await state.close(); + res.status(200).end(); + await Counters.incRequestsSuccessCounter(); + } catch (err) { + logger.error({ + message: `Failed to parse http scaling request ${err}`, + err: err, + }); + res.status(500).contentType('text/plain').end('An exception occurred'); + await Counters.incRequestsFailedCounter(); + } +} + +/** + * Handle scale request from local function call + * + * Called by unified Poller/Scaler on GKE deployments + * + * @param {AutoscalerMemorystoreCluster} cluster + */ +async function scaleMemorystoreClusterLocal(cluster) { + try { + const state = State.buildFor(cluster); + + await processScalingRequest(cluster, state); + await state.close(); + await Counters.incRequestsSuccessCounter(); + } catch (err) { + logger.error({ + message: `Failed to process scaling request: ${err}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + err: err, + }); + } finally { + await Counters.tryFlush(); + } +} + +/** + * Read state and check status of any LRO... + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {State} autoscalerState + * @return {Promise} + */ +async function readStateCheckOngoingLRO(cluster, autoscalerState) { + const savedState = await autoscalerState.get(); + + if (!savedState?.scalingOperationId) { + // no LRO ongoing. + return savedState; + } + + // Check LRO status... + + // The Node.JS redis cluster client library can in theory get Operation + // status, using the checkUpdateClusterProgress() API, as at mar 2024, it is + // broken and does not return any results. + // + // So we use the Redis (yes, non-cluster) REST api here to get the Operation + // status. + // + try { + const {data: operationState} = + await redisApi.projects.locations.operations.get({ + name: savedState.scalingOperationId, + }); + + if (!operationState) { + throw new Error( + `GetOperation(${savedState.scalingOperationId}) returned no results`, + ); + } + // Check metadata type + if ( + !operationState.metadata || + operationState.metadata['@type'] !== + 'type.googleapis.com/google.cloud.redis.cluster.v1.OperationMetadata' + ) { + throw new Error( + `GetOperation(${savedState.scalingOperationId}) contained no OperationMetadata`, + ); + } + + const metadata = /** + * @see https://cloud.google.com/memorystore/docs/cluster/reference/rest/Shared.Types/ListOperationsResponse#Operation + * + * @type {{ + * createTime: string, + * endTime: string, + * target: string, + * verb: string, + * statusDetail: any + * requestedCancellation: boolean + * apiVersion: string + * }} */ (operationState.metadata); + + if (operationState.done) { + if (!operationState.error) { + // Completed successfully. + const endTimestamp = + metadata.endTime == null ? 0 : Date.parse(metadata.endTime); + logger.info({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: Last scaling request for size ${savedState.scalingRequestedSize} SUCCEEDED. Started: ${metadata.createTime}, completed: ${metadata.endTime}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }); + + // Set completion time in SavedState + if (endTimestamp) { + savedState.lastScalingCompleteTimestamp = endTimestamp; + } else { + // invalid end date, assume start date... + logger.warn( + `Failed to parse operation endTime : ${metadata.endTime}`, + ); + savedState.lastScalingCompleteTimestamp = + savedState.lastScalingTimestamp; + } + + // Record success counters. + await Counters.recordScalingDuration( + savedState.lastScalingCompleteTimestamp - + savedState.lastScalingTimestamp, + cluster, + savedState.scalingRequestedSize || 0, + savedState.scalingPreviousSize, + savedState.scalingMethod, + ); + await Counters.incScalingSuccessCounter( + cluster, + savedState.scalingRequestedSize || 0, + savedState.scalingPreviousSize, + savedState.scalingMethod, + ); + + // Clear operation frm savedState + savedState.scalingOperationId = null; + savedState.scalingRequestedSize = null; + savedState.scalingPreviousSize = null; + savedState.scalingMethod = null; + } else { + // Last operation failed with an error + logger.error({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: Last scaling request for size ${savedState.scalingRequestedSize} FAILED: ${operationState.error?.message}. Started: ${metadata.createTime}, completed: ${metadata.endTime}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + error: operationState.error, + payload: cluster, + }); + + await Counters.incScalingFailedCounter( + cluster, + savedState.scalingRequestedSize || 0, + savedState.scalingPreviousSize, + savedState.scalingMethod, + ); + + // Clear last scaling operation from savedState. + savedState.scalingOperationId = null; + savedState.scalingRequestedSize = null; + savedState.lastScalingCompleteTimestamp = 0; + savedState.lastScalingTimestamp = 0; + savedState.scalingPreviousSize = null; + savedState.scalingMethod = null; + } + } else { + if (!!metadata.requestedCancellation) { + logger.info({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: Last scaling request for ${savedState.scalingRequestedSize} CANCEL REQUESTED. Started: ${metadata?.createTime}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }); + } else { + logger.info({ + message: `----- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: Last scaling request for ${savedState.scalingRequestedSize} IN PROGRESS. Started: ${metadata?.createTime}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + payload: cluster, + }); + } + } + } catch (err) { + // Fallback - LRO.get() API failed or returned invalid status. + // Assume complete. + logger.error({ + message: `Failed to retrieve state of operation, assume completed. ID: ${savedState.scalingOperationId}: ${err}`, + err: err, + }); + savedState.lastScalingCompleteTimestamp = savedState.lastScalingTimestamp; + savedState.scalingOperationId = null; + // Record success counters. + await Counters.recordScalingDuration( + savedState.lastScalingCompleteTimestamp - savedState.lastScalingTimestamp, + cluster, + savedState.scalingRequestedSize || 0, + savedState.scalingPreviousSize, + savedState.scalingMethod, + ); + await Counters.incScalingSuccessCounter( + cluster, + savedState.scalingRequestedSize || 0, + savedState.scalingPreviousSize, + savedState.scalingMethod, + ); + savedState.scalingRequestedSize = null; + savedState.scalingPreviousSize = null; + savedState.scalingMethod = null; + } + // Update saved state in storage. + await autoscalerState.updateState(savedState); + return savedState; +} + +module.exports = { + scaleMemorystoreClusterHTTP, + scaleMemorystoreClusterPubSub, + scaleMemorystoreClusterLocal, +}; diff --git a/src/scaler/scaler-core/scaling-methods/base.js b/src/scaler/scaler-core/scaling-methods/base.js new file mode 100644 index 0000000..59e78b8 --- /dev/null +++ b/src/scaler/scaler-core/scaling-methods/base.js @@ -0,0 +1,426 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Base module that encapsulates functionality common to scaling methods: + * * Load rules into rules engine + * * Run rules engine + * * Apply method-specific logic + * * Log sizing suggestions per metric + */ + +const {logger} = require('../../../autoscaler-common/logger'); +const {Engine} = require('json-rules-engine'); +const {AutoscalerDirection} = require('../../../autoscaler-common/types'); +const { + CLUSTER_SIZE_MIN, + CLUSTER_SIZE_INVALID, +} = require('../../../autoscaler-common/config-parameters'); + +/** + * @typedef {import('../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + * @typedef {import('../../../autoscaler-common/types') + * .MemorystoreClusterMetricValue} MemorystoreClusterMetricValue + * @typedef {import('../../../autoscaler-common/types').RuleEngineAnalysis} + * RuleEngineAnalysis + * @typedef {import('../../../autoscaler-common/types').Condition} + * Condition + * @typedef {import('../../../autoscaler-common/types').RuleSet} RuleSet + * @typedef {import('json-rules-engine').RuleResult} RuleResult + * @typedef {import('json-rules-engine').NestedCondition} NestedCondition + * + */ + +/** + * Get a string describing the scaling suggestion. + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {number} suggestedSize + * @param {AutoscalerDirection} scalingDirection + * @return {string} + */ +function getScaleSuggestionMessage(cluster, suggestedSize, scalingDirection) { + if (scalingDirection == AutoscalerDirection.NONE) { + return `no change suggested`; + } + if (suggestedSize == cluster.currentSize) { + return `the suggested size is equal to the current size: ${cluster.currentSize} ${cluster.units}`; + } + if (suggestedSize > cluster.maxSize) { + return `cannot scale to ${suggestedSize} because it is higher than MAX ${cluster.maxSize} ${cluster.units}`; + } + if (suggestedSize < cluster.minSize) { + return `Cannot scale to ${suggestedSize} because it is lower than MIN ${cluster.minSize} ${cluster.units}`; + } + return `suggesting to scale from ${cluster.currentSize} to ${suggestedSize} ${cluster.units}.`; +} + +/** + * Gets a map of matched metric rules with value and threshold. + * + * @param {RuleResult} ruleResult from the engine. + * @return {!Array} List of condition that were triggered the rule. + */ +function getRuleConditionMetrics(ruleResult) { + let /** @type {NestedCondition[]} */ ruleConditions; + if (ruleResult?.conditions && 'all' in ruleResult?.conditions) { + ruleConditions = ruleResult.conditions.all; + } else if (ruleResult?.conditions && 'any' in ruleResult?.conditions) { + ruleConditions = ruleResult?.conditions?.any; + } else { + ruleConditions = []; + } + + const /** @type {!Condition[]} */ ruleConditionsList = []; + for (const ruleCondition of ruleConditions) { + /* + * Narrow down typing and skip NestedConditions. + * Only Condition (currently ConditionProperties) are to be considered. + * TODO: add support for nested conditions. + */ + if (!('result' in ruleCondition)) continue; + if (!('fact' in ruleCondition)) continue; + if (!('factResult' in ruleCondition)) continue; + if (!('value' in ruleCondition)) continue; + + // Only consider rules if they triggered the scale (i.e. result=true). + if (!ruleCondition.result) continue; + + /* + * Redefining this type as workaround because json-rules-engine typing does + * not match the actual signature nor exports the Condition class directly. + * See: https://github.com/CacheControl/json-rules-engine/issues/253 + */ + // @ts-ignore + const /** @type {Condition} */ condition = ruleCondition; + ruleConditionsList.push(condition); + } + + return ruleConditionsList; +} + +/** + * Gets the relevant analysis from the rules engine. + * + * Analysis include: count of firing direction and relevant metrics for scaling, + * for those methods which requires it. + * + * @param {AutoscalerMemorystoreCluster} cluster for which to perform analysis. + * @param {?RuleSet} ruleSet to use to determine scaling decisions. + * @return {!Promise} Scaling analysis from the engine. + */ +async function getEngineAnalysis(cluster, ruleSet) { + if (!ruleSet) return null; + + logger.debug({ + message: + `---- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: ` + + `${cluster.scalingMethod} rules engine ----`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + + const rulesEngine = new Engine(); + + Object.values(ruleSet).forEach((rule) => { + rulesEngine.addRule(rule); + }); + + const /** @type {!RuleEngineAnalysis} */ engineAnalysis = { + firingRuleCount: { + [AutoscalerDirection.IN]: 0, + [AutoscalerDirection.OUT]: 0, + }, + matchedConditions: { + [AutoscalerDirection.IN]: [], + [AutoscalerDirection.OUT]: [], + }, + scalingMetrics: { + [AutoscalerDirection.IN]: new Set(), + [AutoscalerDirection.OUT]: new Set(), + }, + }; + + rulesEngine.on('success', function (event, _, ruleResult) { + logger.debug({ + message: `\tRule firing: ${event.params?.message} => ${event.type}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + event: event, + }); + + const ruleConditions = getRuleConditionMetrics(ruleResult); + const /** @type {!Set} */ scalingMetrics = + event.params?.scalingMetrics || new Set(); + if ( + event.type === AutoscalerDirection.OUT || + event.type === AutoscalerDirection.IN + ) { + engineAnalysis.firingRuleCount[event.type]++; + engineAnalysis.matchedConditions[event.type].push( + ...Object.values(ruleConditions), + ); + for (const scalingMetric of scalingMetrics) { + engineAnalysis.scalingMetrics[event.type].add(scalingMetric); + } + } else { + logger.debug({ + message: `\tIgnoring unexpectedly firing rule of type ${event.type}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + event: event, + }); + } + }); + + const facts = {}; + Object.values(cluster.metrics).forEach((metric) => { + // @ts-ignore + facts[metric.name] = metric.value; // TODO strict types + }); + + await rulesEngine.run(facts); + + return engineAnalysis; +} + +/** + * Get the scaling direction for the given cluster based + * on its metrics and the rules engine + * + * @param {!RuleEngineAnalysis} engineAnalysis Analysis from the engine rules. + * @return {AutoscalerDirection} Direction in which to scale. + */ +function getScalingDirection(engineAnalysis) { + if (!engineAnalysis) return AutoscalerDirection.NONE; + + if (engineAnalysis.firingRuleCount[AutoscalerDirection.OUT] > 0) { + return AutoscalerDirection.OUT; + } + + if (engineAnalysis.firingRuleCount[AutoscalerDirection.IN] > 0) { + return AutoscalerDirection.IN; + } + + return AutoscalerDirection.NONE; +} + +/** + * Retrieve the current maximum memory utilization for the cluster. + * + * @param {AutoscalerMemorystoreCluster} cluster + * @return {number} + */ +function getMaxMemoryUtilization(cluster) { + const MAX_UTILIZATION_METRIC = 'memory_maximum_utilization'; + + for (const metric of /** @type {MemorystoreClusterMetricValue[]} */ ( + cluster.metrics + )) { + if (metric.name === MAX_UTILIZATION_METRIC) { + return metric.value; + } + } + throw new Error(`Cluster metrics had no ${MAX_UTILIZATION_METRIC} field.`); +} + +/** + * Ensure scaling operation is safe to perform. + * + * Check that the suggested size at least the current memory usage plus + * a safety margin, then clamp the size to the next shard that is greater + * than that value. This prevents scaling to a cluster size that is too + * small to comfortably accommodate the current keyspace, per the + * documented best practice. + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {number} suggestedSize + * @param {AutoscalerDirection} scalingDirection + * @return {number} + */ +function ensureMinFreeMemory(cluster, suggestedSize, scalingDirection) { + const currentUtilization = getMaxMemoryUtilization(cluster); + const usedShards = cluster.shardCount * (currentUtilization / 100); + const safeSize = Math.ceil( + usedShards / (1 - cluster.minFreeMemoryPercent / 100), + ); + const suggestedUsagePct = Math.round((usedShards / suggestedSize) * 100); + const safeSizeUsagePct = Math.round((usedShards / safeSize) * 100); + + let size = suggestedSize; + + logger.debug({ + message: `\tCurrent memory utilization: ${currentUtilization.toFixed(2)}%; safe utilization is at ${safeSize} ${cluster.units}: ${safeSizeUsagePct}% (utilization at suggested ${suggestedSize} ${cluster.units}: ${suggestedUsagePct}%)`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + + if (suggestedSize < safeSize) { + size = safeSize; + logger.debug({ + message: + `\tModifying scale ${scalingDirection} to ${size} ${cluster.units} (from ${suggestedSize} ${cluster.units}) ` + + `to ensure safe scaling (used ${usedShards.toFixed(2)} ${cluster.units}, minFreeMemoryPercent ${cluster.minFreeMemoryPercent}%)`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + } + return size; +} + +/** + * Clamp cluster size to between cluster.minSize and cluster.maxSize + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {number} suggestedSize + * @param {AutoscalerDirection} scalingDirection + * @return {number} + */ +function ensureValidClusterSize(cluster, suggestedSize, scalingDirection) { + let size = suggestedSize; + if (suggestedSize > cluster.maxSize) { + logger.debug({ + message: `\tClamping the suggested size of ${suggestedSize} ${cluster.units} to configured maximum ${cluster.maxSize}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + size = cluster.maxSize; + } else if (suggestedSize < cluster.minSize) { + logger.debug({ + message: `\tClamping the suggested size of ${suggestedSize} ${cluster.units} to configured minimum ${cluster.minSize}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + size = cluster.minSize; + } + + /* + * If the calculated size is an invalid cluster shape of 4 shards: + * https://cloud.google.com/memorystore/docs/cluster/cluster-node-specification#unsupported_cluster_shape + * Then enforce a size of one greater, i.e. 5 shards, to prevent flapping. As well as this, check for a + * cluster size that is too small to prevent an invalid scaling operation. A check for a cluster size that + * is too large is not included here because this is dependent on the number of replicas in the cluster. + */ + if (size == CLUSTER_SIZE_INVALID) { + size = CLUSTER_SIZE_INVALID + 1; + logger.debug({ + message: `\tModifiying scale ${scalingDirection} to ${size} ${cluster.units} to avoid invalid cluster size of ${CLUSTER_SIZE_INVALID} ${cluster.units}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + } else if (size < CLUSTER_SIZE_MIN) { + size = CLUSTER_SIZE_MIN; + logger.debug({ + message: `\tModifiying scale ${scalingDirection} to ${size} ${cluster.units} to ensure minimally valid ${CLUSTER_SIZE_MIN} ${cluster.units}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + } + + return size; +} + +/** + * Get the suggested size for the given cluster based + * on its metrics + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {RuleSet | null} ruleSet + * @param {function( + * AutoscalerMemorystoreCluster,AutoscalerDirection,?RuleEngineAnalysis + * ): number} getSuggestedSize + * @return {Promise} + */ +async function calculateScalingDecision(cluster, ruleSet, getSuggestedSize) { + logger.debug({ + message: `---- ${cluster.projectId}/${cluster.regionId}/${cluster.clusterId}: ${cluster.scalingMethod} size suggestions----`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + logger.debug({ + message: `\tMin=${cluster.minSize}, Current=${cluster.currentSize}, Max=${cluster.maxSize} ${cluster.units}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + + const engineAnalysis = await getEngineAnalysis(cluster, ruleSet); + + // If there is an analysis, use it to determine direction, otherwise + // prioritize scaling out. + const scalingDirection = engineAnalysis + ? getScalingDirection(engineAnalysis) + : AutoscalerDirection.OUT; + + logger.debug({ + message: `\tScaling direction: ${scalingDirection}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + + const suggestedSize = getSuggestedSize( + cluster, + scalingDirection, + engineAnalysis, + ); + const scaleSuggestionMessage = getScaleSuggestionMessage( + cluster, + suggestedSize, + scalingDirection, + ); + + logger.debug({ + message: `\tInitial scaling suggestion: ${scaleSuggestionMessage}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + + const safeClusterSize = ensureMinFreeMemory( + cluster, + suggestedSize, + scalingDirection, + ); + + const finalClusterSize = ensureValidClusterSize( + cluster, + safeClusterSize, + scalingDirection, + ); + + logger.debug({ + message: `\t=> Final ${cluster.scalingMethod} suggestion: ${finalClusterSize} ${cluster.units}`, + projectId: cluster.projectId, + regionId: cluster.regionId, + clusterId: cluster.clusterId, + }); + return finalClusterSize; +} + +module.exports = { + calculateScalingDecision, +}; diff --git a/src/scaler/scaler-core/scaling-methods/direct.js b/src/scaler/scaler-core/scaling-methods/direct.js new file mode 100644 index 0000000..8a8c91e --- /dev/null +++ b/src/scaler/scaler-core/scaling-methods/direct.js @@ -0,0 +1,68 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Direct scaling method + * + * Sets the instance to the maxSize directly (avoiding forbidden sizes) + */ +const baseModule = require('./base'); + +/** + * @typedef {import('../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + * @typedef {import('../../../autoscaler-common/types').AutoscalerDirection} + * AutoscalerDirection + * @typedef {import('../../../autoscaler-common/types').RuleSet} + * RuleSet + * @typedef {import('../../../autoscaler-common/types').RuleEngineAnalysis} + * RuleEngineAnalysis + */ + +/** + * Calculates the suggested cluster size for a given metric. + * + * Always scales to the max size. + * + * @param {AutoscalerMemorystoreCluster} cluster for which to suggest a new + * size. + * @param {AutoscalerDirection} direction Direction in which to scale. Not in + * use. + * @param {?RuleEngineAnalysis} engineAnalysis Results from the engine analysis. + * Not in use. + * @return {number} Final suggested size for the cluster. + */ +function getSuggestedSize(cluster, direction, engineAnalysis) { + return cluster.maxSize; +} + +/** + * Scaling calculation for Direct method. Always scales to max size no matter + * what the conditions of the cluster. + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {RuleSet} ruleSet to use to determine scaling decisions. + * @return {Promise} + */ +async function calculateSize(cluster, ruleSet) { + return baseModule.calculateScalingDecision( + cluster, + // The only rule is there are no rules. + null, + getSuggestedSize, + ); +} + +module.exports = {calculateSize}; diff --git a/src/scaler/scaler-core/scaling-methods/linear.js b/src/scaler/scaler-core/scaling-methods/linear.js new file mode 100644 index 0000000..d81f889 --- /dev/null +++ b/src/scaler/scaler-core/scaling-methods/linear.js @@ -0,0 +1,210 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/** @fileoverview Linear scaling method. + * + * Suggests adding or removing shards calculated with proportionality to the + * resources used. + */ + +const {AutoscalerDirection} = require('../../../autoscaler-common/types'); +const baseModule = require('./base'); +const {logger} = require('../../../autoscaler-common/logger'); + +/** + * @typedef {import( + * '../../../autoscaler-common/types').AutoscalerMemorystoreCluster} + * AutoscalerMemorystoreCluster + * @typedef {import( + * '../../../autoscaler-common/types').MemorystoreClusterMetricValue} + * MemorystoreClusterMetricValue + * @typedef {import('../../../autoscaler-common/types').RuleSet} + * RuleSet + * @typedef {import('../../../autoscaler-common/types').RuleEngineAnalysis} + * RuleEngineAnalysis + * @typedef {import('../../../autoscaler-common/types').Condition} + * Condition + * @typedef {import('../../../autoscaler-common/types').ScalingMetricList} + * ScalingMetricList + */ + +/** + * Gets the metrics for scaling based on direction. + * + * When multiple rules provide different condition values, we use the highest. + * + * @param {AutoscalerMemorystoreCluster} cluster for which to get scaling + * metrics. + * @param {AutoscalerDirection} direction in which we are scaling. + * @param {?RuleEngineAnalysis} engineAnalysis Results from the engine analysis. + * @return {!ScalingMetricList} List of scaling metrics that are selected for + * scaling with name, value and threshold. + */ +function getMetricsForScaling(cluster, direction, engineAnalysis) { + if (!engineAnalysis) return []; + + const /** @type {Condition[]} */ matchedConditions = + engineAnalysis.matchedConditions[direction]; + if (!matchedConditions) return []; + + const /** @type {!Set} */ scalingMetrics = + engineAnalysis.scalingMetrics[direction]; + if (!scalingMetrics) return []; + + // Doing a map and filter would be more elegant, but filter() does not + // properly narrow down types. + const /** @type {ScalingMetricList} */ matchedMetrics = []; + for (const matchedCondition of matchedConditions) { + const metricName = matchedCondition.fact; + if (!scalingMetrics.has(metricName)) continue; + + const metricValue = matchedCondition.factResult; + const metricThreshold = matchedCondition.value; + + // This should not happen since the rules engine won't be able to trigger + // this rule if the metric (fact) is not defined. + if (metricValue === null || metricValue === undefined) { + logger.error({ + message: + `Unable to use this metric for linear scaling. ` + + `No value for metric ${metricName} on the cluster. ` + + `Consider removing this metric from scalingMetrics or adding a ` + + `value to the condition for the fact with this name.`, + projectId: cluster.projectId, + regionId: cluster.regionId, + instanceId: cluster.clusterId, + }); + continue; + } + + // This should not happen since the rules engine won't be able to trigger + // this rule if there is not threshold (condition.value). + if (typeof metricThreshold !== 'number') { + logger.error({ + message: + `Unable to use this metric for linear scaling. ` + + `No numeric threshold value for ${metricName}. ` + + `Consider removing this metric from scalingMetrics or adding a ` + + `numeric value to the condition for the fact with this name. ` + + `If a value is already added, ensure it is a number (numeric type).`, + projectId: cluster.projectId, + regionId: cluster.regionId, + instanceId: cluster.clusterId, + }); + continue; + } + + if (metricThreshold === 0) { + logger.error({ + message: + `Unable to use this metric for linear scaling. ` + + `The threshold value for ${metricName} is 0. Linear scaling uses ` + + `threshold value as part of the cross multiplication to calculate ` + + `the size and it is not possible to divide by 0. ` + + `Consider removing this metric from scalingMetrics or adding a ` + + `value other than 0 to the condition for the fact with this name.`, + projectId: cluster.projectId, + regionId: cluster.regionId, + instanceId: cluster.clusterId, + }); + continue; + } + + const matchedMetric /** @type {MemorystoreClusterMetricValue} */ = { + name: metricName, + value: metricValue, + threshold: metricThreshold, + }; + + matchedMetrics.push(matchedMetric); + } + + return matchedMetrics; +} + +/** + * Calculates the suggested cluster size for a given metric. + * + * @param {AutoscalerMemorystoreCluster} cluster for which to suggest a new + * size. + * @param {AutoscalerDirection} direction Direction in which to scale. + * @param {?RuleEngineAnalysis} engineAnalysis Results from the engine analysis. + * @return {number} Final suggested size for the cluster. + */ +function getSuggestedSize(cluster, direction, engineAnalysis) { + if (!engineAnalysis) return cluster.currentSize; + if (direction === AutoscalerDirection.NONE) return cluster.currentSize; + + const scalingMetricList = getMetricsForScaling( + cluster, + direction, + engineAnalysis, + ); + if (!scalingMetricList) return cluster.currentSize; + + let suggestedSize = null; + for (const scalingMetric of scalingMetricList) { + // This should not happen as this check is done on getMetricsForScaling + // and an error message is logged. However, this helps type inference. + if (!scalingMetric.threshold) continue; + + // Linear scaling main calculation. + const metricSuggestedSize = Math.ceil( + cluster.currentSize * (scalingMetric.value / scalingMetric.threshold), + ); + + suggestedSize = Math.max(suggestedSize || 0, metricSuggestedSize); + } + if (suggestedSize === null) suggestedSize = cluster.currentSize; + + if (direction === AutoscalerDirection.IN) { + if (cluster.scaleInLimit) { + suggestedSize = Math.max( + suggestedSize, + cluster.currentSize - cluster.scaleInLimit, + ); + } + + if (suggestedSize < cluster.currentSize) return suggestedSize; + } else if (direction === AutoscalerDirection.OUT) { + if (cluster.scaleOutLimit) { + suggestedSize = Math.min( + suggestedSize, + cluster.currentSize + cluster.scaleOutLimit, + ); + } + + if (suggestedSize > cluster.currentSize) return suggestedSize; + } + + return cluster.currentSize; +} + +/** + * Calculates cluster size for the Linear scaling method. + * + * @param {AutoscalerMemorystoreCluster} cluster to scale. + * @param {RuleSet} ruleSet to use to determine scaling decisions. + * @return {Promise} with the number of nodes to which to scale. + */ +async function calculateSize(cluster, ruleSet) { + return baseModule.calculateScalingDecision( + cluster, + ruleSet, + getSuggestedSize, + ); +} + +module.exports = {calculateSize}; diff --git a/src/scaler/scaler-core/scaling-methods/stepwise.js b/src/scaler/scaler-core/scaling-methods/stepwise.js new file mode 100644 index 0000000..b8c06bc --- /dev/null +++ b/src/scaler/scaler-core/scaling-methods/stepwise.js @@ -0,0 +1,68 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Stepwise scaling method + * + * Default method used by the scaler. + * Suggests adding or removing shards using a fixed step size. + */ +const {AutoscalerDirection} = require('../../../autoscaler-common/types'); +const baseModule = require('./base'); + +/** + * @typedef {import('../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + * @typedef {import('../../../autoscaler-common/types.js').RuleSet} RuleSet + * @typedef {import('../../../autoscaler-common/types').RuleEngineAnalysis} + * RuleEngineAnalysis + */ + +/** + * Calculates the suggested cluster size for a given metric. + * + * @param {AutoscalerMemorystoreCluster} cluster for which to suggest a new + * size. + * @param {AutoscalerDirection} direction Direction in which to scale. + * @param {?RuleEngineAnalysis} engineAnalysis Results from the engine analysis. + * Not in use. + * @return {number} Final suggested size for the cluster. + */ +function getSuggestedSize(cluster, direction, engineAnalysis) { + if (direction === AutoscalerDirection.OUT) { + return cluster.currentSize + cluster.stepSize; + } else if (direction === AutoscalerDirection.IN) { + return cluster.currentSize - cluster.stepSize; + } else { + return cluster.currentSize; + } +} + +/** + * Scaling calculation for Stepwise method + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {RuleSet} ruleSet to use to determine scaling decisions. + * @return {Promise} + */ +async function calculateSize(cluster, ruleSet) { + return baseModule.calculateScalingDecision( + cluster, + ruleSet, + getSuggestedSize, + ); +} + +module.exports = {calculateSize}; diff --git a/src/scaler/scaler-core/scaling-profiles/profiles/README.md b/src/scaler/scaler-core/scaling-profiles/profiles/README.md new file mode 100644 index 0000000..35c5235 --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/profiles/README.md @@ -0,0 +1,13 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler +

+ +## Overview + +This directory contains profiles for scaling based on: + +* [CPU utilization](./cpu.js) +* [Memory utilization](./memory.js) +* [CPU and Memory utilization](./cpu_and_memory.js) diff --git a/src/scaler/scaler-core/scaling-profiles/profiles/cpu.js b/src/scaler/scaler-core/scaling-profiles/profiles/cpu.js new file mode 100644 index 0000000..b153790 --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/profiles/cpu.js @@ -0,0 +1,33 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const cpuHighAverageUtilization = require('../rules/cpu/cpu-high-average-utilization.js'); +const cpuHighMaximumUtilization = require('../rules/cpu/cpu-high-maximum-utilization.js'); +const cpuLowAverageUtilization = require('../rules/cpu/cpu-low-average-utilization.js'); +const cpuLowMaximumUtilization = require('../rules/cpu/cpu-low-maximum-utilization.js'); + +/** + * @typedef {import('../../../../autoscaler-common/types.js').RuleSet} + * RuleSet + */ + +/** @type {RuleSet} */ +module.exports.ruleSet = { + cpuHighMaximumUtilization, + cpuHighAverageUtilization, + cpuLowMaximumUtilization, + cpuLowAverageUtilization, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/profiles/cpu_and_memory.js b/src/scaler/scaler-core/scaling-profiles/profiles/cpu_and_memory.js new file mode 100644 index 0000000..e885afd --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/profiles/cpu_and_memory.js @@ -0,0 +1,41 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const cpuHighAverageUtilization = require('../rules/cpu/cpu-high-average-utilization.js'); +const cpuHighMaximumUtilization = require('../rules/cpu/cpu-high-maximum-utilization.js'); +const cpuLowAverageUtilization = require('../rules/cpu/cpu-low-average-utilization.js'); +const cpuLowMaximumUtilization = require('../rules/cpu/cpu-low-maximum-utilization.js'); + +const memoryHighAverageUtilization = require('../rules/memory/memory-high-average-utilization.js'); +const memoryHighMaximumUtilization = require('../rules/memory/memory-high-maximum-utilization.js'); +const memoryLowAverageUtilization = require('../rules/memory/memory-low-average-utilization.js'); +const memoryLowMaximumUtilization = require('../rules/memory/memory-low-maximum-utilization.js'); + +/** + * @typedef {import('../../../../autoscaler-common/types.js').RuleSet} RuleSet + */ + +/** @type {RuleSet} */ +module.exports.ruleSet = { + cpuHighMaximumUtilization, + cpuHighAverageUtilization, + cpuLowMaximumUtilization, + cpuLowAverageUtilization, + memoryHighAverageUtilization, + memoryHighMaximumUtilization, + memoryLowAverageUtilization, + memoryLowMaximumUtilization, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/profiles/memory.js b/src/scaler/scaler-core/scaling-profiles/profiles/memory.js new file mode 100644 index 0000000..5e96d7d --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/profiles/memory.js @@ -0,0 +1,33 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const memoryHighAverageUtilization = require('../rules/memory/memory-high-average-utilization.js'); +const memoryHighMaximumUtilization = require('../rules/memory/memory-high-maximum-utilization.js'); +const memoryLowAverageUtilization = require('../rules/memory/memory-low-average-utilization.js'); +const memoryLowMaximumUtilization = require('../rules/memory/memory-low-maximum-utilization.js'); + +/** + * @typedef {import('../../../../autoscaler-common/types.js').RuleSet} + * RuleSet + */ + +/** @type {RuleSet} */ +module.exports.ruleSet = { + memoryHighAverageUtilization, + memoryHighMaximumUtilization, + memoryLowAverageUtilization, + memoryLowMaximumUtilization, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/rules/README.md b/src/scaler/scaler-core/scaling-profiles/rules/README.md new file mode 100644 index 0000000..e402343 --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/README.md @@ -0,0 +1,12 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler +

+ +## Overview + +This directory contains rules for scaling based on: + +* [CPU utilization](./cpu/README.md) +* [Memory utilization](./memory/README.md) diff --git a/src/scaler/scaler-core/scaling-profiles/rules/cpu/README.md b/src/scaler/scaler-core/scaling-profiles/rules/cpu/README.md new file mode 100644 index 0000000..f76121d --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/cpu/README.md @@ -0,0 +1,9 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler +

+ +## Overview + +This directory contains rules for scaling based on CPU utilization. diff --git a/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-high-average-utilization.js b/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-high-average-utilization.js new file mode 100644 index 0000000..8e1d95e --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-high-average-utilization.js @@ -0,0 +1,40 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +const {basename} = require('path'); + +/** + * @fileoverview Rule which triggers when the average CPU utilization is > 80% + * + * @type {import('json-rules-engine').RuleProperties} + */ +module.exports = { + name: basename(__filename, '.js'), + conditions: { + all: [ + { + fact: 'cpu_average_utilization', + operator: 'greaterThan', + value: 80, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'high average CPU utilization', + scalingMetrics: ['cpu_average_utilization'], + }, + }, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-high-maximum-utilization.js b/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-high-maximum-utilization.js new file mode 100644 index 0000000..ef245b2 --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-high-maximum-utilization.js @@ -0,0 +1,46 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +const {basename} = require('path'); + +/** + * @fileoverview Rule which triggers when the CPU utilization is high based + * on average cpu > 70% and max cpu > 75% + * + * @type {import('json-rules-engine').RuleProperties} + */ +module.exports = { + name: basename(__filename, '.js'), + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 75, + }, + { + fact: 'cpu_average_utilization', + operator: 'greaterThan', + value: 70, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'high maximum CPU utilization', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-low-average-utilization.js b/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-low-average-utilization.js new file mode 100644 index 0000000..fddfc55 --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-low-average-utilization.js @@ -0,0 +1,51 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +const {basename} = require('path'); + +/** + * @fileoverview Rule which triggers when the average CPU utilization low + * based on < 75% average CPU with no evicted keys. + * + * @type {import('json-rules-engine').RuleProperties} + */ +module.exports = { + name: basename(__filename, '.js'), + conditions: { + all: [ + { + fact: 'cpu_average_utilization', + operator: 'lessThan', + value: 75, + }, + { + fact: 'maximum_evicted_keys', + operator: 'equal', + value: 0, + }, + { + fact: 'average_evicted_keys', + operator: 'equal', + value: 0, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'low average CPU utilization', + scalingMetrics: ['cpu_average_utilization'], + }, + }, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-low-maximum-utilization.js b/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-low-maximum-utilization.js new file mode 100644 index 0000000..73f0432 --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/cpu/cpu-low-maximum-utilization.js @@ -0,0 +1,55 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +const {basename} = require('path'); + +/** + * @fileoverview Rule which triggers when the max CPU utilization is low and no keys are being evicted + * + * @type {import('json-rules-engine').RuleProperties} + */ +module.exports = { + name: basename(__filename, '.js'), + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 70, + }, + { + fact: 'cpu_average_utilization', + operator: 'lessThan', + value: 50, + }, + { + fact: 'maximum_evicted_keys', + operator: 'equal', + value: 0, + }, + { + fact: 'average_evicted_keys', + operator: 'equal', + value: 0, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'low maximum CPU utilization', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/rules/memory/README.md b/src/scaler/scaler-core/scaling-profiles/rules/memory/README.md new file mode 100644 index 0000000..f52d169 --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/memory/README.md @@ -0,0 +1,9 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler +

+ +## Overview + +This directory contains rules for scaling based on memory utilization. diff --git a/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-high-average-utilization.js b/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-high-average-utilization.js new file mode 100644 index 0000000..1d3900b --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-high-average-utilization.js @@ -0,0 +1,40 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +const {basename} = require('path'); + +/** + * @fileoverview Rule which triggers when average memory usage is > 80% + * + * @type {import('json-rules-engine').RuleProperties} + */ +module.exports = { + name: basename(__filename, '.js'), + conditions: { + all: [ + { + fact: 'memory_average_utilization', + operator: 'greaterThan', + value: 80, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'high average memory utilization', + scalingMetrics: ['memory_average_utilization'], + }, + }, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-high-maximum-utilization.js b/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-high-maximum-utilization.js new file mode 100644 index 0000000..1b3bd4f --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-high-maximum-utilization.js @@ -0,0 +1,45 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +const {basename} = require('path'); + +/** + * @fileoverview Rule which triggers when max memory usage is > 90% + * + * @type {import('json-rules-engine').RuleProperties} + */ +module.exports = { + name: basename(__filename, '.js'), + conditions: { + all: [ + { + fact: 'memory_maximum_utilization', + operator: 'greaterThan', + value: 90, + }, + { + fact: 'memory_average_utilization', + operator: 'greaterThan', + value: 60, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'high maximum memory utilization', + scalingMetrics: ['memory_maximum_utilization'], + }, + }, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-low-average-utilization.js b/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-low-average-utilization.js new file mode 100644 index 0000000..35ade73 --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-low-average-utilization.js @@ -0,0 +1,51 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +const {basename} = require('path'); + +/** + * @fileoverview Rule which triggers when average memory usage is less than 70% + * and no keys are being evicted + * + * @type {import('json-rules-engine').RuleProperties} + */ +module.exports = { + name: basename(__filename, '.js'), + conditions: { + all: [ + { + fact: 'memory_average_utilization', + operator: 'lessThan', + value: 70, + }, + { + fact: 'maximum_evicted_keys', + operator: 'equal', + value: 0, + }, + { + fact: 'average_evicted_keys', + operator: 'equal', + value: 0, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'low average memory utilization', + scalingMetrics: ['memory_average_utilization'], + }, + }, +}; diff --git a/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-low-maximum-utilization.js b/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-low-maximum-utilization.js new file mode 100644 index 0000000..3780cf7 --- /dev/null +++ b/src/scaler/scaler-core/scaling-profiles/rules/memory/memory-low-maximum-utilization.js @@ -0,0 +1,56 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +const {basename} = require('path'); + +/** + * @fileoverview Rule which triggers when max and average memory usage is low + * and no keys are being evicted. + * + * @type {import('json-rules-engine').RuleProperties} + */ +module.exports = { + name: basename(__filename, '.js'), + conditions: { + all: [ + { + fact: 'memory_maximum_utilization', + operator: 'lessThan', + value: 60, + }, + { + fact: 'memory_average_utilization', + operator: 'lessThan', + value: 40, + }, + { + fact: 'maximum_evicted_keys', + operator: 'equal', + value: 0, + }, + { + fact: 'average_evicted_keys', + operator: 'equal', + value: 0, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'low maximum memory utilization', + scalingMetrics: ['memory_maximum_utilization'], + }, + }, +}; diff --git a/src/scaler/scaler-core/state.js b/src/scaler/scaler-core/state.js new file mode 100644 index 0000000..8f1af3f --- /dev/null +++ b/src/scaler/scaler-core/state.js @@ -0,0 +1,533 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Manages the Autoscaler persistent state + * + * By default, this implementation uses a Firestore instance in the same + * project as the Memorystore Cluster. To use a different project, set the + * `stateProjectId` parameter in the Cloud Scheduler configuration. + * + * To use another database to save autoscaler state, set the + * `stateDatabase.name` parameter in the Cloud Scheduler configuration. + * The default database is Firestore. + */ + +const firestore = require('@google-cloud/firestore'); +const spanner = require('@google-cloud/spanner'); +const {logger} = require('../../autoscaler-common/logger'); +const assertDefined = require('../../autoscaler-common/assert-defined'); +const {memoize} = require('lodash'); + +/** + * @typedef {import('../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + * @typedef {import('../../autoscaler-common/types').StateDatabaseConfig + * } StateDatabaseConfig + */ + +/** + * @typedef StateData + * @property {number?} lastScalingCompleteTimestamp - when the last scaling operation completed. + * @property {string?} scalingOperationId - the ID of the currently in progress scaling operation. + * @property {number?} scalingRequestedSize - the requested size of the currently in progress scaling operation. + * - the requested size is not returned in the LRO, so we keep a note of it here. + * @property {number?} scalingPreviousSize - the size of the cluster before the currently in progress scaling operation started. + * @property {string?} scalingMethod - the scaling method used to calculate the size for the currently in progress scaling operation. + * @property {number} lastScalingTimestamp - when the last scaling operation was started. + * @property {number} createdOn - the timestamp when this record was created + * @property {number} updatedOn - the timestamp when this record was updated. + */ + +/** @typedef {{name: string, type: string}} ColumnDef */ +/** @type {Array} */ +const STATE_KEY_DEFINITIONS = [ + {name: 'lastScalingTimestamp', type: 'timestamp'}, + {name: 'createdOn', type: 'timestamp'}, + {name: 'updatedOn', type: 'timestamp'}, + {name: 'lastScalingCompleteTimestamp', type: 'timestamp'}, + {name: 'scalingOperationId', type: 'string'}, + {name: 'scalingRequestedSize', type: 'number'}, + {name: 'scalingPreviousSize', type: 'number'}, + {name: 'scalingMethod', type: 'string'}, +]; + +/** + * Used to store state of a cluster + */ +class State { + /** + * Build a State object for the given configuration + * + * @param {AutoscalerMemorystoreCluster} cluster + * @return {State} + */ + static buildFor(cluster) { + if (!cluster) { + throw new Error('cluster should not be null'); + } + switch (cluster?.stateDatabase?.name) { + case 'firestore': + return new StateFirestore(cluster); + case 'spanner': + return new StateSpanner(cluster); + default: + return new StateFirestore(cluster); + } + } + + /** + * @constructor + * @protected + * @param {AutoscalerMemorystoreCluster} cluster + */ + constructor(cluster) { + /** @type {string} */ + this.stateProjectId = + cluster.stateProjectId != null + ? cluster.stateProjectId + : cluster.projectId; + this.projectId = cluster.projectId; + this.regionId = cluster.regionId; + this.clusterId = cluster.clusterId; + } + + /** + * Initialize value in storage + * @return {Promise<*>} + */ + async init() { + throw new Error('Not implemented'); + } + + /** + * Get scaling timestamp from storage + * + * @return {Promise} + */ + async get() { + throw new Error('Not implemented'); + } + + /** + * Update state data in storage with the given values + * @param {StateData} stateData + */ + async updateState(stateData) { + throw new Error('Not implemented'); + } + + /** + * Close storage + */ + async close() { + throw new Error('Not implemented'); + } + + /** + * Get current timestamp in millis. + * + * @return {number}; + */ + get now() { + return Date.now(); + } + + /** + * @return {string} full ID for this cluster + */ + getClusterId() { + return `projects/${this.projectId}/regions/${this.regionId}/clusters/${this.clusterId}`; + } +} + +module.exports = State; + +/** + * Manages the Autoscaler persistent state in spanner. + * + * To manage the Autoscaler state in a spanner database, + * set the `stateDatabase.name` parameter to 'spanner' in the Cloud Scheduler + * configuration. The following is an example. + * + * { + * "stateDatabase": { + * "name": "spanner", + * "instanceId": "autoscale-test", // your instance id + * "databaseId": "my-database" // your database id + * } + * } + */ +class StateSpanner extends State { + /** + * Builds a Spanner DatabaseClient from parameters in spanner.stateDatabase + * @param {string} stateProjectId + * @param {StateDatabaseConfig} stateDatabase + * @return {spanner.Database} + */ + static createSpannerDatabaseClient(stateProjectId, stateDatabase) { + const spannerClient = new spanner.Spanner({projectId: stateProjectId}); + const instance = spannerClient.instance( + assertDefined(stateDatabase.instanceId), + ); + return instance.database(assertDefined(stateDatabase.databaseId)); + } + + /** + * Builds a Spanner database path - used as the key for memoize + * @param {string} stateProjectId + * @param {StateDatabaseConfig} stateDatabase + * @return {string} + */ + static getStateDatabasePath(stateProjectId, stateDatabase) { + return `projects/${stateProjectId}/instances/${stateDatabase.instanceId}/databases/${stateDatabase.databaseId}`; + } + + /** + * Memoize createSpannerDatabseClient() so that we only create one Spanner + * database client for each database ID. + */ + static getSpannerDatabaseClient = memoize( + StateSpanner.createSpannerDatabaseClient, + StateSpanner.getStateDatabasePath, + ); + + /** + * @param {AutoscalerMemorystoreCluster} cluster + */ + constructor(cluster) { + super(cluster); + if (!cluster.stateDatabase) { + throw new Error('stateDatabase is not defined in Spanner config'); + } + this.stateDatabase = cluster.stateDatabase; + + /** @type {spanner.Database} */ + const databaseClient = StateSpanner.getSpannerDatabaseClient( + this.stateProjectId, + this.stateDatabase, + ); + this.table = databaseClient.table('memorystoreClusterAutoscaler'); + } + + /** @inheritdoc */ + async init() { + /** @type {StateData} */ + const data = { + createdOn: this.now, + updatedOn: this.now, + lastScalingTimestamp: 0, + lastScalingCompleteTimestamp: 0, + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + }; + await this.writeToSpanner(StateSpanner.convertToStorage(data)); + // Need to return storage-format data which uses Date objects + return { + createdOn: new Date(data.createdOn), + updatedOn: new Date(data.updatedOn), + lastScalingTimestamp: new Date(0), + lastScalingCompleteTimestamp: new Date(0), + scalingOperationId: null, + }; + } + + /** @inheritdoc */ + async get() { + try { + const query = { + columns: STATE_KEY_DEFINITIONS.map((c) => c.name), + keySet: {keys: [{values: [{stringValue: this.getClusterId()}]}]}, + }; + + const [rows] = await this.table.read(query); + if (rows.length == 0) { + return StateSpanner.convertFromStorage(await this.init()); + } + return StateSpanner.convertFromStorage(rows[0].toJSON()); + } catch (e) { + logger.fatal({ + message: `Failed to read from Spanner State storage: ${StateSpanner.getStateDatabasePath(this.stateProjectId, this.stateDatabase)}/tables/${this.table.name}: ${e}`, + err: e, + }); + throw e; + } + } + + /** @inheritdoc */ + async close() {} + + /** + * Converts row data from Spanner.timestamp (implementation detail) + * to standard JS timestamps, which are number of milliseconds since Epoch + * @param {*} rowData cluster data + * @return {StateData} converted rowData + */ + static convertFromStorage(rowData) { + /** @type {{[x:string] : any}} */ + const ret = {}; + + const rowDataKeys = Object.keys(rowData); + + for (const colDef of STATE_KEY_DEFINITIONS) { + if (rowDataKeys.includes(colDef.name)) { + // copy value + ret[colDef.name] = rowData[colDef.name]; + if (rowData[colDef.name] instanceof Date) { + ret[colDef.name] = rowData[colDef.name].getTime(); + } + } else { + // value not present in storage + if (colDef.type === 'timestamp') { + ret[colDef.name] = 0; + } else { + ret[colDef.name] = null; + } + } + } + return /** @type {StateData} */ (ret); + } + + /** + * Convert StateData to a row object only containing defined spanner + * columns, including converting timestamps. + * + * @param {StateData} stateData + * @return {*} Spanner row + */ + static convertToStorage(stateData) { + /** @type {{[x:string]: any}} */ + const row = {}; + + const stateDataKeys = Object.keys(stateData); + + // Only copy values into row that have defined column names. + for (const colDef of STATE_KEY_DEFINITIONS) { + if (stateDataKeys.includes(colDef.name)) { + // copy value + // @ts-ignore + row[colDef.name] = stateData[colDef.name]; + + // convert timestamp + if (colDef.type === 'timestamp' && row[colDef.name] !== null) { + // convert millis to ISO timestamp + row[colDef.name] = new Date(row[colDef.name]).toISOString(); + } + } + } + return row; + } + + /** + * Update state data in storage with the given values + * @param {StateData} stateData + */ + async updateState(stateData) { + stateData.updatedOn = this.now; + const row = StateSpanner.convertToStorage(stateData); + + // we never want to update createdOn + delete row.createdOn; + + await this.writeToSpanner(row); + } + + /** + * Write the given row to spanner, retrying with the older + * schema if a column not found error is returned. + * @param {*} row + */ + async writeToSpanner(row) { + try { + row.id = this.getClusterId(); + await this.table.upsert(row); + } catch (e) { + logger.fatal({ + msg: `Failed to write to Spanner State storage: ${StateSpanner.getStateDatabasePath(this.stateProjectId, this.stateDatabase)}/tables/${this.table.name}: ${e}`, + err: e, + }); + throw e; + } + } +} + +/** + * Manages the Autoscaler persistent state in firestore. + * + * The default database for state management is firestore. + * It is also possible to manage with firestore + * by explicitly setting `stateDatabase.name` to 'firestore'. + * The following is an example. + * + * { + * "stateDatabase": { + * "name": "firestore" + * } + * } + */ +class StateFirestore extends State { + /** + * Builds a Firestore client for the given project ID + * @param {string} stateProjectId + * @return {firestore.Firestore} + */ + static createFirestoreClient(stateProjectId) { + return new firestore.Firestore({projectId: stateProjectId}); + } + + /** + * Memoize createFirestoreClient() so that we only create one Firestore + * client for each stateProject + */ + static getFirestoreClient = memoize(StateFirestore.createFirestoreClient); + + /** + * @param {AutoscalerMemorystoreCluster} cluster + */ + constructor(cluster) { + super(cluster); + this.firestore = StateFirestore.getFirestoreClient(this.stateProjectId); + } + + /** + * build or return the document reference + * @return {firestore.DocumentReference} + */ + get docRef() { + if (this._docRef == null) { + this._docRef = this.firestore.doc( + `memorystoreClusterAutoscaler/state/${this.getClusterId()}`, + ); + } + return this._docRef; + } + + /** + * Converts document data from Firestore.Timestamp (implementation detail) + * to standard JS timestamps, which are number of milliseconds since Epoch + * https://googleapis.dev/nodejs/firestore/latest/Timestamp.html + * @param {*} docData + * @return {StateData} converted docData + */ + static convertFromStorage(docData) { + /** @type {{[x:string]: any}} */ + const ret = {}; + + const docDataKeys = Object.keys(docData); + + // Copy values into row that are present and are known keys. + for (const colDef of STATE_KEY_DEFINITIONS) { + if (docDataKeys.includes(colDef.name)) { + ret[colDef.name] = docData[colDef.name]; + if (docData[colDef.name] instanceof firestore.Timestamp) { + ret[colDef.name] = docData[colDef.name].toMillis(); + } + } else { + // not present in doc: + if (colDef.type === 'timestamp') { + ret[colDef.name] = 0; + } else { + ret[colDef.name] = null; + } + } + } + return /** @type {StateData} */ (ret); + } + + /** + * Convert StateData to an object only containing defined + * columns, including converting timestamps from millis to Firestore.Timestamp + * + * @param {*} stateData + * @return {*} + */ + static convertToStorage(stateData) { + /** @type {{[x:string]: any}} */ + const doc = {}; + + const stateDataKeys = Object.keys(stateData); + + // Copy values into row that are present and are known keys. + for (const colDef of STATE_KEY_DEFINITIONS) { + if (stateDataKeys.includes(colDef.name)) { + if (colDef.type === 'timestamp') { + // convert millis to Firestore timestamp + doc[colDef.name] = firestore.Timestamp.fromMillis( + stateData[colDef.name], + ); + } else { + // copy value + doc[colDef.name] = stateData[colDef.name]; + } + } + } + // we never want to update createdOn + delete doc.createdOn; + + return doc; + } + + /** @inheritdoc */ + async init() { + const initData = { + createdOn: firestore.Timestamp.fromMillis(this.now), + updatedOn: firestore.Timestamp.fromMillis(this.now), + lastScalingTimestamp: firestore.Timestamp.fromMillis(0), + lastScalingCompleteTimestamp: firestore.Timestamp.fromMillis(0), + scalingOperationId: null, + scalingRequestedSize: null, + scalingPreviousSize: null, + scalingMethod: null, + }; + + await this.docRef.set(initData); + return initData; + } + + /** @inheritdoc */ + async get() { + const snapshot = await this.docRef.get(); // returns QueryDocumentSnapshot + + let data; + if (!snapshot.exists) { + data = await this.init(); + } else { + data = snapshot.data(); + } + + return StateFirestore.convertFromStorage(data); + } + + /** + * Update state data in storage with the given values + * @param {StateData} stateData + */ + async updateState(stateData) { + stateData.updatedOn = this.now; + + const doc = StateFirestore.convertToStorage(stateData); + + // we never want to update createdOn + delete doc.createdOn; + + await this.docRef.update(doc); + } + + /** @inheritdoc */ + async close() {} +} diff --git a/src/scaler/scaler-core/test/counters.test.js b/src/scaler/scaler-core/test/counters.test.js new file mode 100644 index 0000000..d61688a --- /dev/null +++ b/src/scaler/scaler-core/test/counters.test.js @@ -0,0 +1,142 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +const rewire = require('rewire'); +const sinon = require('sinon'); + +describe('#scaler-counters', () => { + const counters = rewire('../counters.js'); + + const countersBaseStubs = { + incCounter: sinon.stub(), + recordValue: sinon.stub(), + }; + + /** + * @type {import('../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} + */ + const cluster = { + projectId: 'myProject', + clusterId: 'myCluster', + currentSize: 5, + maxSize: 10, + minSize: 3, + metrics: [], + regionId: 'us-central1', + scalingProfile: 'CPU_AND_MEMORY', + scalingMethod: 'STEPWISE', + scaleInCoolingMinutes: 10, + scaleOutCoolingMinutes: 5, + minFreeMemoryPercent: 30, + stepSize: 2, + units: 'SHARDS', + shardCount: 5, + sizeGb: 20, + }; + + beforeEach(() => { + Object.values(countersBaseStubs).forEach((stub) => stub.reset()); + counters.__set__('CountersBase', countersBaseStubs); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('incScalingSuccessCounter uses cluster config to determine counter attributes', async () => { + await counters.incScalingSuccessCounter(cluster, 10); + sinon.assert.calledWithExactly( + countersBaseStubs.incCounter, + 'scaler/scaling-success', + { + memorystore_cluster_project_id: 'myProject', + memorystore_cluster_instance_id: 'myCluster', + scaling_method: 'STEPWISE', + scaling_direction: 'SCALE_UP', + }, + ); + }); + + it('incScalingSuccessCounter overrides cluster config with parameters', async () => { + await counters.incScalingSuccessCounter(cluster, 10, 20, 'DIRECT'); + sinon.assert.calledWithExactly( + countersBaseStubs.incCounter, + 'scaler/scaling-success', + { + memorystore_cluster_project_id: 'myProject', + memorystore_cluster_instance_id: 'myCluster', + scaling_method: 'DIRECT', + scaling_direction: 'SCALE_DOWN', + }, + ); + }); + it('incScalingFailedCounter uses cluster config to determine counter attributes', async () => { + await counters.incScalingFailedCounter(cluster, 10); + sinon.assert.calledWithExactly( + countersBaseStubs.incCounter, + 'scaler/scaling-failed', + { + memorystore_cluster_project_id: 'myProject', + memorystore_cluster_instance_id: 'myCluster', + scaling_method: 'STEPWISE', + scaling_direction: 'SCALE_UP', + }, + ); + }); + + it('incScalingFailedCounter overrides cluster config with parameters', async () => { + await counters.incScalingFailedCounter(cluster, 10, 20, 'DIRECT'); + sinon.assert.calledWithExactly( + countersBaseStubs.incCounter, + 'scaler/scaling-failed', + { + memorystore_cluster_project_id: 'myProject', + memorystore_cluster_instance_id: 'myCluster', + scaling_method: 'DIRECT', + scaling_direction: 'SCALE_DOWN', + }, + ); + }); + it('recordScalingDuration uses cluster config to determine counter attributes', async () => { + await counters.recordScalingDuration(60_000, cluster, 10); + sinon.assert.calledWithExactly( + countersBaseStubs.recordValue, + 'scaler/scaling-duration', + 60_000, + { + memorystore_cluster_project_id: 'myProject', + memorystore_cluster_instance_id: 'myCluster', + scaling_method: 'STEPWISE', + scaling_direction: 'SCALE_UP', + }, + ); + }); + + it('recordScalingDuration overrides cluster config with parameters', async () => { + await counters.recordScalingDuration(60_000, cluster, 10, 20, 'DIRECT'); + sinon.assert.calledWithExactly( + countersBaseStubs.recordValue, + 'scaler/scaling-duration', + 60_000, + { + memorystore_cluster_project_id: 'myProject', + memorystore_cluster_instance_id: 'myCluster', + scaling_method: 'DIRECT', + scaling_direction: 'SCALE_DOWN', + }, + ); + }); +}); diff --git a/src/scaler/scaler-core/test/index.test.js b/src/scaler/scaler-core/test/index.test.js new file mode 100644 index 0000000..de5b1aa --- /dev/null +++ b/src/scaler/scaler-core/test/index.test.js @@ -0,0 +1,636 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +const rewire = require('rewire'); +const sinon = require('sinon'); +// @ts-ignore +const referee = require('@sinonjs/referee'); +// @ts-ignore +const assert = referee.assert; +const { + createClusterParameters, + createStubState, + createStateData, +} = require('./test-utils.js'); +const {afterEach} = require('mocha'); + +/** + * @typedef {import('../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + * @typedef {import('../state.js').StateData} StateData + * @typedef {import('../state.js')} State + */ + +afterEach(() => { + // Restore the default sandbox here + sinon.reset(); + sinon.restore(); +}); + +describe('#getScalingRuleSet', () => { + const app = rewire('../index.js'); + const getScalingRuleSet = app.__get__('getScalingRuleSet'); + + it('should return the ruleset for the profile name', async function () { + const cluster = createClusterParameters(); + cluster.scalingProfile = 'CPU'; + const expectedScalingRuleSetCpu = + require('../scaling-profiles/profiles/cpu.js').ruleSet; + const scalingRuleSetCpu = getScalingRuleSet(cluster); + assert.equals(scalingRuleSetCpu, expectedScalingRuleSetCpu); + assert.equals(cluster.scalingProfile, 'CPU'); + }); + + it('should default to the CPU_AND_MEMORY profile and ruleset', async function () { + const cluster = createClusterParameters(); + cluster.scalingProfile = 'UNKNOWN_SCALING_PROFILE'; + const expectedScalingRuleSetCpuAndMemory = + require('../scaling-profiles/profiles/cpu_and_memory.js').ruleSet; + const scalingRuleSetCpuAndMemory = getScalingRuleSet(cluster); + assert.equals( + scalingRuleSetCpuAndMemory, + expectedScalingRuleSetCpuAndMemory, + ); + assert.equals(cluster.scalingProfile, 'CPU_AND_MEMORY'); + }); + + it('should use a custom ruleset when provided', async function () { + const cluster = createClusterParameters(); + cluster.scalingProfile = 'CUSTOM'; + const expectedScalingRuleSetCustom = + require('./samples/custom-scaling-rules.json').scalingRules; + cluster.scalingRules = expectedScalingRuleSetCustom; + const scalingRuleSetCustom = getScalingRuleSet(cluster); + expectedScalingRuleSetCustom.forEach((rule) => { + assert.equals(rule, scalingRuleSetCustom[rule.name]); + }); + assert.equals(cluster.scalingProfile, 'CUSTOM'); + }); +}); + +describe('#getScalingMethod', () => { + const app = rewire('../index.js'); + const getScalingMethod = app.__get__('getScalingMethod'); + + it('should return the configured scaling method function', async function () { + const cluster = createClusterParameters(); + cluster.scalingMethod = 'LINEAR'; + const scalingFunction = getScalingMethod(cluster); + assert.isFunction(scalingFunction.calculateSize); + assert.equals(cluster.scalingMethod, 'LINEAR'); + }); + + it('should default to STEPWISE scaling', async function () { + const cluster = createClusterParameters(); + cluster.scalingMethod = 'UNKNOWN_SCALING_METHOD'; + const scalingFunction = getScalingMethod(cluster); + assert.isFunction(scalingFunction.calculateSize); + assert.equals(cluster.scalingMethod, 'STEPWISE'); + }); +}); + +describe('#processScalingRequest', () => { + const app = rewire('../index.js'); + const processScalingRequest = app.__get__('processScalingRequest'); + + const countersStub = { + incScalingSuccessCounter: sinon.stub(), + incScalingFailedCounter: sinon.stub(), + incScalingDeniedCounter: sinon.stub(), + recordScalingDuration: sinon.stub(), + }; + const getSuggestedSizeStub = sinon.stub(); + const withinCooldownPeriod = sinon.stub(); + const stubScaleMemorystoreCluster = sinon.stub(); + const readStateCheckOngoingLRO = sinon.stub(); + + beforeEach(() => { + // Setup common stubs + stubScaleMemorystoreCluster.resolves(); + app.__set__('scaleMemorystoreCluster', stubScaleMemorystoreCluster); + app.__set__('Counters', countersStub); + app.__set__('withinCooldownPeriod', withinCooldownPeriod.returns(false)); + app.__set__('getSuggestedSize', getSuggestedSizeStub); + app.__set__('readStateCheckOngoingLRO', readStateCheckOngoingLRO); + + readStateCheckOngoingLRO.returns(createStateData()); + }); + + afterEach(() => { + // reset stubs + Object.values(countersStub).forEach((stub) => stub.reset()); + stubScaleMemorystoreCluster.reset(); + getSuggestedSizeStub.reset(); + withinCooldownPeriod.reset(); + }); + + it('should not autoscale if suggested size is equal to current size', async function () { + const cluster = createClusterParameters(); + getSuggestedSizeStub.returns(cluster.currentSize); + + await processScalingRequest(cluster, createStubState()); + + assert.equals(stubScaleMemorystoreCluster.callCount, 0); + sinon.assert.calledOnceWithExactly( + countersStub.incScalingDeniedCounter, + sinon.match.any, + cluster.currentSize, + 'CURRENT_SIZE', + ); + }); + + it('should not autoscale if suggested size is equal to max size', async function () { + const cluster = createClusterParameters(); + cluster.currentSize = cluster.maxSize; + getSuggestedSizeStub.returns(cluster.maxSize); + + await processScalingRequest(cluster, createStubState()); + + assert.equals(stubScaleMemorystoreCluster.callCount, 0); + sinon.assert.calledOnceWithExactly( + countersStub.incScalingDeniedCounter, + sinon.match.any, + cluster.maxSize, + 'MAX_SIZE', + ); + }); + + it('should autoscale if suggested size is not equal to current size', async function () { + const cluster = createClusterParameters(); + const suggestedSize = cluster.currentSize + 1; + + getSuggestedSizeStub.returns(suggestedSize); + stubScaleMemorystoreCluster.returns('scalingOperationId'); + const stateStub = createStubState(); + + await processScalingRequest(cluster, stateStub); + assert.equals(stubScaleMemorystoreCluster.callCount, 1); + assert.equals( + stubScaleMemorystoreCluster.getCall(0).args[1], + suggestedSize, + ); + sinon.assert.calledWith(stateStub.updateState, { + lastScalingTimestamp: stateStub.now, + createdOn: 0, + updatedOn: 0, + lastScalingCompleteTimestamp: null, + scalingOperationId: 'scalingOperationId', + scalingRequestedSize: suggestedSize, + scalingMethod: cluster.scalingMethod, + scalingPreviousSize: cluster.currentSize, + }); + + assert.equals(stubScaleMemorystoreCluster.callCount, 1); + }); + it('should not autoscale if in cooldown period', async function () { + const cluster = createClusterParameters(); + const suggestedSize = cluster.currentSize + 1; + getSuggestedSizeStub.returns(suggestedSize); + withinCooldownPeriod.returns(true); + + await processScalingRequest(cluster, createStubState()); + + assert.equals(stubScaleMemorystoreCluster.callCount, 0); + sinon.assert.calledOnceWithExactly( + countersStub.incScalingDeniedCounter, + sinon.match.any, + cluster.currentSize + 1, + 'WITHIN_COOLDOWN', + ); + }); + + it('should not autoscale if scalingOperationId is set', async () => { + // set operation ongoing... + const stubState = createStubState(); + readStateCheckOngoingLRO.returns({ + lastScalingTimestamp: stubState.now, + createdOn: 0, + updatedOn: 0, + lastScalingCompleteTimestamp: 0, + scalingOperationId: 'DummyOpID', + scalingRequestedSize: 10, + }); + + const cluster = createClusterParameters(); + const suggestedSize = cluster.currentSize + 1; + getSuggestedSizeStub.returns(suggestedSize); + + await processScalingRequest(cluster, stubState); + + assert.equals(stubScaleMemorystoreCluster.callCount, 0); + sinon.assert.calledOnceWithExactly( + countersStub.incScalingDeniedCounter, + sinon.match.any, + suggestedSize, + 'IN_PROGRESS', + ); + }); +}); + +describe('#withinCooldownPeriod', () => { + const app = rewire('../index.js'); + const withinCooldownPeriod = app.__get__('withinCooldownPeriod'); + + /** @type {StateData} */ + let autoscalerState; + /** @type {AutoscalerMemorystoreCluster} */ + let clusterParams; + + const lastScalingTime = Date.parse('2024-01-01T12:00:00Z'); + const MILLIS_PER_MINUTE = 60_000; + + beforeEach(() => { + clusterParams = createClusterParameters(); + + autoscalerState = { + lastScalingCompleteTimestamp: lastScalingTime, + scalingOperationId: null, + scalingRequestedSize: null, + lastScalingTimestamp: lastScalingTime, + scalingMethod: null, + scalingPreviousSize: null, + createdOn: 0, + updatedOn: 0, + }; + }); + + it('should be false when no scaling has ever happened', () => { + autoscalerState.lastScalingCompleteTimestamp = 0; + autoscalerState.lastScalingTimestamp = 0; + + assert.isFalse( + withinCooldownPeriod( + clusterParams, + clusterParams.currentSize + 100, + autoscalerState, + lastScalingTime, + ), + ); + }); + + it('should be false when scaling up later than cooldown', () => { + // test at 1 min after end of cooldown... + const testTime = + lastScalingTime + + (clusterParams.scaleOutCoolingMinutes + 1) * MILLIS_PER_MINUTE; + + assert.isFalse( + withinCooldownPeriod( + clusterParams, + clusterParams.currentSize + 100, + autoscalerState, + testTime, + ), + ); + }); + + it('should be false when scaling down later than cooldown', () => { + // test at 1 min before end of cooldown... + const testTime = + lastScalingTime + + (clusterParams.scaleInCoolingMinutes + 1) * MILLIS_PER_MINUTE; + + assert.isFalse( + withinCooldownPeriod( + clusterParams, + clusterParams.currentSize - 100, + autoscalerState, + testTime, + ), + ); + }); + + it('should be true when scaling up within scaleOutCoolingMinutes', () => { + // test at 1 min before end of cooldown... + const testTime = + lastScalingTime + + (clusterParams.scaleOutCoolingMinutes - 1) * MILLIS_PER_MINUTE; + + assert.isTrue( + withinCooldownPeriod( + clusterParams, + clusterParams.currentSize + 100, + autoscalerState, + testTime, + ), + ); + }); + + it('should be true when scaling down within scaleInCoolingMinutes', () => { + // test at 1 min before end of cooldown... + const testTime = + lastScalingTime + + (clusterParams.scaleInCoolingMinutes - 1) * MILLIS_PER_MINUTE; + + assert.isTrue( + withinCooldownPeriod( + clusterParams, + clusterParams.currentSize - 100, + autoscalerState, + testTime, + ), + ); + }); + + it('should use lastScalingCompleteTimestamp when specified', () => { + autoscalerState.lastScalingTimestamp = 0; + + assert.isTrue( + withinCooldownPeriod( + clusterParams, + clusterParams.currentSize - 100, + autoscalerState, + lastScalingTime, + ), + ); + }); + + it('should use lastScalingTimestamp if complete not specified', () => { + autoscalerState.lastScalingCompleteTimestamp = 0; + + assert.isTrue( + withinCooldownPeriod( + clusterParams, + clusterParams.currentSize - 100, + autoscalerState, + lastScalingTime, + ), + ); + }); +}); + +describe('#readStateCheckOngoingLRO', () => { + const app = rewire('../index.js'); + const readStateCheckOngoingLRO = app.__get__('readStateCheckOngoingLRO'); + + /** @type {StateData} */ + let autoscalerState; + /** @type {StateData} */ + let originalAutoscalerState; + /** @type {AutoscalerMemorystoreCluster} */ + let clusterParams; + /** @type {sinon.SinonStubbedInstance} */ + let stateStub; + /** @type {*} */ + let operation; + + const getOperation = sinon.stub(); + const fakeRedisAPI = { + projects: { + locations: { + operations: { + get: getOperation, + }, + }, + }, + }; + const countersStub = { + incScalingSuccessCounter: sinon.stub(), + incScalingFailedCounter: sinon.stub(), + incScalingDeniedCounter: sinon.stub(), + recordScalingDuration: sinon.stub(), + }; + app.__set__('redisApi', fakeRedisAPI); + + const lastScalingDate = new Date('2024-01-01T12:00:00Z'); + + beforeEach(() => { + clusterParams = createClusterParameters(); + stateStub = createStubState(); + app.__set__('Counters', countersStub); + + // A State with an ongoing operation + autoscalerState = { + lastScalingCompleteTimestamp: 0, + scalingOperationId: 'OperationId', + lastScalingTimestamp: lastScalingDate.getTime(), + scalingRequestedSize: 10, + createdOn: 0, + updatedOn: 0, + scalingPreviousSize: 5, + scalingMethod: 'STEPWISE', + }; + originalAutoscalerState = {...autoscalerState}; + + operation = { + done: null, + error: null, + metadata: { + '@type': + 'type.googleapis.com/google.cloud.redis.cluster.v1.OperationMetadata', + 'endTime': null, + 'createTime': lastScalingDate.toISOString(), + }, + }; + }); + + afterEach(() => { + getOperation.reset(); + Object.values(countersStub).forEach((stub) => stub.reset()); + }); + + it('should no-op when no LRO ID in state', async () => { + autoscalerState.scalingOperationId = null; + autoscalerState.scalingRequestedSize = null; + autoscalerState.scalingPreviousSize = null; + autoscalerState.scalingMethod = null; + + stateStub.get.resolves(autoscalerState); + const expectedState = { + ...originalAutoscalerState, + scalingOperationId: null, + scalingRequestedSize: null, + scalingPreviousSize: null, + scalingMethod: null, + }; + assert.equals( + await readStateCheckOngoingLRO(clusterParams, stateStub), + expectedState, + ); + sinon.assert.notCalled(getOperation); + sinon.assert.notCalled(stateStub.updateState); + sinon.assert.notCalled(countersStub.incScalingSuccessCounter); + sinon.assert.notCalled(countersStub.incScalingFailedCounter); + sinon.assert.notCalled(countersStub.recordScalingDuration); + }); + + it('should clear the operation if operation.get fails', async () => { + stateStub.get.resolves(autoscalerState); + getOperation.rejects(new Error('operation.get() error')); + + const expectedState = { + ...originalAutoscalerState, + scalingOperationId: null, + scalingRequestedSize: null, + scalingPreviousSize: null, + scalingMethod: null, + lastScalingCompleteTimestamp: + originalAutoscalerState.lastScalingTimestamp, + }; + assert.equals( + await readStateCheckOngoingLRO(clusterParams, stateStub), + expectedState, + ); + + sinon.assert.calledOnce(getOperation); + sinon.assert.calledWith(stateStub.updateState, expectedState); + sinon.assert.calledOnce(countersStub.incScalingSuccessCounter); + sinon.assert.notCalled(countersStub.incScalingFailedCounter); + sinon.assert.calledOnce(countersStub.recordScalingDuration); + }); + + it('should clear the operation if operation.get returns null', async () => { + stateStub.get.resolves(autoscalerState); + getOperation.resolves({data: null}); + + const expectedState = { + ...originalAutoscalerState, + scalingOperationId: null, + scalingRequestedSize: null, + scalingPreviousSize: null, + scalingMethod: null, + lastScalingCompleteTimestamp: + originalAutoscalerState.lastScalingTimestamp, + }; + assert.equals( + await readStateCheckOngoingLRO(clusterParams, stateStub), + expectedState, + ); + + sinon.assert.calledOnce(getOperation); + sinon.assert.calledWith(stateStub.updateState, expectedState); + sinon.assert.calledOnce(countersStub.incScalingSuccessCounter); + sinon.assert.notCalled(countersStub.incScalingFailedCounter); + sinon.assert.calledOnce(countersStub.recordScalingDuration); + }); + + it('should clear lastScaling, requestedSize if op failed with error', async () => { + stateStub.get.resolves(autoscalerState); + operation.done = true; + operation.error = {message: 'Scaling op failed'}; + operation.metadata.endTime = // 60 seconds after start + new Date(lastScalingDate.getTime() + 60_000).toISOString(); + getOperation.resolves({data: operation}); + + const expectedState = { + ...originalAutoscalerState, + scalingOperationId: null, + scalingRequestedSize: null, + scalingPreviousSize: null, + scalingMethod: null, + lastScalingCompleteTimestamp: 0, + lastScalingTimestamp: 0, + }; + assert.equals( + await readStateCheckOngoingLRO(clusterParams, stateStub), + expectedState, + ); + + sinon.assert.calledOnce(getOperation); + sinon.assert.calledWith(stateStub.updateState, expectedState); + sinon.assert.notCalled(countersStub.incScalingSuccessCounter); + sinon.assert.calledOnce(countersStub.incScalingFailedCounter); + sinon.assert.notCalled(countersStub.recordScalingDuration); + }); + + it('should clear the operation if no metadata', async () => { + stateStub.get.resolves(autoscalerState); + operation.metadata = null; + getOperation.resolves({data: operation}); + + const expectedState = { + ...originalAutoscalerState, + scalingOperationId: null, + scalingRequestedSize: null, + scalingPreviousSize: null, + scalingMethod: null, + lastScalingCompleteTimestamp: lastScalingDate.getTime(), + }; + assert.equals( + await readStateCheckOngoingLRO(clusterParams, stateStub), + expectedState, + ); + + sinon.assert.calledOnce(getOperation); + sinon.assert.calledWith(stateStub.updateState, expectedState); + sinon.assert.calledOnce(countersStub.incScalingSuccessCounter); + sinon.assert.notCalled(countersStub.incScalingFailedCounter); + sinon.assert.calledOnce(countersStub.recordScalingDuration); + }); + + it('should leave state unchanged if op not done yet', async () => { + stateStub.get.resolves(autoscalerState); + operation.done = false; + getOperation.resolves({data: operation}); + + assert.equals( + await readStateCheckOngoingLRO(clusterParams, stateStub), + originalAutoscalerState, + ); + + sinon.assert.calledOnce(getOperation); + sinon.assert.calledWith(stateStub.updateState, originalAutoscalerState); + sinon.assert.notCalled(countersStub.incScalingSuccessCounter); + sinon.assert.notCalled(countersStub.incScalingFailedCounter); + sinon.assert.notCalled(countersStub.recordScalingDuration); + }); + + it('should update timestamp and clear ID when completed', async () => { + // Ensure that savedState scaling params are different from cluster params. + stateStub.get.resolves({ + ...autoscalerState, + scalingRequestedSize: 20, + scalingPreviousSize: 10, + scalingMethod: 'DIRECT', + }); + // 60 seconds after start + const endTime = lastScalingDate.getTime() + 60_000; + operation.done = true; + operation.metadata.endTime = new Date(endTime).toISOString(); + getOperation.resolves({data: operation}); + + const expectedState = { + ...originalAutoscalerState, + scalingOperationId: null, + scalingRequestedSize: null, + scalingPreviousSize: null, + scalingMethod: null, + lastScalingCompleteTimestamp: endTime, + }; + assert.equals( + await readStateCheckOngoingLRO(clusterParams, stateStub), + expectedState, + ); + + sinon.assert.calledOnce(getOperation); + sinon.assert.calledWith(stateStub.updateState, expectedState); + sinon.assert.calledOnceWithExactly( + countersStub.incScalingSuccessCounter, + sinon.match.any, // cluster + 20, // requested + 10, // original + 'DIRECT', // method + ); + sinon.assert.notCalled(countersStub.incScalingFailedCounter); + sinon.assert.calledOnceWithExactly( + countersStub.recordScalingDuration, + 60_000, + sinon.match.any, // cluster + 20, // requestedSize + 10, // originalSize + 'DIRECT', // method + ); + }); +}); diff --git a/src/scaler/scaler-core/test/samples/custom-scaling-rules.json b/src/scaler/scaler-core/test/samples/custom-scaling-rules.json new file mode 100644 index 0000000..ed1c201 --- /dev/null +++ b/src/scaler/scaler-core/test/samples/custom-scaling-rules.json @@ -0,0 +1,44 @@ +{ + "scalingRules": [ + { + "name": "custom_max_memory_rule", + "conditions": { + "all": [ + { + "fact": "memory_maximum_utilization", + "operator": "lessThan", + "value": 70 + } + ] + }, + "event": { + "type": "IN", + "params": { + "message": "low maximum memory utilization", + "scalingMetrics": ["memory_maximum_utilization"] + } + }, + "priority": 1 + }, + { + "name": "custom_average_memory_rule", + "conditions": { + "all": [ + { + "fact": "memory_average_utilization", + "operator": "lessThan", + "value": 60 + } + ] + }, + "event": { + "type": "IN", + "params": { + "message": "low average memory utilization", + "scalingMetrics": ["memory_average_utilization"] + } + }, + "priority": 1 + } + ] +} diff --git a/src/scaler/scaler-core/test/samples/downstream-msg.json b/src/scaler/scaler-core/test/samples/downstream-msg.json new file mode 100644 index 0000000..2117fc3 --- /dev/null +++ b/src/scaler/scaler-core/test/samples/downstream-msg.json @@ -0,0 +1,34 @@ +{ + "projectId": "project1", + "regionId": "region1", + "instanceId": "cluster1", + "currentSize": 100, + "suggestedSize": 300, + "units": "SHARDS", + "metrics": [ + { + "name": "cpu_maximum_utilization", + "scaleInThreshold": 50, + "scaleOutThreshold": 70, + "value": 0.19835128894461815 + }, + { + "name": "cpu_average_utilization", + "scaleInThreshold": 50, + "scaleOutThreshold": 70, + "value": 0.18477335171747497 + }, + { + "name": "memory_maximum_utilization", + "scaleInThreshold": 50, + "scaleOutThreshold": 70, + "value": 0.0186809696873731 + }, + { + "name": "memory_average_utilization", + "scaleInThreshold": 50, + "scaleOutThreshold": 70, + "value": 0.018497523020197155 + } + ] +} diff --git a/src/scaler/scaler-core/test/samples/parameters.json b/src/scaler/scaler-core/test/samples/parameters.json new file mode 100644 index 0000000..1572751 --- /dev/null +++ b/src/scaler/scaler-core/test/samples/parameters.json @@ -0,0 +1,43 @@ +{ + "projectId": "project1", + "regionId": "region1", + "clusterId": "cluster1", + "units": "SHARDS", + "minSize": 5, + "maxSize": 10, + "stepSize": 1, + "shardCount": 6, + "sizeGb": 78, + "scalingProfile": "CPU_AND_MEMORY", + "scalingMethod": "STEPWISE", + "minFreeMemoryPercent": 30, + "scaleOutCoolingMinutes": 5, + "scaleInCoolingMinutes": 30, + "metrics": [ + { + "name": "cpu_maximum_utilization", + "value": 0 + }, + { + "name": "cpu_average_utilization", + "value": 0 + }, + { + "name": "memory_maximum_utilization", + "value": 0 + }, + { + "name": "memory_average_utilization", + "value": 0 + }, + { + "name": "maximum_evicted_keys", + "value": 0 + }, + { + "name": "average_evicted_keys", + "value": 0 + } + ], + "currentSize": 5 +} diff --git a/src/scaler/scaler-core/test/scaling-methods/base.test.js b/src/scaler/scaler-core/test/scaling-methods/base.test.js new file mode 100644 index 0000000..3136fa5 --- /dev/null +++ b/src/scaler/scaler-core/test/scaling-methods/base.test.js @@ -0,0 +1,471 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * ESLINT: Ignore max line length errors on lines starting with 'it(' + * (test descriptions) + */ +/* eslint max-len: ["error", { "ignorePattern": "^\\s*it\\(" }] */ +const rewire = require('rewire'); + +const app = rewire('../../scaling-methods/base.js'); +const {createClusterParameters, metricsOverlay} = require('../test-utils.js'); +const {AutoscalerDirection} = require('../../../../autoscaler-common/types'); +const { + ruleSet: defaultRuleSet, +} = require('../../scaling-profiles/profiles/cpu_and_memory.js'); + +/** + * @typedef { import('../../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster } AutoscalerMemorystoreCluster + */ + +const getScaleSuggestionMessage = app.__get__('getScaleSuggestionMessage'); +describe('#getScaleSuggestionMessage', () => { + it('should suggest no change when metric value within range', () => { + getScaleSuggestionMessage({}, 999, 'NONE').should.containEql('no change'); + }); + + it('should not suggest scaling when shards suggestion is equal to current', () => { + const msg = getScaleSuggestionMessage( + { + units: 'SHARDS', + currentSize: 3, + minSize: 2, + maxSize: 8, + }, + 3, + '', + ); + msg.should.containEql('size is equal to the current size'); + msg.should.containEql('SHARDS'); + }); + + it('should suggest scaling when shards suggestion is not equal to current', () => { + const msg = getScaleSuggestionMessage( + { + units: 'SHARDS', + currentSize: 3, + minSize: 2, + maxSize: 8, + }, + 5, + '', + ); + msg.should.containEql('suggesting to scale'); + msg.should.containEql('SHARDS'); + }); + + it('should indicate scaling is not possible if shards suggestion is above max', () => { + const msg = getScaleSuggestionMessage( + { + units: 'SHARDS', + currentSize: 3, + minSize: 2, + maxSize: 8, + }, + 9, + '', + ); + msg.should.containEql('higher than MAX'); + msg.should.containEql('SHARDS'); + }); + + it('should indicate scaling is not possible if shards suggestion is below min', () => { + const msg = getScaleSuggestionMessage( + { + units: 'SHARDS', + currentSize: 3, + minSize: 2, + maxSize: 8, + }, + 1, + '', + ); + msg.should.containEql('lower than MIN'); + msg.should.containEql('SHARDS'); + }); +}); + +describe('#getScalingDirection', () => { + const getEngineAnalysis = app.__get__('getEngineAnalysis'); + const getScalingDirection = app.__get__('getScalingDirection'); + + it('should suggest scale OUT given high average CPU utilization', async () => { + const metrics = [ + { + name: 'cpu_average_utilization', + value: 100, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.equal('OUT'); + }); + + it('should suggest scale OUT given high maximum and average CPU utilization', async () => { + const metrics = [ + { + name: 'cpu_maximum_utilization', + value: 100, + }, + { + name: 'cpu_average_utilization', + value: 100, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.equal('OUT'); + }); + + it('should suggest scale IN given low average CPU utilization', async () => { + const metrics = [ + { + name: 'cpu_average_utilization', + value: 10, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.equal('IN'); + }); + + it('should suggest scale IN given low maximum and average CPU utilization', async () => { + const metrics = [ + { + name: 'cpu_maximum_utilization', + value: 10, + }, + { + name: 'cpu_average_utilization', + value: 10, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.equal('IN'); + }); + + it('should suggest scale OUT given high average memory utilization', async () => { + const metrics = [ + { + name: 'memory_average_utilization', + value: 100, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.equal('OUT'); + }); + + it('should suggest scale OUT given high maximum and average memory utilization', async () => { + const metrics = [ + { + name: 'memory_maximum_utilization', + value: 100, + }, + { + name: 'memory_average_utilization', + value: 100, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.equal('OUT'); + }); + + it('should suggest scale IN given low average memory utilization', async () => { + const metrics = [ + { + name: 'memory_average_utilization', + value: 10, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.equal('IN'); + }); + + it('should suggest scale IN given low maximum and average memory utilization', async () => { + const metrics = [ + { + name: 'memory_maximum_utilization', + value: 10, + }, + { + name: 'memory_average_utilization', + value: 10, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.equal('IN'); + }); + + it('should not suggest scale IN due to low CPU if keys are being evicted', async () => { + const metrics = [ + { + name: 'cpu_maximum_utilization', + value: 10, + }, + { + name: 'cpu_average_utilization', + value: 10, + }, + { + name: 'average_evicted_keys', + value: 100, + }, + { + name: 'maximum_evicted_keys', + value: 100, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.not.equal('IN'); + }); + + it('should not suggest scale IN due to low memory utilization if keys are being evicted', async () => { + const metrics = [ + { + name: 'memory_maximum_utilization', + value: 10, + }, + { + name: 'memory_average_utilization', + value: 10, + }, + { + name: 'average_evicted_keys', + value: 100, + }, + { + name: 'maximum_evicted_keys', + value: 100, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.not.equal('IN'); + }); + + it('should suggest scale OUT if a metric indicates scale OUT while others indicate scale IN', async () => { + const metrics = [ + { + name: 'cpu_maximum_utilization', + value: 10, + }, + { + name: 'cpu_average_utilization', + value: 100, + }, + { + name: 'memory_maximum_utilization', + value: 10, + }, + { + name: 'memory_average_utilization', + value: 10, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + + const engineAnalysis = await getEngineAnalysis(cluster, defaultRuleSet); + const direction = await getScalingDirection(engineAnalysis); + + direction.should.equal('OUT'); + }); +}); + +const getMaxMemoryUtilization = app.__get__('getMaxMemoryUtilization'); +describe('#getMaxMemoryUtilization', () => { + it('should return the maximum memory utilization', () => { + const metrics = [ + { + name: 'dummy_metric_1', + value: 10, + }, + { + name: 'memory_maximum_utilization', + value: 20, + }, + { + name: 'dummy_metric_2', + value: 30, + }, + ]; + const cluster = createClusterParameters(); + cluster.metrics = metricsOverlay(cluster, metrics); + const maxUtilization = getMaxMemoryUtilization(cluster); + maxUtilization.should.equal(20); + }); +}); + +const ensureMinFreeMemory = app.__get__('ensureMinFreeMemory'); +describe('#ensureMinFreeMemory', () => { + it('should prevent scale-in to a size below the safe scaling size', () => { + const metrics = [ + { + name: 'memory_maximum_utilization', + value: 60, + }, + ]; + const cluster = createClusterParameters({ + shardCount: 10, + minFreeMemoryPercent: 20, + }); + cluster.metrics = metricsOverlay(cluster, metrics); + const safeSize = ensureMinFreeMemory(cluster, 4, AutoscalerDirection.IN); + safeSize.should.equal(8); + }); + + it('should do nothing for a safe suggested scale-in size', () => { + const metrics = [ + { + name: 'memory_maximum_utilization', + value: 20, + }, + ]; + const cluster = createClusterParameters({ + shardCount: 10, + minFreeMemoryPercent: 20, + }); + cluster.metrics = metricsOverlay(cluster, metrics); + const safeSize = ensureMinFreeMemory(cluster, 9, AutoscalerDirection.IN); + safeSize.should.equal(9); + }); + + it('should invert a scale-in to a scale-out if required', () => { + const metrics = [ + { + name: 'memory_maximum_utilization', + value: 85, + }, + ]; + const cluster = createClusterParameters({ + shardCount: 10, + minFreeMemoryPercent: 30, + }); + cluster.metrics = metricsOverlay(cluster, metrics); + const safeSize = ensureMinFreeMemory(cluster, 4, AutoscalerDirection.IN); + safeSize.should.equal(13); + }); +}); + +const ensureValidClusterSize = app.__get__('ensureValidClusterSize'); +describe('#ensureValidClusterSize', () => { + it('should clamp cluster size at configured maximum', () => { + const cluster = createClusterParameters({minSize: 10, maxSize: 20}); + const clampedSize = ensureValidClusterSize( + cluster, + 21, + AutoscalerDirection.OUT, + ); + clampedSize.should.equal(20); + }); + + it('should clamp cluster size at configured minimum', () => { + const cluster = createClusterParameters({minSize: 10, maxSize: 20}); + const clampedSize = ensureValidClusterSize( + cluster, + 9, + AutoscalerDirection.IN, + ); + clampedSize.should.equal(10); + }); + + it('should not clamp when cluster size is within configured range', () => { + const cluster = createClusterParameters({minSize: 10, maxSize: 20}); + const clampedSize = ensureValidClusterSize( + cluster, + 15, + AutoscalerDirection.OUT, + ); + clampedSize.should.equal(15); + }); + + it('should prevent invalid cluster size scaling out', () => { + const cluster = createClusterParameters({minSize: 3, maxSize: 10}); + const clampedSize = ensureValidClusterSize( + cluster, + 4, + AutoscalerDirection.OUT, + ); + clampedSize.should.equal(5); + }); + + it('should prevent invalid cluster size scaling in', () => { + const cluster = createClusterParameters({minSize: 3, maxSize: 10}); + const clampedSize = ensureValidClusterSize( + cluster, + 4, + AutoscalerDirection.IN, + ); + clampedSize.should.equal(5); + }); + + it('should prevent scale in below minimum supported cluster size', () => { + const cluster = createClusterParameters({minSize: 3, maxSize: 10}); + const clampedSize = ensureValidClusterSize( + cluster, + 2, + AutoscalerDirection.IN, + ); + clampedSize.should.equal(3); + }); +}); diff --git a/src/scaler/scaler-core/test/scaling-methods/direct.test.js b/src/scaler/scaler-core/test/scaling-methods/direct.test.js new file mode 100644 index 0000000..6e77c29 --- /dev/null +++ b/src/scaler/scaler-core/test/scaling-methods/direct.test.js @@ -0,0 +1,89 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * ESLINT: Ignore max line length errors on lines starting with 'it(' + * (test descriptions) + */ +/* eslint max-len: ["error", { "ignorePattern": "^\\s*it\\(" }] */ + +const rewire = require('rewire'); +const sinon = require('sinon'); +// @ts-ignore +const referee = require('@sinonjs/referee'); +// @ts-ignore +const assert = referee.assert; +const {createClusterParameters} = require('../test-utils.js'); + +const app = rewire('../../scaling-methods/direct.js'); + +/** + * @typedef {import('../../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + */ + +afterEach(() => { + // Restore the default sandbox here + sinon.restore(); +}); + +const calculateSize = app.__get__('calculateSize'); +describe('#direct.calculateSize', () => { + /** @type {sinon.SinonSpy} */ + let calculateScalingDecisionSpy; + beforeEach(() => { + const baseModule = app.__get__('baseModule'); + calculateScalingDecisionSpy = sinon.spy( + baseModule, + 'calculateScalingDecision', + ); + }); + + it('should return max size', async () => { + const cluster = createClusterParameters({ + currentSize: 5, + maxSize: 10, + minSize: 1, + scalingMethod: 'DIRECT', + }); + const size = await calculateSize(cluster); + assert.equals(size, 10); + assert.equals(calculateScalingDecisionSpy.callCount, 1); + }); + + it('should return 5 when 4 is suggested', async () => { + const cluster = createClusterParameters({ + currentSize: 3, + maxSize: 4, + minSize: 1, + scalingMethod: 'DIRECT', + }); + const size = await calculateSize(cluster); + assert.equals(size, 5); + assert.equals(calculateScalingDecisionSpy.callCount, 1); + }); + + it('should return 3 when below 3 is suggested', async () => { + const cluster = createClusterParameters({ + currentSize: 5, + maxSize: 2, + minSize: 1, + scalingMethod: 'DIRECT', + }); + const size = await calculateSize(cluster); + assert.equals(size, 3); + assert.equals(calculateScalingDecisionSpy.callCount, 1); + }); +}); diff --git a/src/scaler/scaler-core/test/scaling-methods/linear.test.js b/src/scaler/scaler-core/test/scaling-methods/linear.test.js new file mode 100644 index 0000000..5acbbaf --- /dev/null +++ b/src/scaler/scaler-core/test/scaling-methods/linear.test.js @@ -0,0 +1,1302 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +const assert = require('assert'); +const linear = require('../../scaling-methods/linear.js'); + +/** + * @typedef {import('../../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + * @typedef {import('../../scaling-profiles/profiles/cpu_and_memory') + * .RuleSet} RuleSet + */ + +/** + * Creates a cluster representation. + * @param {*} overrideParameters + * @return {AutoscalerMemorystoreCluster} Cluster representation. + */ +const createClusterParameters = (overrideParameters) => { + return { + 'projectId': 'project1', + 'regionId': 'region1', + 'clusterId': 'cluster1', + 'units': 'SHARDS', + 'minSize': 3, + 'maxSize': 100, + 'stepSize': 1, + 'scalingProfile': 'CPU_AND_MEMORY', + 'scalingMethod': 'LINEAR', + 'minFreeMemoryPercent': 30, + 'scaleOutCoolingMinutes': 5, + 'scaleInCoolingMinutes': 30, + 'metrics': [ + { + 'name': 'cpu_maximum_utilization', + 'value': 0, + }, + { + 'name': 'cpu_average_utilization', + 'value': 0, + }, + { + 'name': 'memory_maximum_utilization', + 'value': 0, + }, + { + 'name': 'memory_average_utilization', + 'value': 0, + }, + { + 'name': 'maximum_evicted_keys', + 'value': 0, + }, + { + 'name': 'average_evicted_keys', + 'value': 0, + }, + ], + 'currentSize': 5, + ...overrideParameters, + }; +}; + +describe('#linear', () => { + describe('calculateSize', () => { + [ + ['above scaling metric by 200%', 50, 100, 10, 20], + ['above scaling metric by 150%', 50, 75, 10, 15], + // Scale OUT type won't scale in. + ['below scaling metric by 70%', 50, 35, 10, 10], + ['below scaling metric by 50%', 50, 25, 10, 10], + ].forEach( + ([ + testName, + thresholdMetric, + actualMetric, + currentSize, + expectedSize, + ]) => { + it(`scales OUT proportionally when ${testName}`, async () => { + const cluster = createClusterParameters({ + currentSize: currentSize, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': actualMetric, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: thresholdMetric, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'sample scaling out', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + assert.equal(suggestedSize, expectedSize); + }); + }, + ); + + [ + ['below scaling metric by 70%', 50, 35, 10, 7], + ['below scaling metric by 50%', 50, 25, 10, 5], + // Scale IN type won't scale out. + ['above scaling metric by 200%', 50, 100, 10, 10], + ['above scaling metric by 150%', 50, 75, 10, 10], + ].forEach( + ([ + testName, + thresholdMetric, + actualMetric, + currentSize, + expectedSize, + ]) => { + it(`scales IN proportionally when ${testName}`, async () => { + const cluster = createClusterParameters({ + currentSize: currentSize, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': actualMetric, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: thresholdMetric, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'sample scaling out', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + assert.equal(suggestedSize, expectedSize); + }); + }, + ); + + it(`scales OUT, but only up to maxSize`, async () => { + const cluster = createClusterParameters({ + currentSize: 10, + maxSize: 15, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 100, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 50, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'sample scaling out', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 20, but it's capped at 15, so it scales to 15. + assert.equal(suggestedSize, 15); + }); + + it(`scales IN, but only up to minSize`, async () => { + const cluster = createClusterParameters({ + currentSize: 10, + minSize: 7, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 25, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 50, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'sample scaling in', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 5, but it's capped at 7, so it scales to 7. + assert.equal(suggestedSize, 7); + }); + + it(`scales IN directly to minSize when metric is 0`, async () => { + const cluster = createClusterParameters({ + currentSize: 100, + minSize: 5, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 0, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 50, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'sample scaling in', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + assert.equal(suggestedSize, 5); + }); + + it('scales OUT, respecting scaleOutLimit', async () => { + const cluster = createClusterParameters({ + currentSize: 10, + scaleOutLimit: 2, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 100, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 50, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'sample scaling out', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 20, but it's capped at +2 by scaleOutLimit, so it + // scales to 12. + assert.equal(suggestedSize, 12); + }); + + it(`scales IN, respecting scaleInLimit`, async () => { + const cluster = createClusterParameters({ + currentSize: 10, + scaleInLimit: 1, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 25, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 50, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'sample scaling in', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 5, but it's capped at -1 by scaleInLimit, so it + // scales to 9. + assert.equal(suggestedSize, 9); + }); + + it('scaleOutLimit=0 allows scaling out to the suggested size', async () => { + const cluster = createClusterParameters({ + currentSize: 10, + scaleOutLimit: 0, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 100, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 50, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'sample scaling out', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // scaleOutLimit=0 does not prevent scaling out to suggestedSize. + assert.equal(suggestedSize, 20); + }); + + it(`scaleInLimit=0 allows scaling in to the suggested size`, async () => { + const cluster = createClusterParameters({ + currentSize: 10, + scaleInLimit: 0, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 25, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 50, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'sample scaling in', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // scaleInLimit=0 does not prevent scaling in to suggestedSize. + assert.equal(suggestedSize, 5); + }); + + it('scales OUT, but avoids invalid config of 4', async () => { + const cluster = createClusterParameters({ + currentSize: 3, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 40, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 30, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'sample scaling out', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 4, but it's invalid, so it scales to 5. + assert.equal(suggestedSize, 5); + }); + + it('scales IN, but avoids invalid config of 4', async () => { + const cluster = createClusterParameters({ + currentSize: 5, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 40, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 50, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'sample scaling in', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 4, but it's invalid, so it scales to 5. + assert.equal(suggestedSize, 5); + }); + + it('scales OUT to the largest size when multiple scale OUT metrics', async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 100, + }, + { + 'name': 'memory_maximum_utilization', + 'value': 75, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 50, + }, + { + fact: 'memory_maximum_utilization', + operator: 'greaterThan', + value: 50, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'sample scaling out with multiple metrics', + scalingMetrics: [ + 'cpu_maximum_utilization', + 'memory_maximum_utilization', + ], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 15 for memory_maximum_utilization, but it should + // scale to 20 for cpu_maximum_utilization. The largest is used. + assert.equal(suggestedSize, 20); + }); + + it('scales IN but to the largest size when multiple scale IN metrics', async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 70, + }, + { + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 100, + }, + { + fact: 'memory_maximum_utilization', + operator: 'lessThan', + value: 100, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'sample scaling in with multiple metrics', + scalingMetrics: [ + 'cpu_maximum_utilization', + 'memory_maximum_utilization', + ], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 5 for memory_maximum_utilization, but it should + // scale to 7 for cpu_maximum_utilization. The largest is used. + assert.equal(suggestedSize, 7); + }); + + it('scales OUT to the largest size when multiple scale OUT rules matched', async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 100, + }, + { + 'name': 'memory_maximum_utilization', + 'value': 75, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 50, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'scaling out, rule 1', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + memoryMaxUtilization: { + name: 'memoryMaxUtilization', + conditions: { + all: [ + { + fact: 'memory_maximum_utilization', + operator: 'greaterThan', + value: 50, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'scale out, rule 2', + scalingMetrics: ['memory_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 15 for memoryMaxUtilization rule, but it should + // scale to 20 for cpuHighMaximumUtilization rule. The largest is used. + assert.equal(suggestedSize, 20); + }); + + it('scales IN to the largest size when multiple scale IN rules are matched', async () => { + const cluster = createClusterParameters({ + currentSize: 100, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 25, + }, + { + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 100, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'scaling in, rule 1', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + memoryMaxUtilization: { + name: 'memoryMaxUtilization', + conditions: { + all: [ + { + fact: 'memory_maximum_utilization', + operator: 'lessThan', + value: 100, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'scale in, rule 2', + scalingMetrics: ['memory_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale to 25 for cpuHighMaximumUtilization rule, but it + // should scale to 50 for memoryMaxUtilization rule. The largest is + // used. + assert.equal(suggestedSize, 50); + }); + + it('scales OUT when both IN and OUT rules are matched', async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 100, + }, + { + 'name': 'memory_maximum_utilization', + 'value': 5, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 50, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'scaling out rule', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + memoryMaxUtilization: { + name: 'memoryMaxUtilization', + conditions: { + all: [ + { + fact: 'memory_maximum_utilization', + operator: 'lessThan', + value: 50, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'scale in rule', + scalingMetrics: ['memory_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // It should scale IN for memoryMaxUtilization rule, but it should + // scale OUT for cpuHighMaximumUtilization rule. Scale OUT is + // prioritized. + assert.equal(suggestedSize, 20); + }); + + it('uses largest cluster size when scaling OUT metric is used multiple times', async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 100, + }, + { + 'name': 'memory_maximum_utilization', + 'value': 5, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 20, + }, + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 50, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'scaling out rule', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + cpuMaximumUtilization: { + name: 'cpuMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 10, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'another scaling out rule', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // There are 3 threhsolds for cpu_maximum_utilization: 10, 20 and 50. + // This would result in 100, 50 and 20 suggested sizes respectively. + // Therefore it scales to 100, the largest. + assert.equal(suggestedSize, 100); + }); + + it('uses largest cluster size when scaling IN metric is used multiple times', async () => { + const cluster = createClusterParameters({ + currentSize: 100, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 20, + }, + { + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 80, + }, + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 50, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'scaling out rule', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + cpuMaximumUtilization: { + name: 'cpuMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'lessThan', + value: 40, + }, + ], + }, + event: { + type: 'IN', + params: { + message: 'another scaling in rule', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + // There are 3 threhsolds for cpu_maximum_utilization: 80, 50 and 40. + // This would result in 25, 40 and 50 suggested sizes respectively. + // Therefore, it scales to 50, the largest. + assert.equal(suggestedSize, 50); + }); + + [ + { + testName: 'scaling IN rule', + direction: 'IN', + operator: 'lessThan', + threshold: 50, + actualMetric: 100, + }, + { + testName: 'scaling OUT rule', + direction: 'OUT', + operator: 'greaterThan', + threshold: 50, + actualMetric: 25, + }, + ].forEach(({testName, direction, operator, threshold, actualMetric}) => { + it(`returns current size when no rules are triggered by ${testName}`, async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': actualMetric, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: operator, + value: threshold, + }, + ], + }, + event: { + type: direction, + params: { + message: 'sample rule', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + assert.equal(suggestedSize, 10); + }); + }); + + [ + { + testName: 'scaling IN rule', + direction: 'IN', + operator: 'lessThan', + threshold: 100, + actualMetric: 50, + }, + { + testName: 'scaling OUT rule', + direction: 'OUT', + operator: 'greaterThan', + threshold: 50, + actualMetric: 100, + }, + ].forEach(({testName, direction, operator, threshold, actualMetric}) => { + it(`returns current size when scaling metric doesn't exist on ${testName}`, async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': actualMetric, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: operator, + value: threshold, + }, + ], + }, + event: { + type: direction, + params: { + message: 'sample rule', + scalingMetrics: ['non_existing_metric'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + assert.equal(suggestedSize, 10); + }); + }); + + [ + { + testName: 'scaling IN rule', + direction: 'IN', + operator: 'lessThan', + threshold: 100, + actualMetric: 50, + }, + { + testName: 'scaling OUT rule', + direction: 'OUT', + operator: 'greaterThan', + threshold: 50, + actualMetric: 100, + }, + ].forEach(({testName, direction, operator, threshold, actualMetric}) => { + it( + 'returns current size when scaling metric does not have value on ' + + testName, + async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': actualMetric, + }, + { + 'name': 'metric_used_for_scaling', + // Indicating a lack of value without triggering type alerts. + 'value': undefined, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: operator, + value: threshold, + }, + ], + }, + event: { + type: direction, + params: { + message: 'sample rule', + scalingMetrics: ['metric_used_for_scaling'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + assert.equal(suggestedSize, 10); + }, + ); + }); + + [ + { + testName: 'scaling IN rule', + direction: 'IN', + operator: 'lessThan', + threshold: 100, + actualMetric: 50, + }, + { + testName: 'scaling OUT rule', + direction: 'OUT', + operator: 'greaterThan', + threshold: 50, + actualMetric: 100, + }, + ].forEach(({testName, direction, operator, threshold, actualMetric}) => { + it(`ignores the rule when there is no scaling metrics on ${testName}`, async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': actualMetric, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: operator, + value: threshold, + }, + ], + }, + event: { + type: direction, + params: { + message: 'sample rule', + scalingMetrics: [], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + assert.equal(suggestedSize, 10); + }); + }); + + it(`ignores the rule when threshold is 0`, async () => { + const cluster = createClusterParameters({ + currentSize: 10, + metrics: [ + { + 'name': 'cpu_maximum_utilization', + 'value': 100, + }, + { + // Not relevant for the test, but required by base. + 'name': 'memory_maximum_utilization', + 'value': 50, + }, + ], + }); + const /** @type {!RuleSet} */ ruleSet = { + cpuHighMaximumUtilization: { + name: 'cpuHighMaximumUtilization', + conditions: { + all: [ + { + fact: 'cpu_maximum_utilization', + operator: 'greaterThan', + value: 0, + }, + ], + }, + event: { + type: 'OUT', + params: { + message: 'sample scaling out', + scalingMetrics: ['cpu_maximum_utilization'], + }, + }, + }, + }; + + const suggestedSize = await linear.calculateSize(cluster, ruleSet); + + assert.equal(suggestedSize, 10); + }); + }); +}); diff --git a/src/scaler/scaler-core/test/scaling-methods/stepwise.test.js b/src/scaler/scaler-core/test/scaling-methods/stepwise.test.js new file mode 100644 index 0000000..1d2f17a --- /dev/null +++ b/src/scaler/scaler-core/test/scaling-methods/stepwise.test.js @@ -0,0 +1,80 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * ESLINT: Ignore max line length errors on lines starting with 'it(' + * (test descriptions) + */ +/* eslint max-len: ["error", { "ignorePattern": "^\\s*it\\(" }] */ + +const rewire = require('rewire'); +const sinon = require('sinon'); +// @ts-ignore +const referee = require('@sinonjs/referee'); +// @ts-ignore +const assert = referee.assert; +const {createClusterParameters} = require('../test-utils.js'); +const {AutoscalerDirection} = require('../../../../autoscaler-common/types'); +const app = rewire('../../scaling-methods/stepwise.js'); + +/** + * @typedef {import('../../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + */ + +afterEach(() => { + // Restore the default sandbox here + sinon.restore(); +}); + +/** + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {AutoscalerDirection} direction + * @return {sinon.SinonStub} base module + */ +function stubBaseModule(cluster, direction) { + const callbackStub = sinon.stub().callsArgWith(2, cluster, direction); + app.__set__('baseModule.calculateScalingDecision', callbackStub); + app.__set__('baseModule.getScalingDirection', () => direction); + return callbackStub; +} + +const calculateSize = app.__get__('calculateSize'); +describe('#stepwise.calculateSize', () => { + it('should return current size if no scaling is needed', async () => { + const cluster = createClusterParameters({currentSize: 10, stepSize: 2}); + const callbackStub = stubBaseModule(cluster, AutoscalerDirection.NONE); + const size = await calculateSize(cluster, null); + size.should.equal(10); + assert.equals(callbackStub.callCount, 1); + }); + + it('should return current size increased by stepSize if scale OUT is suggested', async () => { + const cluster = createClusterParameters({currentSize: 6, stepSize: 1}); + const callbackStub = stubBaseModule(cluster, AutoscalerDirection.OUT); + const size = await calculateSize(cluster, null); + size.should.equal(7); + assert.equals(callbackStub.callCount, 1); + }); + + it('should return current size decreased by stepSize if scale IN is suggested', async () => { + const cluster = createClusterParameters({currentSize: 6, stepSize: 1}); + const callbackStub = stubBaseModule(cluster, AutoscalerDirection.IN); + const size = await calculateSize(cluster, null); + size.should.equal(5); + assert.equals(callbackStub.callCount, 1); + }); +}); diff --git a/src/scaler/scaler-core/test/state.test.js b/src/scaler/scaler-core/test/state.test.js new file mode 100644 index 0000000..0bacbbe --- /dev/null +++ b/src/scaler/scaler-core/test/state.test.js @@ -0,0 +1,579 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +const firestore = require('@google-cloud/firestore'); +const spanner = require('@google-cloud/spanner'); + +const rewire = require('rewire'); +// eslint-disable-next-line no-unused-vars +const should = require('should'); +const sinon = require('sinon'); +// @ts-ignore +const referee = require('@sinonjs/referee'); +// @ts-ignore +const assert = referee.assert; + +/** + * @typedef {import('../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + */ + +// Create a dummy Firestore module with a dummy class constructor +// that returns a stub instance. +const stubFirestoreConstructor = sinon.stub(); +/** Dummy class to return the Firestore stub */ +class DummyFirestoreClass { + /** @param {AutoscalerMemorystoreCluster} arg */ + constructor(arg) { + return stubFirestoreConstructor(arg); + } +} +const dummyFirestoreModule = { + Firestore: DummyFirestoreClass, + Timestamp: firestore.Timestamp, + FieldValue: firestore.FieldValue, +}; + +const stubSpannerConstructor = sinon.stub(); +/** Dummy class to return the Spanner stub */ +class DummySpannerClass { + /** @param {AutoscalerMemorystoreCluster} arg */ + constructor(arg) { + return stubSpannerConstructor(arg); + } + // @ts-ignore + // eslint-disable-next-line require-jsdoc + static timestamp(arg) { + return spanner.Spanner.timestamp(arg); + } +} +const dummySpannerModule = { + Spanner: DummySpannerClass, +}; + +// import module to define State type for typechecking... +let State = require('../state'); +const {AutoscalerUnits} = require('../../../autoscaler-common/types'); +// override module with rewired module +// @ts-ignore +State = rewire('../state.js'); + +// @ts-expect-error +State.__set__('firestore', dummyFirestoreModule); +// @ts-expect-error +State.__set__('spanner', dummySpannerModule); +// @ts-expect-error +const StateFirestore = State.__get__('StateFirestore'); +// @ts-expect-error +const StateSpanner = State.__get__('StateSpanner'); + +afterEach(() => { + // Restore the default sandbox here + sinon.reset(); + sinon.restore(); +}); + +const DUMMY_TIMESTAMP = 1704110400000; +const DUMMY_TIMESTAMP2 = 1709660000000; + +/** @type {AutoscalerMemorystoreCluster} */ +const BASE_CONFIG = { + projectId: 'myProject', + regionId: 'myRegion', + clusterId: 'myCluster', + stateProjectId: 'stateProject', + scaleOutCoolingMinutes: 20, + scaleInCoolingMinutes: 20, + scalingProfile: 'CPU_AND_MEMORY', + scalingMethod: 'STEPWISE', + minFreeMemoryPercent: 30, + currentSize: 100, + metrics: [], + units: AutoscalerUnits.SHARDS, + minSize: 5, + maxSize: 10, + stepSize: 1, + shardCount: 5, + sizeGb: 64, +}; + +describe('stateFirestoreTests', () => { + /** @type {sinon.SinonStubbedInstance} */ + let stubFirestoreInstance; + /** @type {sinon.SinonStubbedInstance>} */ + let docRef; + + const DUMMY_FIRESTORE_TIMESTAMP = + firestore.Timestamp.fromMillis(DUMMY_TIMESTAMP); + const DUMMY_FIRESTORE_TIMESTAMP2 = + firestore.Timestamp.fromMillis(DUMMY_TIMESTAMP2); + + const DOC_PATH = + `memorystoreClusterAutoscaler/state/projects/${BASE_CONFIG.projectId}` + + `/regions/${BASE_CONFIG.regionId}/clusters/${BASE_CONFIG.clusterId}`; + + /** @type {AutoscalerMemorystoreCluster} */ + const autoscalerConfig = { + ...BASE_CONFIG, + }; + + /** @type {firestore.DocumentSnapshot} */ + // @ts-ignore + const EXISTING_DOC = { + exists: true, + data: () => { + return { + createdOn: DUMMY_FIRESTORE_TIMESTAMP, + updatedOn: DUMMY_FIRESTORE_TIMESTAMP, + lastScalingTimestamp: DUMMY_FIRESTORE_TIMESTAMP, + lastScalingCompleteTimestamp: DUMMY_FIRESTORE_TIMESTAMP2, + scalingRequestedSize: null, + scalingOperationId: null, + }; + }, + }; + + /** @type {firestore.DocumentSnapshot} */ + // @ts-ignore + const NON_EXISTING_DOC = { + exists: false, + data: () => null, + }; + + beforeEach(() => { + // stub instances need to be recreated before each test. + stubFirestoreInstance = sinon.createStubInstance(firestore.Firestore); + stubFirestoreConstructor.reset(); + stubFirestoreConstructor.returns(stubFirestoreInstance); + docRef = sinon.createStubInstance(firestore.DocumentReference); + stubFirestoreInstance.doc.withArgs(DOC_PATH).returns(docRef); + // Clear cached Firestore instances from the memoized function in + // StateFirestore: + StateFirestore.getFirestoreClient.cache.clear(); + }); + + it('should create a StateFirestore object on memorystore projectId', function () { + const config = { + ...autoscalerConfig, + }; + delete config.stateProjectId; + const state = State.buildFor(config); + assert.equals(state.constructor.name, 'StateFirestore'); + sinon.assert.calledWith(stubFirestoreConstructor, {projectId: 'myProject'}); + }); + + it('should create a StateFirestore object connecting to stateProjectId', function () { + const state = State.buildFor(autoscalerConfig); + assert.equals(state.constructor.name, 'StateFirestore'); + sinon.assert.calledWith(stubFirestoreConstructor, { + projectId: 'stateProject', + }); + }); + + it('should reuse the Firestore clients for each project', function () { + const config1 = { + ...autoscalerConfig, + stateProjectId: 'stateProject1', + }; + const config2 = { + ...autoscalerConfig, + stateProjectId: 'stateProject2', + }; + + State.buildFor(config1); + State.buildFor(config2); + State.buildFor(config1); + State.buildFor(config2); + State.buildFor(config1); + State.buildFor(config2); + + const calls = stubFirestoreConstructor.getCalls(); + assert.equals(calls.length, 2); + assert.equals(calls[0].args[0], { + projectId: 'stateProject1', + }); + assert.equals(calls[1].args[0], { + projectId: 'stateProject2', + }); + }); + + it('get() should read document from collection when exists', async function () { + docRef.get.returns(Promise.resolve(EXISTING_DOC)); + + const state = State.buildFor(autoscalerConfig); + const data = await state.get(); + + sinon.assert.calledOnce(docRef.get); + sinon.assert.calledWith(stubFirestoreInstance.doc, DOC_PATH); + + // timestamp was converted... + assert.equals(data, { + createdOn: DUMMY_TIMESTAMP, + updatedOn: DUMMY_TIMESTAMP, + lastScalingTimestamp: DUMMY_TIMESTAMP, + lastScalingCompleteTimestamp: DUMMY_TIMESTAMP2, + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + }); + }); + + it('get() should create a document when it does not exist', async function () { + docRef.get.returns(Promise.resolve(NON_EXISTING_DOC)); + const state = State.buildFor(autoscalerConfig); + // make state.now return a fixed value + const nowfunc = sinon.stub(); + sinon.replaceGetter(state, 'now', nowfunc); + nowfunc.returns(DUMMY_TIMESTAMP); + + const data = await state.get(); + + const expectedValue = { + lastScalingTimestamp: 0, + createdOn: DUMMY_TIMESTAMP, + updatedOn: DUMMY_TIMESTAMP, + lastScalingCompleteTimestamp: 0, + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + }; + + const expectedDoc = { + createdOn: DUMMY_FIRESTORE_TIMESTAMP, + updatedOn: DUMMY_FIRESTORE_TIMESTAMP, + lastScalingTimestamp: firestore.Timestamp.fromMillis(0), + lastScalingCompleteTimestamp: firestore.Timestamp.fromMillis(0), + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + }; + + sinon.assert.calledOnce(stubFirestoreInstance.doc); + assert.equals(stubFirestoreInstance.doc.getCall(0).args[0], DOC_PATH); + + sinon.assert.calledOnce(docRef.get); + + sinon.assert.calledOnce(docRef.set); + assert.equals(docRef.set.getCall(0).args[0], expectedDoc); + assert.equals(data, expectedValue); + }); + + it('updateState() should write document to collection', async function () { + // updateState calls get(), so give it a doc to return... + docRef.get.returns(Promise.resolve(EXISTING_DOC)); + + const state = State.buildFor(autoscalerConfig); + + // make state.now return a fixed value + const nowfunc = sinon.stub(); + sinon.replaceGetter(state, 'now', nowfunc); + nowfunc.returns(DUMMY_TIMESTAMP2); + + const doc = await state.get(); + doc.lastScalingTimestamp = DUMMY_TIMESTAMP2; + doc.lastScalingCompleteTimestamp = DUMMY_TIMESTAMP2 + 1800_000; // +30mins + await state.updateState(doc); + + sinon.assert.calledOnce(stubFirestoreInstance.doc); + assert.equals(stubFirestoreInstance.doc.getCall(0).args[0], DOC_PATH); + + sinon.assert.calledOnce(docRef.update); + assert.equals(docRef.update.getCall(0).args[0], { + updatedOn: DUMMY_FIRESTORE_TIMESTAMP2, + lastScalingTimestamp: DUMMY_FIRESTORE_TIMESTAMP2, + lastScalingCompleteTimestamp: firestore.Timestamp.fromMillis( + DUMMY_TIMESTAMP2 + 1800_000, + ), + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + }); + }); +}); + +describe('stateSpannerTests', () => { + /** @type {sinon.SinonStubbedInstance} */ + let stubSpannerClient; + /** @type {sinon.SinonStubbedInstance} */ + let stubSpannerInstance; + /** @type {sinon.SinonStubbedInstance} */ + let stubSpannerDatabase; + /** @type {sinon.SinonStubbedInstance} */ + let stubSpannerTable; + + const autoscalerConfig = { + ...BASE_CONFIG, + stateDatabase: { + name: 'spanner', + instanceId: 'stateInstanceId', + databaseId: 'stateDatabaseId', + }, + }; + + const expectedRowId = `projects/${BASE_CONFIG.projectId}/regions/${BASE_CONFIG.regionId}/clusters/${BASE_CONFIG.clusterId}`; + const expectedQuery = { + columns: [ + 'lastScalingTimestamp', + 'createdOn', + 'updatedOn', + 'lastScalingCompleteTimestamp', + 'scalingOperationId', + 'scalingRequestedSize', + 'scalingPreviousSize', + 'scalingMethod', + ], + keySet: { + keys: [ + { + values: [ + { + stringValue: expectedRowId, + }, + ], + }, + ], + }, + }; + + const VALID_ROW = { + toJSON: () => { + return { + lastScalingTimestamp: new Date(DUMMY_TIMESTAMP), + createdOn: new Date(DUMMY_TIMESTAMP), + updatedOn: new Date(DUMMY_TIMESTAMP), + lastScalingCompleteTimestamp: new Date(DUMMY_TIMESTAMP), + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + }; + }, + }; + + const SPANNER_EPOCH_ISO_TIME = new Date(0).toISOString(); + const DUMMY_SPANNER_ISO_TIME = new Date(DUMMY_TIMESTAMP).toISOString(); + const DUMMY_SPANNER_ISO_TIME2 = new Date(DUMMY_TIMESTAMP2).toISOString(); + + beforeEach(() => { + stubSpannerClient = sinon.createStubInstance(spanner.Spanner); + stubSpannerInstance = sinon.createStubInstance(spanner.Instance); + stubSpannerDatabase = sinon.createStubInstance(spanner.Database); + stubSpannerTable = sinon.createStubInstance(spanner.Table); + + stubSpannerConstructor.reset(); + stubSpannerConstructor.returns(stubSpannerClient); + stubSpannerClient.instance.returns(stubSpannerInstance); + stubSpannerInstance.database.returns(stubSpannerDatabase); + stubSpannerDatabase.table + .withArgs('memorystoreClusterAutoscaler') + .returns(stubSpannerTable); + + // Clear cached Spanner DB instances from the memoized function in + // StateSpanner + StateSpanner.getSpannerDatabaseClient.cache.clear(); + }); + + it('should create a StateSpanner object connecting to memorystore projectId', function () { + const config = { + ...autoscalerConfig, + }; + delete config.stateProjectId; + const state = State.buildFor(config); + assert.equals(state.constructor.name, 'StateSpanner'); + sinon.assert.calledWith(stubSpannerConstructor, { + projectId: autoscalerConfig.projectId, + }); + sinon.assert.calledWith( + stubSpannerClient.instance, + autoscalerConfig.stateDatabase.instanceId, + ); + sinon.assert.calledWith( + stubSpannerInstance.database, + autoscalerConfig.stateDatabase.databaseId, + ); + sinon.assert.calledWith( + stubSpannerDatabase.table, + 'memorystoreClusterAutoscaler', + ); + }); + + it('should create a StateSpanner object connecting to stateProjectId', function () { + const state = State.buildFor(autoscalerConfig); + assert.equals(state.constructor.name, 'StateSpanner'); + sinon.assert.calledWith(stubSpannerConstructor, { + projectId: 'stateProject', + }); + sinon.assert.calledWith( + stubSpannerClient.instance, + autoscalerConfig.stateDatabase.instanceId, + ); + sinon.assert.calledWith( + stubSpannerInstance.database, + autoscalerConfig.stateDatabase.databaseId, + ); + sinon.assert.calledWith( + stubSpannerDatabase.table, + 'memorystoreClusterAutoscaler', + ); + }); + + it('should reuse the Spanner clients for each database', function () { + const config1 = { + ...autoscalerConfig, + stateProjectId: 'stateProject1', + stateDatabase: { + name: 'spanner', + instanceId: 'stateInstanceId1', + databaseId: 'stateDatabaseId1', + }, + }; + const config2 = { + ...autoscalerConfig, + stateProjectId: 'stateProject2', + stateDatabase: { + name: 'spanner', + instanceId: 'stateInstanceId2', + databaseId: 'stateDatabaseId2', + }, + }; + + State.buildFor(config1); + State.buildFor(config2); + State.buildFor(config1); + State.buildFor(config2); + State.buildFor(config1); + State.buildFor(config2); + + // get client for project + assert.equals(stubSpannerConstructor.getCalls().length, 2); + assert.equals(stubSpannerConstructor.firstCall.args[0], { + projectId: 'stateProject1', + }); + assert.equals(stubSpannerConstructor.secondCall.args[0], { + projectId: 'stateProject2', + }); + + // get instance for project + assert.equals(stubSpannerClient.instance.getCalls().length, 2); + assert.equals( + stubSpannerClient.instance.firstCall.args[0], + config1.stateDatabase.instanceId, + ); + assert.equals( + stubSpannerClient.instance.secondCall.args[0], + config2.stateDatabase.instanceId, + ); + + // get database from instance + assert.equals(stubSpannerInstance.database.getCalls().length, 2); + assert.equals( + stubSpannerInstance.database.firstCall.args[0], + config1.stateDatabase.databaseId, + ); + assert.equals( + stubSpannerInstance.database.secondCall.args[0], + config2.stateDatabase.databaseId, + ); + }); + + it('get() should read document from table when exists', async function () { + // @ts-ignore + stubSpannerTable.read.returns(Promise.resolve([[VALID_ROW]])); + + const state = State.buildFor(autoscalerConfig); + const data = await state.get(); + + sinon.assert.calledWith(stubSpannerTable.read, expectedQuery); + assert.equals(data, { + createdOn: DUMMY_TIMESTAMP, + updatedOn: DUMMY_TIMESTAMP, + lastScalingTimestamp: DUMMY_TIMESTAMP, + lastScalingCompleteTimestamp: DUMMY_TIMESTAMP, + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + }); + }); + + it('get() should create a document when it does not exist', async function () { + // @ts-ignore + stubSpannerTable.read.returns(Promise.resolve([[]])); + + const state = State.buildFor(autoscalerConfig); + // make state.now return a fixed value + const nowfunc = sinon.stub(); + sinon.replaceGetter(state, 'now', nowfunc); + nowfunc.returns(DUMMY_TIMESTAMP); + + const data = await state.get(); + + sinon.assert.calledWith(stubSpannerTable.upsert, { + lastScalingTimestamp: SPANNER_EPOCH_ISO_TIME, + createdOn: DUMMY_SPANNER_ISO_TIME, + updatedOn: DUMMY_SPANNER_ISO_TIME, + lastScalingCompleteTimestamp: SPANNER_EPOCH_ISO_TIME, + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + id: expectedRowId, + }); + + assert.equals(data, { + lastScalingTimestamp: 0, + lastScalingCompleteTimestamp: 0, + createdOn: DUMMY_TIMESTAMP, + updatedOn: DUMMY_TIMESTAMP, + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + }); + }); + + it('updateState() should write document to table', async function () { + // @ts-ignore + stubSpannerTable.read.returns(Promise.resolve([[VALID_ROW]])); + + const state = State.buildFor(autoscalerConfig); + + // make state.now return a fixed value + const nowfunc = sinon.stub(); + sinon.replaceGetter(state, 'now', nowfunc); + nowfunc.returns(DUMMY_TIMESTAMP); + + const doc = await state.get(); + + nowfunc.returns(DUMMY_TIMESTAMP2); + doc.lastScalingTimestamp = DUMMY_TIMESTAMP2; + await state.updateState(doc); + + sinon.assert.calledWith(stubSpannerTable.upsert, { + updatedOn: DUMMY_SPANNER_ISO_TIME2, + lastScalingTimestamp: DUMMY_SPANNER_ISO_TIME2, + lastScalingCompleteTimestamp: DUMMY_SPANNER_ISO_TIME, + scalingOperationId: null, + scalingRequestedSize: null, + scalingMethod: null, + scalingPreviousSize: null, + id: expectedRowId, + }); + }); +}); diff --git a/src/scaler/scaler-core/test/test-utils.js b/src/scaler/scaler-core/test/test-utils.js new file mode 100644 index 0000000..9f05cc5 --- /dev/null +++ b/src/scaler/scaler-core/test/test-utils.js @@ -0,0 +1,97 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +const sinon = require('sinon'); +const State = require('../state.js'); +const unionBy = require('lodash.unionby'); + +const parameters = require('./samples/parameters.json'); + +/** + * @typedef {import('../../../autoscaler-common/types') + * .AutoscalerMemorystoreCluster} AutoscalerMemorystoreCluster + * @typedef {import('../../../autoscaler-common/types').MemorystoreClusterMetric + * } MemorystoreClusterMetric + * @typedef {import('../../../autoscaler-common/types') + * .MemorystoreClusterMetricValue} MemorystoreClusterMetricValue + * @typedef {State.StateData} StateData + */ + +const DUMMY_TIMESTAMP = 1704110400000; + +/** + * Read Spanner params from file + * + * @param {Object} [overrideParams] + * @return {AutoscalerMemorystoreCluster} + */ +function createClusterParameters(overrideParams) { + return /** @type {AutoscalerMemorystoreCluster} */ ({ + ...parameters, + ...overrideParams, + }); +} + +/** + * Merge metrics objects + * + * @param {AutoscalerMemorystoreCluster} cluster + * @param {(MemorystoreClusterMetric | MemorystoreClusterMetricValue)[]} + * metricsOverlay + * @return {(MemorystoreClusterMetric | MemorystoreClusterMetricValue)[]} + */ +function metricsOverlay(cluster, metricsOverlay) { + return unionBy(metricsOverlay, cluster.metrics, 'name'); +} + +/** + * @return {sinon.SinonStubbedInstance} state class stub + */ +function createStubState() { + const stubState = sinon.createStubInstance(State); + stubState.updateState.resolves(); + sinon.replaceGetter(stubState, 'now', () => DUMMY_TIMESTAMP); + return stubState; +} + +/** + * @return {StateData} StateData object + */ +function createStateData() { + return { + lastScalingTimestamp: 0, + createdOn: 0, + updatedOn: 0, + lastScalingCompleteTimestamp: 0, + scalingOperationId: null, + scalingRequestedSize: null, + scalingPreviousSize: null, + scalingMethod: null, + }; +} + +/** + * @return {string} downstream message + */ +function createDownstreamMsg() { + return JSON.stringify(require('./samples/downstream-msg.json'), null, 2); +} + +module.exports = { + createClusterParameters, + createStubState, + createDownstreamMsg, + createStateData, + metricsOverlay, +}; diff --git a/src/scaler/scaler-core/test/utils.test.js b/src/scaler/scaler-core/test/utils.test.js new file mode 100644 index 0000000..0e84739 --- /dev/null +++ b/src/scaler/scaler-core/test/utils.test.js @@ -0,0 +1,77 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +const {Topic} = require('@google-cloud/pubsub'); +const rewire = require('rewire'); +// eslint-disable-next-line no-unused-vars +const should = require('should'); +const sinon = require('sinon'); +// @ts-ignore +const referee = require('@sinonjs/referee'); +// @ts-ignore +const assert = referee.assert; +const {createDownstreamMsg} = require('./test-utils.js'); + +const app = rewire('../utils.js'); + +const {PubSub} = require('@google-cloud/pubsub'); +const pubsub = new PubSub(); +const protobuf = require('protobufjs'); + +const publishProtoMsgDownstream = app.__get__('publishProtoMsgDownstream'); +describe('#publishProtoMsgDownstream', () => { + beforeEach(function () { + sinon.restore(); + }); + + it('should not instantiate downstream topic if not defined in config', async function () { + const stubPubSub = sinon.stub(pubsub); + app.__set__('pubsub', stubPubSub); + + await publishProtoMsgDownstream('EVENT', '', undefined); + + assert(stubPubSub.topic.notCalled); + }); + + it('should publish downstream message', async function () { + const stubTopic = sinon.createStubInstance(Topic); + stubTopic.publishMessage.resolves(); + const stubPubSub = sinon.stub(pubsub); + stubPubSub.topic.returns(stubTopic); + + app.__set__('pubsub', stubPubSub); + app.__set__( + 'createProtobufMessage', + sinon.stub().returns(Buffer.from('{}')), + ); + + await publishProtoMsgDownstream('EVENT', '', 'the/topic'); + assert(stubTopic.publishMessage.calledOnce); + }); +}); + +const createProtobufMessage = app.__get__('createProtobufMessage'); +describe('#createProtobufMessage', () => { + it('should create a Protobuf message that can be validated', async function () { + const message = await createProtobufMessage(createDownstreamMsg()); + const result = message.toJSON(); + + const root = await protobuf.load( + 'src/scaler/scaler-core/downstream.schema.proto', + ); + const DownstreamEvent = root.lookupType('DownstreamEvent'); + assert.equals(DownstreamEvent.verify(result), null); + }); +}); diff --git a/src/scaler/scaler-core/utils.js b/src/scaler/scaler-core/utils.js new file mode 100644 index 0000000..cb049fc --- /dev/null +++ b/src/scaler/scaler-core/utils.js @@ -0,0 +1,103 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +/* + * Helper functions + */ + +// Create PubSub client and cache it +const {PubSub} = require('@google-cloud/pubsub'); +const pubsub = new PubSub(); +const protobuf = require('protobufjs'); +const {logger} = require('../../autoscaler-common/logger'); + +/** + * Format duration as human-readable text + * + * @param {number} millisec + * @return {string} + */ +function convertMillisecToHumanReadable(millisec) { + // By Nofi @ https://stackoverflow.com/a/32180863 + const seconds = millisec / 1000; + const minutes = millisec / (1000 * 60); + const hours = millisec / (1000 * 60 * 60); + const days = millisec / (1000 * 60 * 60 * 24); + + if (seconds < 60) { + return seconds.toFixed(1) + ' Sec'; + } else if (minutes < 60) { + return minutes.toFixed(1) + ' Min'; + } else if (hours < 24) { + return hours.toFixed(1) + ' Hrs'; + } else { + return days.toFixed(1) + ' Days'; + } +} + +/** + * Create Pub/Sub messages with Protobuf schema + * @param {Object} jsonData + * @return {Promise} + */ +async function createProtobufMessage(jsonData) { + const root = await protobuf.load( + 'src/scaler/scaler-core/downstream.schema.proto', + ); + const DownstreamEvent = root.lookupType('DownstreamEvent'); + return DownstreamEvent.create(jsonData); +} + +/** + * Publish pub/sub message + * + * @param {string} eventName + * @param {Object} jsonData + * @param {string} [topicId] + * @return {Promise<*>} + */ +async function publishProtoMsgDownstream(eventName, jsonData, topicId) { + if (!topicId) { + logger.debug( + `If you want ${eventName} messages published downstream then specify ` + + 'downstreamPubSubTopic in your config.', + ); + return Promise.resolve(); + } + + const topic = pubsub.topic(topicId); + const message = await createProtobufMessage(jsonData); + const data = Buffer.from(JSON.stringify(message.toJSON())); + const attributes = {event: eventName}; + + return topic + .publishMessage({data: data, attributes: attributes}) + .then(() => + logger.info( + `Published ${eventName} message downstream to topic: ${topicId}`, + ), + ) + .catch((err) => { + logger.error({ + message: `An error occurred publishing ${eventName} message downstream to topic: ${topicId}: ${err}`, + err: err, + }); + }); +} + +module.exports = { + convertMillisecToHumanReadable, + publishProtoMsgDownstream, +}; diff --git a/src/unified-scaler.js b/src/unified-scaler.js new file mode 100644 index 0000000..0d5cd33 --- /dev/null +++ b/src/unified-scaler.js @@ -0,0 +1,77 @@ +/* Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +const pollerCore = require('./poller/poller-core'); +const scalerCore = require('./scaler/scaler-core'); +const {logger} = require('./autoscaler-common/logger'); +const yaml = require('js-yaml'); +const fs = require('fs/promises'); +const CountersBase = require('./autoscaler-common/counters-base'); +const {version: packageVersion} = require('../package.json'); + +/** + * Startup function for unified poller/scaler + */ +async function main() { + const DEFAULT_CONFIG_LOCATION = + '/etc/autoscaler-config/autoscaler-config.yaml'; + + logger.info( + `Autoscaler unified Poller/Scaler v${packageVersion} job started`, + ); + + // This is not a long-running process, but we only want to flush the counters + // when it has completed. So disable flushing here, and enable and flush in + // the finally {} block + CountersBase.setTryFlushEnabled(false); + + let configLocation = DEFAULT_CONFIG_LOCATION; + + /* + * If set, the AUTOSCALER_CONFIG environment variable is used to + * retrieve the configuration for this instance of the Poller. + * Please refer to the documentation in the README.md for GKE + * deployment for more details. + */ + + if (process.env.AUTOSCALER_CONFIG) { + configLocation = process.env.AUTOSCALER_CONFIG; + logger.debug(`Using custom config location ${configLocation}`); + } else { + logger.debug(`Using default config location ${configLocation}`); + } + + try { + const config = await fs.readFile(configLocation, {encoding: 'utf8'}); + const clusters = await pollerCore.checkMemorystoreClusterScaleMetricsLocal( + JSON.stringify(yaml.load(config)), + ); + for (const cluster of clusters) { + await scalerCore.scaleMemorystoreClusterLocal(cluster); + } + } catch (err) { + logger.error({ + message: 'Error in unified poller/scaler wrapper: ${err}', + err: err, + }); + } finally { + CountersBase.setTryFlushEnabled(true); + await CountersBase.tryFlush(); + } +} + +module.exports = { + main, +}; diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..0fdaa71 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,125 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Set up the Autoscaler using Terraform configuration files +
+ Home + · + Scaler component + · + Poller component + · + Forwarder component + · + Terraform configuration + · + Monitoring +
+ Cloud Run functions + · + Google Kubernetes Engine +

+ +

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Monitoring](#monitoring) +* [Productionization](#productionization) + +## Overview + +This directory contains Terraform configuration files to quickly set up the +infrastructure of your Autoscaler. + +The Autoscaler can currently be deployed as follows: + +* [Deployment to Cloud Run functions](cloud-functions/README.md): Autoscaler + components are deployed to [Cloud Run functions][cloudfunctions], with + [Pub/Sub][pubsub] used for asynchronous messaging between components. Use + this deployment type for serverless operation, and to take maximal + advantage of Google Cloud managed services. +* [Deployment to Google Kubernetes Engine (GKE)](gke/README.md): Autoscaler + components are deployed to [Google Kubernetes Engine (GKE)][gke], with + Kubernetes-native constructs used for messaging and configuration. Use this + deployment type if you want to use Kubernetes or cannot use the Google + Cloud service dependencies in the Cloud Run functions model described above. + +## Monitoring + +The [monitoring](modules/autoscaler-monitoring) module is an optional +module for monitoring, and creates the following resources. + +* Cloud Monitoring Dashboard: a starter dashboard users could deploy to get + started. This dashboard has metrics that show the utilization and current + state of a Memorystore Cluster instance that is being autoscaled. + +## Productionization + +The following steps are recommended for productionizing deployment of the +Autoscaler: + +* Begin by deploying the Autoscaler in Dev/Test environments and progress + your use of the Autoscaler safely towards your Production environments. +* Incorporate the relevant portions of the supplied Terraform configuration + into your own Terraform codebase. You may choose to use the supplied modules + directly, or select portions of the modules to use in your own projects. +* Create additional cloud resource deployment pipelines using your CI/CD + tooling to automate the deployment and lifecycle management of the + Autoscaler. This should include the cloud resources that are used by + the Autoscaler, as well as the Autoscaler application components + themselves, i.e. the Cloud Run functions or container images for the + [Poller][autoscaler-poller] and [Scaler][autoscaler-scaler] components. +* Decouple the lifecycle of the Autoscaler components from the + lifecycles of the Memorystore instances being scaled. In particular, it + should be possible to completely tear down and redeploy all components + of the Autoscaler without affecting your Memorystore instances. +* Store your Autoscaler configuration files in your source control system, + along with the Terraform and application codebase. +* Automate updating the Autoscaler configuration using a deployment + pipeline separate from deploying the Autoscaler itself. This will + allow you to incorporate policy and other checks according to your + organizational requirements (e.g. change freeze periods), as well as + decoupling updates to the Autoscaler configuration from updates to the + Autoscaler itself. +* Pay particular attention to the management and permissions of the service + accounts you configure the Autoscaler to use. We recommend assigning + [minimally permissioned service accounts][sa-permissions]. +* Define [alerts][alerts] to be notified of autoscaling events that may + affect your platform or your application. You can use + [log-based-alerts][log-based-alerts] to configure alerts that will + notify you whenever a specific message appears in the logs. +* In the case of the [Centralized][centralized] or + [Distributed][distributed] deployment topologies, consider + running the Autoscaler components in a dedicated project with tightly + controlled access. +* In the case of deployment to [gke][gke], you may choose to incorporate + addtional security measures, such as [Artifact Analysis][artifact-analysis], + [Binary Authorization][binary-authorization], and + [Container Threat Detection][container-threat-detection], to help + secure your deployment. + +Please note that during the Preview program we recommend using the Autoscaler +in non-production environments only. + + + +[alerts]: https://cloud.google.com/monitoring/alerts +[artifact-analysis]: https://cloud.google.com/artifact-registry/docs/analysis +[autoscaler-poller]: ../src/poller/README.md +[autoscaler-scaler]: ../src/scaler/README.md +[binary-authorization]: https://cloud.google.com/binary-authorization/docs/setting-up +[centralized]: cloud-functions/centralized/README.md +[cloudfunctions]: https://cloud.google.com/functions +[container-threat-detection]: https://cloud.google.com/security-command-center/docs/concepts-container-threat-detection-overview +[distributed]: cloud-functions/distributed/README.md +[gke]: https://cloud.google.com/kubernetes-engine +[log-based-alerts]: https://cloud.google.com/logging/docs/alerting/log-based-alerts +[pubsub]: https://cloud.google.com/pubsub +[sa-permissions]: https://cloud.google.com/iam/docs/service-account-overview#service-account-permissions diff --git a/terraform/cloud-functions/README.md b/terraform/cloud-functions/README.md new file mode 100644 index 0000000..03696b7 --- /dev/null +++ b/terraform/cloud-functions/README.md @@ -0,0 +1,199 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Set up the Autoscaler using Terraform configuration files +
+ Home + · + Scaler component + · + Poller component + · + Forwarder component + · + Terraform configuration + · + Monitoring +
+ Cloud Run functions + · + Google Kubernetes Engine +
+ Per-Project + · + Centralized + · + Distributed +

+ +

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Architecture](#architecture) +* [Deployment](#deployment) +* [Monitoring](#monitoring) + +## Overview + +This directory contains Terraform configuration files to quickly set up the +infrastructure of your Autoscaler on Cloud Run functions. + +## Architecture + +![architecture-per-project](../../resources/architecture-per-project.png) + +The diagram above shows the components of the Autoscaler and the +interaction flow: + +1. Using [Cloud Scheduler][cloud-scheduler] you define how + often one or more Memorystore Cluster instances should be verified. You can + define separate Cloud Scheduler jobs to check several Memorystore Cluster + instances with different schedules, or you can group many instances under a + single schedule. + +2. At the specified time and frequency, Cloud Scheduler pushes a message into + the Polling [Cloud Pub/Sub][cloud-pub-sub] topic. The message contains a + JSON payload with the Autoscaler [configuration parameters](#configuration) + that you defined for each Memorystore Cluster instance. + +3. When Cloud Scheduler pushes a message into the Poller topic, an instance of + the [Poller Cloud Function][autoscaler-poller] is created to handle the + message. + +4. The Poller function reads the message payload and queries the + [Cloud Monitoring][cloud-monitoring] API to retrieve the utilization metrics + for each Memorystore Cluster instance. + +5. For each instance, the Poller function pushes one message into the Scaling + Pub/Sub topic. The message payload contains the utilization metrics for the + specific Memorystore Cluster instance, and some of its corresponding configuration + parameters. + +6. For each message pushed into the Scaler topic, an instance of the + [Scaler Cloud Function][autoscaler-scaler] is created to handle it. + Using the chosen [scaling method][scaling-methods] the + Scaler function compares the Memorystore Cluster instance metrics against + the recommended thresholds, and determines if the instance should be scaled, + and the number of shards/nodes that it should be scaled to. + +7. The Scaler function retrieves the time when the instance was last scaled + from the state data stored in [Cloud Firestore][cloud-firestore] and + compares it with the current database time. + +8. If the configured cooldown period has passed, then the Scaler function + requests the Memorystore Cluster instance to scale out or in. + +Throughout the flow, the Autoscaler writes a step by step summary +of its recommendations and actions to [Cloud Logging][cloud-logging] for +tracking and auditing. + +## Deployment + +The Autoscaler can be deployed following three different strategies. Choose the +one that is best adjusted to fulfill your technical and operational needs. + +* [Per-Project deployment](per-project/README.md): all the components of the + Autoscaler reside in the same project as your Memorystore Cluster + instances. This deployment is ideal for independent teams who want to self + manage the configuration and infrastructure of their own Autoscalers. It is + also a good entry point for testing the Autoscaler capabilities. + +* [Centralized deployment](centralized/README.md): a slight departure from the + pre-project deployment, where all the components of the Memorystore Cluster + Autoscaler reside in the same project, but the Memorystore Cluster instances + may be located in different projects. This deployment is suited for a team + managing the configuration and infrastructure of several Autoscalers in a + central place. + +* [Distributed deployment](distributed/README.md): all the components of the + Autoscaler reside in a single project, with the exception of + Cloud Scheduler. This deployment is a hybrid where teams who own the + Memorystore Cluster instances want to manage only the Autoscaler + configuration parameters for their instances, but the rest of the Autoscaler + infrastructure is managed by a central team. + +## Configuration + +After deploying the Autoscaler, you are ready to configure its parameters. + +1. Open the [Cloud Scheduler console page][cloud-scheduler-console]. + +2. Select the checkbox next to the name of the job created by the Autoscaler + deployment: `poll-cluster-metrics` + +3. Click on **Edit** on the top bar. + +4. Click on **Configure the execution**. + +5. Modify the Autoscaler parameters shown in the job payload, in the field + **Message body**. The following is an example: + + ```json + [ + { + "projectId": "memorystore-cluster-project-id", + "regionId": "us-central1", + "clusterId": "autoscaler-target-memorystore-cluster", + "minSize": 5, + "maxSize": 10, + "units": "SHARDS", + "scalerPubSubTopic": "projects/memorystore-cluster-project-id/topics/scaler-topic", + "stateDatabase": { + "name": "firestore" + }, + "scalingMethod": "STEPWISE", + } + ] + ``` + + The payload is defined using a [JSON][json] array. Each element in the array + represents a Memorystore Cluster instance that will share the same + Autoscaler job schedule. + + Additionally, a single instance can have multiple Autoscaler configurations in + different job schedules. This is useful for example if you want to have an + instance configured with the linear method for normal operations, but also have + another Autoscaler configuration with the direct method for planned batch + workloads. + + You can find the details about the parameters and their default values in the + [Poller component page][autoscaler-poller]. + +6. Click on **Update** at the bottom to save the changes. + +The Autoscaler is now configured and will start monitoring and scaling your +instances in the next scheduled job run. + +Note that in the default configuration, any changes made to the Cloud Scheduler +configuration as described above will be reset by a subsequent Terraform run. +If you would prefer to manage the Cloud Scheduler configuration manually +following its initial creation, i.e. using the Google Cloud Web Console, the +`gcloud` CLI, or any other non-Terraform mechanism, please +[see this link][cloud-scheduler-lifecycle]. Without this change, the Terraform +configuration will remain the source of truth, and any direct modifications +to the Cloud Scheduler configuration will be reset on the next Terraform run. + +## Monitoring + +The [monitoring](../modules/autoscaler-monitoring) module is an optional +module for monitoring, which includes the creation of a dashboard to show +relevant Memorystore metrics. + +[autoscaler-poller]: ../../src/poller/README.md +[autoscaler-scaler]: ../../src/scaler/README.md +[cloud-firestore]: https://firebase.google.com/docs/firestore +[cloud-logging]: https://cloud.google.com/logging +[cloud-pub-sub]: https://cloud.google.com/pubsub +[cloud-monitoring]: https://cloud.google.com/monitoring +[cloud-scheduler]: https://cloud.google.com/scheduler +[cloud-scheduler-console]: https://console.cloud.google.com/cloudscheduler +[cloud-scheduler-lifecycle]: ../../terraform/modules/autoscaler-scheduler/main.tf#L67 +[json]: https://www.json.org/ +[scaling-methods]: ../../src/scaler/README.md#scaling-methods diff --git a/terraform/cloud-functions/centralized/README.md b/terraform/cloud-functions/centralized/README.md new file mode 100644 index 0000000..8aa7f34 --- /dev/null +++ b/terraform/cloud-functions/centralized/README.md @@ -0,0 +1,141 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Set up the Autoscaler in Cloud Run functions in a centralized + deployment using Terraform +
+ Home + · + Scaler component + · + Poller component + · + Forwarder component + · + Terraform configuration + · + Monitoring +
+ Cloud Run functions + · + Google Kubernetes Engine +
+ Per-Project + · + Centralized + · + Distributed + +

+ +

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Architecture](#architecture) + * [Pros](#pros) + * [Cons](#cons) +* [Before you begin](#before-you-begin) +* [Configuring your Application project](#configuring-your-application-project) + +## Overview + +This document shows the centralized deployment of the Autoscaler. +In the centralized deployment all the components of the Autoscaler +reside in the same project, but the Memorystore Cluster instances may be located +in different projects. + +This deployment is suited for a team managing the configuration and +infrastructure of one or more Autoscalers in a central place. The Memorystore Cluster +instances reside in other projects, called Application projects, which are owned +by the same or other teams. + +## Architecture + +![architecture-centralized](../../../resources/architecture-centralized.png) + +For an explanation of the components of the Autoscaler and the +interaction flow, please read the +[main Architecture section](../README.md#architecture). + +The centralized deployment has the following pros and cons: + +### Pros + +* **Configuration and infrastructure**: The scheduler parameters and the + Autoscaler infrastructure is controlled by a single team. This may desirable + on highly regulated industries. +* **Maintenance**: Maintenance and setup is expected to require less effort + overall when compared to single project deployment. +* **Policies and audit**: Best practices across teams might be easier to + specify and enact. Audits might be easier to execute. + +### Cons + +* **Configuration**: any change to the Autoscaler parameters needs to go + through the centralized team, even though the team requesting the change + owns the Memorystore Cluster instance. +* **Risk**: the centralized team itself may become a single point of failure + even if the infrastructure is designed with high availability in mind. + +## Before you begin + +The centralized deployment is a slight departure from the per-project option +where the Memorystore Cluster instances and the Autoscaler reside in different projects. +Therefore, most of the instructions to set it up are the same. + +Follow the instructions for the per-project option starting with the +[Before you begin section](../per-project/README.md#before-you-begin) and stop +before the +[Deploying the Autoscaler section](../per-project/README.md#deploying-the-autoscaler) + +## Configuring your Application project + +In this section you configure the project where your Memorystore cluster +resides. This project is called an "Application project" because the Memorystore +Cluster serves one or more specific applications. The teams responsible for +those applications are assumed to be separate from the team responsible for the +Autoscaler infrastructure and configuration. + +1. Go to the [project selector page][project-selector] in the Cloud Console. + Select or create a Cloud project. + +2. Make sure that billing is enabled for your Google Cloud project. + [Learn how to confirm billing is enabled for your project][enable-billing]. + +3. In Cloud Shell, set environment variables with the ID of your + **application** project. Replace the `` + placeholder and run the following command: + + ```sh + export APP_PROJECT_ID= + ``` + +4. Enable the Redis API: + + ```sh + gcloud services enable --project="${APP_PROJECT_ID}" \ + redis.googleapis.com + ``` + +5. Set the Application project ID in the corresponding Terraform environment + variable + + ```sh + export TF_VAR_app_project_id="${APP_PROJECT_ID}" + ``` + +You have configured your Application project. Please continue from the +[Deploying the Autoscaler section](../per-project/README.md#deploying-the-autoscaler) +in the per-project deployment documentation. + + + +[enable-billing]: https://cloud.google.com/billing/docs/how-to/modify-project +[project-selector]: https://console.cloud.google.com/projectselector2/home/dashboard diff --git a/terraform/cloud-functions/distributed/README.md b/terraform/cloud-functions/distributed/README.md new file mode 100644 index 0000000..4c66c4f --- /dev/null +++ b/terraform/cloud-functions/distributed/README.md @@ -0,0 +1,449 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Set up the Autoscaler in Cloud Run functions in a distributed + deployment using Terraform +
+ Home + · + Scaler component + · + Poller component + · + Forwarder component + · + Terraform configuration + · + Monitoring +
+ Cloud Run functions + · + Google Kubernetes Engine +
+ Per-Project + · + Centralized + · + Distributed + +

+ +

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Architecture](#architecture) + * [Pros](#pros) + * [Cons](#cons) +* [Before you begin](#before-you-begin) +* [Preparing the Autoscaler Project](#preparing-the-autoscaler-project) + * [Using Firestore for Autoscaler state](#using-firestore-for-autoscaler-state) + * [Using Spanner for Autoscaler state](#using-spanner-for-autoscaler-state) + * [Deploying the Autoscaler](#deploying-the-autoscaler) +* [Preparing the Application Project](#preparing-the-application-project) + * [Deploying the Autoscaler](#deploying-the-autoscaler) + * [Authorize the Forwarder function to publish to the Poller topic](#authorize-the-forwarder-function-to-publish-to-the-poller-topic) +* [Verifying your deployment](#verifying-your-deployment) + +## Overview + +This directory contains Terraform configuration files to quickly set up the +infrastructure for your Autoscaler with a distributed deployment. + +In this deployment option all the components of the Autoscaler +reside in a single project, with the exception of Cloud Scheduler (step 1) and +the [Forwarder topic and function](../../../src/forwarder/README.md) + +This deployment is the best of both worlds between the per-project and the +centralized deployments: *Teams who own the Memorystore Cluster instances, +called Application teams, are able to manage the Autoscaler configuration +parameters for their instances with their own Cloud Scheduler jobs.* On the +other hand, the rest of the Autoscaler infrastructure is managed by a central +team. + +## Architecture + +![architecture-distributed](../../../resources/architecture-distributed.png) + +For an explanation of the components of the Autoscaler and the +interaction flow, please read the +[main Architecture section](../README.md#architecture). + +Cloud Scheduler can only publish messages to topics in the same project. +Therefore in step 2, we transparently introduce an intermediate component to +make this architecture possible. For more information, see the +[Forwarder function](../../../src/forwarder/README.md). + +The distributed deployment has the following pros and cons: + +### Pros + +* **Configuration and infrastructure**: application teams are in control of + their config and schedules +* **Maintenance**: Scaler infrastructure is centralized, reducing up-keep + overhead +* **Policies and audit**: Best practices across teams might be easier to + specify and enact. Audits might be easier to execute. + +### Cons + +* **Configuration**: application teams need to provide service accounts to + write to the polling topic. +* **Risk**: the centralized team itself may become a single point of failure + even if the infrastructure is designed with high availability in mind. + +## Before you begin + +1. Open the [Cloud Console][cloud-console] +2. Activate [Cloud Shell][cloud-shell] \ + At the bottom of the Cloud Console, a + Cloud Shell + session starts and displays a command-line prompt. Cloud Shell is a shell + environment with the Cloud SDK already installed, including the + gcloud command-line tool, and with values already set for your + current project. It can take a few seconds for the session to initialize. + +3. In Cloud Shell, clone this repository: + + ```sh + gcloud source repos clone memorystore-cluster-autoscaler --project=memorystore-oss-preview + ``` + +4. Change into the directory of the cloned repository, and check out the + `main` branch: + + ```sh + cd memorystore-cluster-autoscaler && git checkout main + ``` + +5. Export variables for the working directories: + + ```sh + export AUTOSCALER_DIR="$(pwd)/terraform/cloud-functions/distributed/autoscaler-project" + export APP_DIR="$(pwd)/terraform/cloud-functions/distributed/app-project" + ``` + +## Preparing the Autoscaler Project + +In this section you prepare the deployment of the project where the centralized +Autoscaler infrastructure, with the exception of Cloud Scheduler, lives. + +1. Go to the [project selector page][project-selector] in the Cloud Console. + Select or create a Cloud project. + +2. Make sure that billing is enabled for your Google Cloud project. + [Learn how to confirm billing is enabled for your project][enable-billing]. + +3. In Cloud Shell, set environment variables with the ID of your **autoscaler** + project: + + ```sh + export AUTOSCALER_PROJECT_ID= + gcloud config set project "${AUTOSCALER_PROJECT_ID}" + ``` + +4. Choose the [region][region-and-zone] where the Autoscaler + infrastructure will be located. + + ```sh + export AUTOSCALER_REGION=us-central1 + ``` + +5. Enable the required Cloud APIs : + + ```sh + gcloud services enable \ + appengine.googleapis.com \ + artifactregistry.googleapis.com \ + cloudbuild.googleapis.com \ + cloudfunctions.googleapis.com \ + cloudresourcemanager.googleapis.com \ + compute.googleapis.com \ + eventarc.googleapis.com \ + iam.googleapis.com \ + networkconnectivity.googleapis.com \ + pubsub.googleapis.com \ + logging.googleapis.com \ + monitoring.googleapis.com \ + run.googleapis.com \ + serviceconsumermanagement.googleapis.com + ``` + +6. There are two options for deploying the state store for the Autoscaler: + + 1. Store the state in [Firestore][cloud-firestore] + 2. Store the state in [Spanner][cloud-spanner] + + For Firestore, follow the steps in + [Using Firestore for Autoscaler State](#using-firestore-for-autoscaler-state). + For Spanner, follow the steps in [Using Spanner for Autoscaler state](#using-spanner-for-autoscaler-state). + +### Using Firestore for Autoscaler state + +1. To use Firestore for the Autoscaler state, enable the additional APIs: + + ```sh + gcloud services enable firestore.googleapis.com + ``` + +2. Create a Google App Engine app to enable the API for Firestore: + + ```sh + gcloud app create --region="${REGION}" + ``` + +3. To store the state of the Autoscaler, update the database created with the + Google App Engine app to use [Firestore native mode][firestore-native]. + + ```sh + gcloud firestore databases update --type=firestore-native + ``` + +4. Next, continue to [Deploying the Autoscaler](#deploying-the-autoscaler). + +### Using Spanner for Autoscaler state + +1. To use Spanner for the Autoscaler state, enable the additional API: + + ```sh + gcloud services enable spanner.googleapis.com + ``` + +2. If you want Terraform to create a Spanner instance (named + `memorystore-autoscaler-state` by default) to store the state, + set the following variable: + + ```sh + export TF_VAR_terraform_spanner_state=true + ``` + + If you already have a Spanner instance where state must be stored, + set the the name of your instance: + + ```sh + export TF_VAR_spanner_state_name= + ``` + + If you want to manage the state of the Autoscaler in your own + Cloud Spanner instance, please create the following table in advance: + + ```sql + CREATE TABLE memorystoreClusterAutoscaler ( + id STRING(MAX), + lastScalingTimestamp TIMESTAMP, + createdOn TIMESTAMP, + updatedOn TIMESTAMP, + lastScalingCompleteTimestamp TIMESTAMP, + scalingOperationId STRING(MAX), + scalingRequestedSize INT64, + scalingPreviousSize INT64, + scalingMethod STRING(MAX), + ) PRIMARY KEY (id) + ``` + +3. Next, continue to [Deploying the Autoscaler](#deploying-the-autoscaler). + +### Deploying the Autoscaler + +1. Set the project ID and region in the + corresponding Terraform environment variables: + + ```sh + export TF_VAR_project_id="${AUTOSCALER_PROJECT_ID}" + export TF_VAR_region="${AUTOSCALER_REGION}" + ``` + +2. Change directory into the Terraform scaler-project directory and initialize + it. + + ```sh + cd "${AUTOSCALER_DIR}" + terraform init + ``` + +3. Create the Autoscaler infrastructure. Answer `yes` when prompted, after + reviewing the resources that Terraform intends to create. + + ```sh + terraform apply -parallelism=2 + ``` + + * If you are running this command in Cloud Shell and encounter errors of + the form "`Error: cannot assign requested address`", this is a [known + issue][provider-issue] in the Terraform Google provider, please retry + with -parallelism=1. + +## Preparing the Application Project + +In this section you prepare the deployment of the Cloud Scheduler, Forwarder +topic and function in the project where the Memorystore Cluster instances live. + +1. Go to the [project selector page][project-selector] in the Cloud Console. + Select or create a Cloud project. + +2. Make sure that billing is enabled for your Google Cloud project. + [Learn how to confirm billing is enabled for your project][enable-billing]. + +3. In Cloud Shell, set the environment variables with the ID of your + **application** project: + + ```sh + export APP_PROJECT_ID= + gcloud config set project "${APP_PROJECT_ID}" + ``` + +4. Choose the [region][region-and-zone] where the Application project + will be located: + + ```sh + export APP_REGION=us-central1 + ``` + +5. Use the following command to enable the Cloud APIs: + + ```sh + gcloud services enable \ + appengine.googleapis.com \ + artifactregistry.googleapis.com \ + cloudbuild.googleapis.com \ + cloudfunctions.googleapis.com \ + cloudresourcemanager.googleapis.com \ + cloudscheduler.googleapis.com \ + compute.googleapis.com \ + eventarc.googleapis.com \ + iam.googleapis.com \ + networkconnectivity.googleapis.com \ + pubsub.googleapis.com \ + logging.googleapis.com \ + monitoring.googleapis.com \ + redis.googleapis.com \ + run.googleapis.com \ + serviceconsumermanagement.googleapis.com + ``` + +6. Create an App to enable Cloud Scheduler, but do not create a Firestore + database: + + ```sh + gcloud app create --region="${APP_REGION}" + ``` + +### Deploy the Application infrastructure + +1. Set the project ID, region, and App Engine location in the + corresponding Terraform environment variables + + ```sh + export TF_VAR_project_id="${APP_PROJECT_ID}" + export TF_VAR_region="${APP_REGION}" + ``` + +2. By default, a new Memorystore Cluster instance will be created for testing. + If you want to scale an existing Memorystore Cluster instance, set the + following variable: + + ```sh + export TF_VAR_terraform_memorystore_cluster=false + ``` + + Set the following variable to choose the name of a new or existing cluster + to scale: + + ```sh + export TF_VAR_memorystore_cluster_name= + ``` + + If you do not set this variable, `autoscaler-target-memorystore-cluster` + will be used. + +3. Set the project ID where the Firestore instance resides. + + ```sh + export TF_VAR_state_project_id="${AUTOSCALER_PROJECT_ID}" + ``` + +4. To create a testbench VM with utilities for testing Memorystore, including + generating load, set the following variable: + + ```sh + export TF_VAR_terraform_test_vm=true + ``` + + Note that this option can only be selected when you have chosen to create a + new Memorystore cluster. + +5. Change directory into the Terraform app-project directory and initialize it. + + ```sh + cd "${APP_DIR}" + terraform init + ``` + +6. Create the infrastructure in the application project. Answer `yes` when + prompted, after reviewing the resources that Terraform intends to create. + + ```sh + terraform import module.autoscaler-scheduler.google_app_engine_application.app "${APP_PROJECT_ID}" + terraform apply -parallelism=2 + ``` + + * If you are running this command in Cloud Shell and encounter errors of + the form "`Error: cannot assign requested address`", this is a [known + issue][provider-issue] in the Terraform Google provider, please retry + with -parallelism=1 + +### Authorize the Forwarder function to publish to the Poller topic + +1. Switch back to the Autoscaler project and ensure that Terraform variables + are correctly set. + + ```sh + cd "${AUTOSCALER_DIR}" + + export TF_VAR_project_id="${AUTOSCALER_PROJECT_ID}" + export TF_VAR_region="${AUTOSCALER_REGION}" + ``` + +2. Set the Terraform variables for your Forwarder service accounts, updating + and adding your service accounts as needed. Answer `yes` when prompted, + after reviewing the resources that Terraform intends to create. + + ```sh + export TF_VAR_forwarder_sa_emails='["serviceAccount:forwarder-sa@'"${APP_PROJECT_ID}"'.iam.gserviceaccount.com"]' + terraform apply -parallelism=2 + ``` + +If you are running this command in Cloud Shell and encounter errors of the form +"`Error: cannot assign requested address`", this is a +[known issue][provider-issue] in the Terraform Google provider, please retry +with -parallelism=1 + +## Verifying your deployment + +Your Autoscaler infrastructure is ready, follow the instructions in the main +page to [configure your Autoscaler](../README.md#configuration). Please take +in account that in a distributed deployment: *Logs from the Poller and Scaler +functions will appear in the [Logs Viewer][logs-viewer] for the Autoscaler +project.* Logs about syntax errors in the JSON configuration of the Cloud +Scheduler payload will appear in the Logs viewer of each Application project, so +that the team responsible for a specific Cloud Spanner instance can troubleshoot +its configuration issues independently. + + + +[cloud-console]: https://console.cloud.google.com +[cloud-firestore]: https://cloud.google.com/firestore +[cloud-shell]: https://console.cloud.google.com/?cloudshell=true +[cloud-spanner]: https://cloud.google.com/spanner +[enable-billing]: https://cloud.google.com/billing/docs/how-to/modify-project +[firestore-native]: https://cloud.google.com/datastore/docs/firestore-or-datastore#in_native_mode +[logs-viewer]: https://console.cloud.google.com/logs/query +[project-selector]: https://console.cloud.google.com/projectselector2/home/dashboard +[provider-issue]: https://github.com/hashicorp/terraform-provider-google/issues/6782 +[region-and-zone]: https://cloud.google.com/compute/docs/regions-zones#locations diff --git a/terraform/cloud-functions/distributed/app-project/.terraform.lock.hcl b/terraform/cloud-functions/distributed/app-project/.terraform.lock.hcl new file mode 100644 index 0000000..92f6b1b --- /dev/null +++ b/terraform/cloud-functions/distributed/app-project/.terraform.lock.hcl @@ -0,0 +1,79 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.6.0" + hashes = [ + "h1:rYAubRk7UHC/fzYqFV/VHc+7VIY01ugCxauyTYCNf9E=", + "zh:29273484f7423b7c5b3f5df34ccfc53e52bb5e3d7f46a81b65908e7a8fd69072", + "zh:3cba58ec3aea5f301caf2acc31e184c55d994cc648126cac39c63ae509a14179", + "zh:55170cd17dbfdea842852c6ae2416d057fec631ba49f3bb6466a7268cd39130e", + "zh:7197db402ba35631930c3a4814520f0ebe980ae3acb7f8b5a6f70ec90dc4a388", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:8bf7fe0915d7fb152a3a6b9162614d2ec82749a06dba13fab3f98d33c020ec4f", + "zh:8ce811844fd53adb0dabc9a541f8cb43aacfa7d8e39324e4bd3592b3428f5bfb", + "zh:bca795bca815b8ac90e3054c0a9ab1ccfb16eedbb3418f8ad473fc5ad6bf0ef7", + "zh:d9355a18df5a36cf19580748b23249de2eb445c231c36a353709f8f40a6c8432", + "zh:dc32cc32cfd8abf8752d34f2a783de0d3f7200c573b885ecb64ece5acea173b4", + "zh:ef498e20391bf7a280d0fd6fd6675621c85fbe4e92f0f517ae4394747db89bde", + "zh:f2bc5226c765b0c8055a7b6207d0fe1eb9484e3ec8880649d158827ac6ed3b22", + ] +} + +provider "registry.terraform.io/hashicorp/google" { + version = "6.4.0" + constraints = ">= 6.1.0" + hashes = [ + "h1:+Xl/dWoAhhZ7GRPZwv7PCpnGa0MFGXyGesd9XxY+GeU=", + "zh:082e343d678da7bc8429c718b0251fc645a76b4d9b96a2cf669de02faa46c721", + "zh:117b781102aef79f63851bcb00e63d999d6b53ca46aac3f992107621c1058e47", + "zh:27bb144de4782ccc718485e033bfc7701ac36a3ee25ec41e4810a777d4fd083d", + "zh:3e0a05de8eb33bebb97947a515ae49760874ce30ff8601c79e8a4a38ca2b2510", + "zh:488777668eb61bdb4d5e949fc1f48a4c07a83f99c749a0b443be4908545bd412", + "zh:56f6a9d817dcb5754f377fae45e0ce0973a4619ee2eb26c8fdb933485ccc89e5", + "zh:5ed4a502834c5596e47969ad9bd646ff8c3c29d8aaaf75dfbd5623a577400a8d", + "zh:a0e971185ea15a62b505ccd8601fd16c1acf2744c51edc5a2cb151690055421c", + "zh:a2bf68d36c9ff401f554292cd4ace96443d1f1fb2dc11f95aa361a62c99dbc03", + "zh:c63f940a43258ba9aa95d7cc99104b12736f5ac76633009a5ad3c39335325a5c", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fa41ab733169e962cd6f26bdcd295823290905e0afba97d68f12a028066b7cf3", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.6.3" + hashes = [ + "h1:Fnaec9vA8sZ8BXVlN3Xn9Jz3zghSETIKg7ch8oXhxno=", + "zh:04ceb65210251339f07cd4611885d242cd4d0c7306e86dda9785396807c00451", + "zh:448f56199f3e99ff75d5c0afacae867ee795e4dfda6cb5f8e3b2a72ec3583dd8", + "zh:4b4c11ccfba7319e901df2dac836b1ae8f12185e37249e8d870ee10bb87a13fe", + "zh:4fa45c44c0de582c2edb8a2e054f55124520c16a39b2dfc0355929063b6395b1", + "zh:588508280501a06259e023b0695f6a18149a3816d259655c424d068982cbdd36", + "zh:737c4d99a87d2a4d1ac0a54a73d2cb62974ccb2edbd234f333abd079a32ebc9e", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a357ab512e5ebc6d1fda1382503109766e21bbfdfaa9ccda43d313c122069b30", + "zh:c51bfb15e7d52cc1a2eaec2a903ac2aff15d162c172b1b4c17675190e8147615", + "zh:e0951ee6fa9df90433728b96381fb867e3db98f66f735e0c3e24f8f16903f0ad", + "zh:e3cdcb4e73740621dabd82ee6a37d6cfce7fee2a03d8074df65086760f5cf556", + "zh:eff58323099f1bd9a0bec7cb04f717e7f1b2774c7d612bf7581797e1622613a0", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.12.1" + hashes = [ + "h1:6BhxSYBJdBBKyuqatOGkuPKVenfx6UmLdiI13Pb3his=", + "zh:090023137df8effe8804e81c65f636dadf8f9d35b79c3afff282d39367ba44b2", + "zh:26f1e458358ba55f6558613f1427dcfa6ae2be5119b722d0b3adb27cd001efea", + "zh:272ccc73a03384b72b964918c7afeb22c2e6be22460d92b150aaf28f29a7d511", + "zh:438b8c74f5ed62fe921bd1078abe628a6675e44912933100ea4fa26863e340e9", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:85c8bd8eefc4afc33445de2ee7fbf33a7807bc34eb3734b8eefa4e98e4cddf38", + "zh:98bbe309c9ff5b2352de6a047e0ec6c7e3764b4ed3dfd370839c4be2fbfff869", + "zh:9c7bf8c56da1b124e0e2f3210a1915e778bab2be924481af684695b52672891e", + "zh:d2200f7f6ab8ecb8373cda796b864ad4867f5c255cff9d3b032f666e4c78f625", + "zh:d8c7926feaddfdc08d5ebb41b03445166df8c125417b28d64712dccd9feef136", + "zh:e2412a192fc340c61b373d6c20c9d805d7d3dee6c720c34db23c2a8ff0abd71b", + "zh:e6ac6bba391afe728a099df344dbd6481425b06d61697522017b8f7a59957d44", + ] +} diff --git a/terraform/cloud-functions/distributed/app-project/main.tf b/terraform/cloud-functions/distributed/app-project/main.tf new file mode 100644 index 0000000..e473e1e --- /dev/null +++ b/terraform/cloud-functions/distributed/app-project/main.tf @@ -0,0 +1,122 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = ">= 6.1.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +data "terraform_remote_state" "autoscaler" { + backend = "local" + + config = { + path = "../autoscaler-project/terraform.tfstate" + } +} + +module "autoscaler-network" { + count = var.terraform_memorystore_cluster ? 1 : 0 + source = "../../../modules/autoscaler-network" + + region = var.region + project_id = var.project_id + ip_range = var.ip_range +} + +module "autoscaler-memorystore-cluster" { + source = "../../../modules/autoscaler-memorystore-cluster" + + region = var.region + project_id = var.project_id + memorystore_cluster_name = var.memorystore_cluster_name + + network = var.terraform_memorystore_cluster ? one(module.autoscaler-network).network : null + subnetwork = var.terraform_memorystore_cluster ? one(module.autoscaler-network).subnetwork : null + dns_zone = var.terraform_memorystore_cluster ? one(module.autoscaler-network).dns_zone : null + + terraform_memorystore_cluster = var.terraform_memorystore_cluster + + poller_sa_email = data.terraform_remote_state.autoscaler.outputs.poller_sa_email + scaler_sa_email = data.terraform_remote_state.autoscaler.outputs.scaler_sa_email + + memorystore_shard_count = var.memorystore_shard_count + memorystore_replica_count = var.memorystore_replica_count + + depends_on = [module.autoscaler-network] +} + +module "autoscaler-test-vm" { + count = var.terraform_test_vm && var.terraform_memorystore_cluster ? 1 : 0 + source = "../../../modules/autoscaler-test-vm" + + region = var.region + project_id = var.project_id + name = var.terraform_test_vm_name + network = one(module.autoscaler-network).network + subnetwork = one(module.autoscaler-network).subnetwork +} + +module "autoscaler-monitoring" { + count = var.terraform_dashboard ? 1 : 0 + source = "../../../modules/autoscaler-monitoring" + + region = var.region + project_id = var.project_id + memorystore_cluster_name = var.memorystore_cluster_name +} + +module "autoscaler-scheduler" { + source = "../../../modules/autoscaler-scheduler" + + project_id = var.project_id + location = var.region + memorystore_cluster_name = var.memorystore_cluster_name + state_project_id = var.state_project_id + pubsub_topic = module.autoscaler-forwarder.forwarder_topic + target_pubsub_topic = data.terraform_remote_state.autoscaler.outputs.scaler_topic + + terraform_spanner_state = var.terraform_spanner_state + spanner_state_name = var.spanner_state_name + spanner_state_database = var.spanner_state_database + + // Example of passing config as json + // json_config = base64encode(jsonencode([{ + // "projectId": "${var.project_id}", + // "instanceId": "${module.autoscaler-memorystore-cluster.memorystore_cluster_name}", + // "scalerPubSubTopic": "${module.autoscaler-functions.scaler_topic}", + // "units": "SHARDS", + // "minSize": 3 + // "maxSize": 30, + // "scalingMethod": "LINEAR" + // }])) +} + +module "autoscaler-forwarder" { + source = "../../../modules/autoscaler-forwarder" + + project_id = var.project_id + region = var.region + target_pubsub_topic = data.terraform_remote_state.autoscaler.outputs.poller_topic +} diff --git a/terraform/cloud-functions/distributed/app-project/outputs.tf b/terraform/cloud-functions/distributed/app-project/outputs.tf new file mode 100644 index 0000000..ea96fc8 --- /dev/null +++ b/terraform/cloud-functions/distributed/app-project/outputs.tf @@ -0,0 +1,35 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "scheduler_job_id" { + value = module.autoscaler-scheduler.scheduler_job_id + description = "ID of the Scheduler job" +} + +output "memorystore_discovery_endpoint" { + value = module.autoscaler-memorystore-cluster.memorystore_discovery_endpoint != null ? module.autoscaler-memorystore-cluster.memorystore_discovery_endpoint.address : null + description = "Memorystore discovery endpoint (currently single value)" +} + +output "test_vm_zone" { + value = length(module.autoscaler-test-vm) > 0 ? one(module.autoscaler-test-vm).zone : null + description = "Zone of the test VM" +} + +output "test_vm_name" { + value = length(module.autoscaler-test-vm) > 0 ? one(module.autoscaler-test-vm).instance_name : null + description = "Name of the test VM" +} diff --git a/terraform/cloud-functions/distributed/app-project/variables.tf b/terraform/cloud-functions/distributed/app-project/variables.tf new file mode 100644 index 0000000..7ab8f40 --- /dev/null +++ b/terraform/cloud-functions/distributed/app-project/variables.tf @@ -0,0 +1,101 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "memorystore_cluster_name" { + type = string + default = "autoscaler-target-memorystore-cluster" +} + +variable "memorystore_shard_count" { + type = number + default = 3 +} + +variable "memorystore_replica_count" { + type = number + default = 1 +} + +variable "app_project_id" { + description = "The project where the Memorystore Cluster(s) live. If specified and different than project_id => centralized deployment" + type = string + default = "" +} + +variable "state_project_id" { + type = string +} + +variable "terraform_memorystore_cluster" { + description = "If set to true, Terraform will create a test Memorystore cluster." + type = bool + default = true +} + +variable "terraform_spanner_state" { + description = "If set to true, Terraform will create a Spanner instance for autoscaler state." + type = bool + default = false +} + +variable "spanner_state_name" { + type = string + default = "memorystore-autoscaler-state" +} + +variable "spanner_state_database" { + type = string + default = "memorystore-autoscaler-state" +} + +variable "terraform_test_vm" { + description = "If set to true, Terraform will create a test VM with Memorystore utils installed." + type = bool + default = false +} + +variable "terraform_test_vm_name" { + description = "Name for the optional test VM" + type = string + default = "terraform-test-vm" +} + +variable "terraform_dashboard" { + description = "If set to true, Terraform will create a Cloud Monitoring dashboard including important Memorystore Cluster metrics." + type = bool + default = true +} + +variable "ip_range" { + description = "IP range for the network" + type = string + default = "10.0.0.0/24" +} + +locals { + # By default, these config files produce a per-project deployment + # If you want a centralized deployment instead, then specify + # an app_project_id that is different from project_id + app_project_id = var.app_project_id == "" ? var.project_id : var.app_project_id +} diff --git a/terraform/cloud-functions/distributed/autoscaler-project/.terraform.lock.hcl b/terraform/cloud-functions/distributed/autoscaler-project/.terraform.lock.hcl new file mode 100644 index 0000000..a668404 --- /dev/null +++ b/terraform/cloud-functions/distributed/autoscaler-project/.terraform.lock.hcl @@ -0,0 +1,79 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.6.0" + hashes = [ + "h1:rYAubRk7UHC/fzYqFV/VHc+7VIY01ugCxauyTYCNf9E=", + "zh:29273484f7423b7c5b3f5df34ccfc53e52bb5e3d7f46a81b65908e7a8fd69072", + "zh:3cba58ec3aea5f301caf2acc31e184c55d994cc648126cac39c63ae509a14179", + "zh:55170cd17dbfdea842852c6ae2416d057fec631ba49f3bb6466a7268cd39130e", + "zh:7197db402ba35631930c3a4814520f0ebe980ae3acb7f8b5a6f70ec90dc4a388", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:8bf7fe0915d7fb152a3a6b9162614d2ec82749a06dba13fab3f98d33c020ec4f", + "zh:8ce811844fd53adb0dabc9a541f8cb43aacfa7d8e39324e4bd3592b3428f5bfb", + "zh:bca795bca815b8ac90e3054c0a9ab1ccfb16eedbb3418f8ad473fc5ad6bf0ef7", + "zh:d9355a18df5a36cf19580748b23249de2eb445c231c36a353709f8f40a6c8432", + "zh:dc32cc32cfd8abf8752d34f2a783de0d3f7200c573b885ecb64ece5acea173b4", + "zh:ef498e20391bf7a280d0fd6fd6675621c85fbe4e92f0f517ae4394747db89bde", + "zh:f2bc5226c765b0c8055a7b6207d0fe1eb9484e3ec8880649d158827ac6ed3b22", + ] +} + +provider "registry.terraform.io/hashicorp/google" { + version = "6.4.0" + constraints = ">= 6.1.0" + hashes = [ + "h1:+Xl/dWoAhhZ7GRPZwv7PCpnGa0MFGXyGesd9XxY+GeU=", + "zh:082e343d678da7bc8429c718b0251fc645a76b4d9b96a2cf669de02faa46c721", + "zh:117b781102aef79f63851bcb00e63d999d6b53ca46aac3f992107621c1058e47", + "zh:27bb144de4782ccc718485e033bfc7701ac36a3ee25ec41e4810a777d4fd083d", + "zh:3e0a05de8eb33bebb97947a515ae49760874ce30ff8601c79e8a4a38ca2b2510", + "zh:488777668eb61bdb4d5e949fc1f48a4c07a83f99c749a0b443be4908545bd412", + "zh:56f6a9d817dcb5754f377fae45e0ce0973a4619ee2eb26c8fdb933485ccc89e5", + "zh:5ed4a502834c5596e47969ad9bd646ff8c3c29d8aaaf75dfbd5623a577400a8d", + "zh:a0e971185ea15a62b505ccd8601fd16c1acf2744c51edc5a2cb151690055421c", + "zh:a2bf68d36c9ff401f554292cd4ace96443d1f1fb2dc11f95aa361a62c99dbc03", + "zh:c63f940a43258ba9aa95d7cc99104b12736f5ac76633009a5ad3c39335325a5c", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fa41ab733169e962cd6f26bdcd295823290905e0afba97d68f12a028066b7cf3", + ] +} + +provider "registry.terraform.io/hashicorp/google-beta" { + version = "6.4.0" + hashes = [ + "h1:Iw3ruA/vLW0tWeGjtGYOC7Hv8cpQMcBP4nJggDhEwNQ=", + "zh:1999f091bf66ac63fbe23db2052c17cca92bcfadee1f593facba0606383c8dda", + "zh:26c80110366559ac713e8c94b967e27c0aae22a65f87b837b9e224acf4627b04", + "zh:3c847dd2816e297a8237b4951617d11723ee22645a4e10cf4a4ea20935bd53bb", + "zh:3dfbb433e1bf568f9658858f515ae17df41228a7a258920a5545fb19e46ac976", + "zh:422273df0ed56eaf7bb503d569c14b9df221ff0ac3f1d03400fa39b3becb9d32", + "zh:53353c7fa3a03ae7719da4a6f47a30aed0376d4b357d4987620a588474acd59a", + "zh:5d9e536fecf81f71e8ebb47150996b87f84f2ebdbb81e7eee32d490494fc8472", + "zh:9853c86c6f02517fd80c9fc57d2fd116a79fcd658670de9f1bdeeb7e74c2671d", + "zh:df97f71abdfc756ff9a5640d6d0b44ab7b3ce5ed237fc97bc8d77483f01ebed8", + "zh:e07a339f045c7c816ddc9dbb020796e3e3c956d0097ae98a09bdd7835446933e", + "zh:f07842ce106f8528f5c3e69a1b3128328a1ed4c7f16e84b7683d931ac5bcac00", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.12.1" + hashes = [ + "h1:6BhxSYBJdBBKyuqatOGkuPKVenfx6UmLdiI13Pb3his=", + "zh:090023137df8effe8804e81c65f636dadf8f9d35b79c3afff282d39367ba44b2", + "zh:26f1e458358ba55f6558613f1427dcfa6ae2be5119b722d0b3adb27cd001efea", + "zh:272ccc73a03384b72b964918c7afeb22c2e6be22460d92b150aaf28f29a7d511", + "zh:438b8c74f5ed62fe921bd1078abe628a6675e44912933100ea4fa26863e340e9", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:85c8bd8eefc4afc33445de2ee7fbf33a7807bc34eb3734b8eefa4e98e4cddf38", + "zh:98bbe309c9ff5b2352de6a047e0ec6c7e3764b4ed3dfd370839c4be2fbfff869", + "zh:9c7bf8c56da1b124e0e2f3210a1915e778bab2be924481af684695b52672891e", + "zh:d2200f7f6ab8ecb8373cda796b864ad4867f5c255cff9d3b032f666e4c78f625", + "zh:d8c7926feaddfdc08d5ebb41b03445166df8c125417b28d64712dccd9feef136", + "zh:e2412a192fc340c61b373d6c20c9d805d7d3dee6c720c34db23c2a8ff0abd71b", + "zh:e6ac6bba391afe728a099df344dbd6481425b06d61697522017b8f7a59957d44", + ] +} diff --git a/terraform/cloud-functions/distributed/autoscaler-project/main.tf b/terraform/cloud-functions/distributed/autoscaler-project/main.tf new file mode 100644 index 0000000..60631e2 --- /dev/null +++ b/terraform/cloud-functions/distributed/autoscaler-project/main.tf @@ -0,0 +1,85 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = ">= 6.1.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +provider "google-beta" { + project = var.project_id + region = var.region +} + +resource "google_service_account" "poller_sa" { + account_id = "poller-sa" + display_name = "Memorystore Cluster Autoscaler - Poller SA" +} + +resource "google_service_account" "scaler_sa" { + account_id = "scaler-sa" + display_name = "Memorystore Cluster Autoscaler - Scaler SA" +} + +module "autoscaler-base" { + source = "../../../modules/autoscaler-base" + + project_id = var.project_id + poller_sa_email = google_service_account.poller_sa.email + scaler_sa_email = google_service_account.scaler_sa.email +} + +module "autoscaler-functions" { + source = "../../../modules/autoscaler-functions" + + project_id = var.project_id + region = var.region + poller_sa_email = google_service_account.poller_sa.email + scaler_sa_email = google_service_account.scaler_sa.email + + forwarder_sa_emails = var.forwarder_sa_emails + build_sa_id = module.autoscaler-base.build_sa_id +} + +module "firestore" { + source = "../../../modules/autoscaler-firestore" + + project_id = var.project_id + poller_sa_email = google_service_account.poller_sa.email + scaler_sa_email = google_service_account.scaler_sa.email +} + +module "autoscaler-spanner" { + source = "../../../modules/autoscaler-spanner" + + region = var.region + project_id = var.project_id + terraform_spanner_state = var.terraform_spanner_state + spanner_state_name = var.spanner_state_name + spanner_state_database = var.spanner_state_database + + poller_sa_email = google_service_account.poller_sa.email + scaler_sa_email = google_service_account.scaler_sa.email +} diff --git a/terraform/cloud-functions/distributed/autoscaler-project/outputs.tf b/terraform/cloud-functions/distributed/autoscaler-project/outputs.tf new file mode 100644 index 0000000..a783dfb --- /dev/null +++ b/terraform/cloud-functions/distributed/autoscaler-project/outputs.tf @@ -0,0 +1,31 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "poller_sa_email" { + value = google_service_account.poller_sa.email +} + +output "scaler_sa_email" { + value = google_service_account.scaler_sa.email +} + +output "poller_topic" { + value = module.autoscaler-functions.poller_topic +} + +output "scaler_topic" { + value = module.autoscaler-functions.scaler_topic +} diff --git a/terraform/cloud-functions/distributed/autoscaler-project/variables.tf b/terraform/cloud-functions/distributed/autoscaler-project/variables.tf new file mode 100644 index 0000000..2843cd1 --- /dev/null +++ b/terraform/cloud-functions/distributed/autoscaler-project/variables.tf @@ -0,0 +1,51 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "forwarder_sa_emails" { + type = list(string) + // Example ["serviceAccount:forwarder_sa@app-project.iam.gserviceaccount.com"] + default = [] +} + +variable "terraform_spanner_state" { + description = "If set to true, Terraform will create a Spanner instance for autoscaler state." + type = bool + default = false +} + +variable "spanner_state_name" { + type = string + default = "memorystore-autoscaler-state" +} + +variable "spanner_state_database" { + type = string + default = "memorystore-autoscaler-state" +} + +variable "terraform_dashboard" { + description = "If set to true, Terraform will create a Cloud Monitoring dashboard including important Spanner metrics." + type = bool + default = true +} diff --git a/terraform/cloud-functions/per-project/.terraform.lock.hcl b/terraform/cloud-functions/per-project/.terraform.lock.hcl new file mode 100644 index 0000000..92f6b1b --- /dev/null +++ b/terraform/cloud-functions/per-project/.terraform.lock.hcl @@ -0,0 +1,79 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.6.0" + hashes = [ + "h1:rYAubRk7UHC/fzYqFV/VHc+7VIY01ugCxauyTYCNf9E=", + "zh:29273484f7423b7c5b3f5df34ccfc53e52bb5e3d7f46a81b65908e7a8fd69072", + "zh:3cba58ec3aea5f301caf2acc31e184c55d994cc648126cac39c63ae509a14179", + "zh:55170cd17dbfdea842852c6ae2416d057fec631ba49f3bb6466a7268cd39130e", + "zh:7197db402ba35631930c3a4814520f0ebe980ae3acb7f8b5a6f70ec90dc4a388", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:8bf7fe0915d7fb152a3a6b9162614d2ec82749a06dba13fab3f98d33c020ec4f", + "zh:8ce811844fd53adb0dabc9a541f8cb43aacfa7d8e39324e4bd3592b3428f5bfb", + "zh:bca795bca815b8ac90e3054c0a9ab1ccfb16eedbb3418f8ad473fc5ad6bf0ef7", + "zh:d9355a18df5a36cf19580748b23249de2eb445c231c36a353709f8f40a6c8432", + "zh:dc32cc32cfd8abf8752d34f2a783de0d3f7200c573b885ecb64ece5acea173b4", + "zh:ef498e20391bf7a280d0fd6fd6675621c85fbe4e92f0f517ae4394747db89bde", + "zh:f2bc5226c765b0c8055a7b6207d0fe1eb9484e3ec8880649d158827ac6ed3b22", + ] +} + +provider "registry.terraform.io/hashicorp/google" { + version = "6.4.0" + constraints = ">= 6.1.0" + hashes = [ + "h1:+Xl/dWoAhhZ7GRPZwv7PCpnGa0MFGXyGesd9XxY+GeU=", + "zh:082e343d678da7bc8429c718b0251fc645a76b4d9b96a2cf669de02faa46c721", + "zh:117b781102aef79f63851bcb00e63d999d6b53ca46aac3f992107621c1058e47", + "zh:27bb144de4782ccc718485e033bfc7701ac36a3ee25ec41e4810a777d4fd083d", + "zh:3e0a05de8eb33bebb97947a515ae49760874ce30ff8601c79e8a4a38ca2b2510", + "zh:488777668eb61bdb4d5e949fc1f48a4c07a83f99c749a0b443be4908545bd412", + "zh:56f6a9d817dcb5754f377fae45e0ce0973a4619ee2eb26c8fdb933485ccc89e5", + "zh:5ed4a502834c5596e47969ad9bd646ff8c3c29d8aaaf75dfbd5623a577400a8d", + "zh:a0e971185ea15a62b505ccd8601fd16c1acf2744c51edc5a2cb151690055421c", + "zh:a2bf68d36c9ff401f554292cd4ace96443d1f1fb2dc11f95aa361a62c99dbc03", + "zh:c63f940a43258ba9aa95d7cc99104b12736f5ac76633009a5ad3c39335325a5c", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fa41ab733169e962cd6f26bdcd295823290905e0afba97d68f12a028066b7cf3", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.6.3" + hashes = [ + "h1:Fnaec9vA8sZ8BXVlN3Xn9Jz3zghSETIKg7ch8oXhxno=", + "zh:04ceb65210251339f07cd4611885d242cd4d0c7306e86dda9785396807c00451", + "zh:448f56199f3e99ff75d5c0afacae867ee795e4dfda6cb5f8e3b2a72ec3583dd8", + "zh:4b4c11ccfba7319e901df2dac836b1ae8f12185e37249e8d870ee10bb87a13fe", + "zh:4fa45c44c0de582c2edb8a2e054f55124520c16a39b2dfc0355929063b6395b1", + "zh:588508280501a06259e023b0695f6a18149a3816d259655c424d068982cbdd36", + "zh:737c4d99a87d2a4d1ac0a54a73d2cb62974ccb2edbd234f333abd079a32ebc9e", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a357ab512e5ebc6d1fda1382503109766e21bbfdfaa9ccda43d313c122069b30", + "zh:c51bfb15e7d52cc1a2eaec2a903ac2aff15d162c172b1b4c17675190e8147615", + "zh:e0951ee6fa9df90433728b96381fb867e3db98f66f735e0c3e24f8f16903f0ad", + "zh:e3cdcb4e73740621dabd82ee6a37d6cfce7fee2a03d8074df65086760f5cf556", + "zh:eff58323099f1bd9a0bec7cb04f717e7f1b2774c7d612bf7581797e1622613a0", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.12.1" + hashes = [ + "h1:6BhxSYBJdBBKyuqatOGkuPKVenfx6UmLdiI13Pb3his=", + "zh:090023137df8effe8804e81c65f636dadf8f9d35b79c3afff282d39367ba44b2", + "zh:26f1e458358ba55f6558613f1427dcfa6ae2be5119b722d0b3adb27cd001efea", + "zh:272ccc73a03384b72b964918c7afeb22c2e6be22460d92b150aaf28f29a7d511", + "zh:438b8c74f5ed62fe921bd1078abe628a6675e44912933100ea4fa26863e340e9", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:85c8bd8eefc4afc33445de2ee7fbf33a7807bc34eb3734b8eefa4e98e4cddf38", + "zh:98bbe309c9ff5b2352de6a047e0ec6c7e3764b4ed3dfd370839c4be2fbfff869", + "zh:9c7bf8c56da1b124e0e2f3210a1915e778bab2be924481af684695b52672891e", + "zh:d2200f7f6ab8ecb8373cda796b864ad4867f5c255cff9d3b032f666e4c78f625", + "zh:d8c7926feaddfdc08d5ebb41b03445166df8c125417b28d64712dccd9feef136", + "zh:e2412a192fc340c61b373d6c20c9d805d7d3dee6c720c34db23c2a8ff0abd71b", + "zh:e6ac6bba391afe728a099df344dbd6481425b06d61697522017b8f7a59957d44", + ] +} diff --git a/terraform/cloud-functions/per-project/README.md b/terraform/cloud-functions/per-project/README.md new file mode 100644 index 0000000..a8f0e2b --- /dev/null +++ b/terraform/cloud-functions/per-project/README.md @@ -0,0 +1,393 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Set up the Autoscaler in Cloud Run functions in a per-project + deployment using Terraform +
+ Home + · + Scaler component + · + Poller component + · + Forwarder component + · + Terraform configuration + · + Monitoring +
+ Cloud Run functions + · + Google Kubernetes Engine +
+ Per-Project + · + Centralized + · + Distributed + +

+ +

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Architecture](#architecture) + * [Pros](#pros) + * [Cons](#cons) +* [Before you begin](#before-you-begin) +* [Preparing the Autoscaler Project](#preparing-the-autoscaler-project) + * [Using Firestore for Autoscaler state](#using-firestore-for-autoscaler-state) + * [Using Spanner for Autoscaler state](#using-spanner-for-autoscaler-state) +* [Deploying the Autoscaler](#deploying-the-autoscaler) +* [Connecting to the test VM](#connecting-to-the-test-vm) +* [Importing your Memorystore Cluster instances](#importing-your-memorystore-cluster-instances) +* [Next steps](#next-steps) + +## Overview + +This directory contains Terraform configuration files to quickly set up the +infrastructure for your Autoscaler with a per-project deployment. + +In this deployment option, all the components of the Autoscaler +reside in the same project as your Memorystore Cluster instances. + +This deployment is ideal for independent teams who want to self-manage the +infrastructure and configuration of their own Autoscalers. It is also a good +entry point for testing the Autoscaler capabilities. + +## Architecture + +![architecture-per-project](../../../resources/architecture-per-project.png) + +For an explanation of the components of the Autoscaler and the +interaction flow, please read the +[main Architecture section](../README.md#architecture). + +The per-project deployment has the following pros and cons: + +### Pros + +* **Design**: This option has the simplest design. +* **Configuration**: The control over scheduler parameters belongs to the team + that owns the Memorystore clusters, therefore the team has the highest + degree of freedom to adapt the Autoscaler to its needs. +* **Infrastructure**: This design establishes a clear boundary of + responsibility and security over the Autoscaler infrastructure because the + team that owns the Memorystore clusters is also the owner of the Autoscaler + infrastructure. + +### Cons + +* **Maintenance**: With each team being responsible for the Autoscaler + configuration and infrastructure it may become difficult to make sure that + all Autoscalers across the organization follow the same update guidelines. +* **Audit**: Because of the high level of control by each team, a centralized + audit may become more complex. + +## Before you begin + +In this section you prepare your project for deployment. + +1. Open the [Cloud Console][cloud-console] +2. Activate [Cloud Shell][cloud-shell] \ + At the bottom of the Cloud Console, a + Cloud Shell + session starts and displays a command-line prompt. Cloud Shell is a shell + environment with the Cloud SDK already installed, including the + gcloud command-line tool, and with values already set for your + current project. It can take a few seconds for the session to initialize. + +3. In Cloud Shell, clone this repository: + + ```sh + gcloud source repos clone memorystore-cluster-autoscaler --project=memorystore-oss-preview + ``` + +4. Change into the directory of the cloned repository, and check out the + `main` branch: + + ```sh + cd memorystore-cluster-autoscaler && git checkout main + ``` + +5. Export a variable for the working directory: + + ```sh + export AUTOSCALER_DIR="$(pwd)/terraform/cloud-functions/per-project" + ``` + +## Preparing the Autoscaler Project + +In this section you prepare your project for deployment. + +1. Go to the [project selector page][project-selector] in the Cloud Console. + Select or create a Cloud project. + +2. Make sure that billing is enabled for your Google Cloud project. + [Learn how to confirm billing is enabled for your project][enable-billing]. + +3. In Cloud Shell, set environment variables with the ID of your **autoscaler** + project: + + ```sh + export PROJECT_ID= + gcloud config set project "${PROJECT_ID}" + ``` + +4. Choose the [region][region-and-zone] and + [App Engine Location][app-engine-location] where the Autoscaler + infrastructure will be located. + + ```sh + export REGION=us-central1 + ``` + +5. Enable the required Cloud APIs + + ```sh + gcloud services enable \ + appengine.googleapis.com \ + cloudbuild.googleapis.com \ + cloudfunctions.googleapis.com \ + cloudresourcemanager.googleapis.com \ + cloudscheduler.googleapis.com \ + compute.googleapis.com \ + eventarc.googleapis.com \ + iam.googleapis.com \ + networkconnectivity.googleapis.com \ + pubsub.googleapis.com \ + logging.googleapis.com \ + monitoring.googleapis.com \ + redis.googleapis.com \ + run.googleapis.com \ + serviceconsumermanagement.googleapis.com + ``` + +6. There are two options for deploying the state store for the Autoscaler: + + 1. Store the state in [Firestore][cloud-firestore] + 2. Store the state in [Spanner][cloud-spanner] + + For Firestore, follow the steps in + [Using Firestore for Autoscaler State](#using-firestore-for-autoscaler-state). + For Spanner, follow the steps in [Using Spanner for Autoscaler state](#using-spanner-for-autoscaler-state). + +### Using Firestore for Autoscaler state + +1. To use Firestore for the Autoscaler state, enable the additional APIs: + + ```sh + gcloud services enable firestore.googleapis.com + ``` + +2. Create a Google App Engine app to enable the API for Firestore: + + ```sh + gcloud app create --region="${REGION}" + ``` + +3. To store the state of the Autoscaler, update the database created with the + Google App Engine app to use [Firestore native mode][firestore-native]. + + ```sh + gcloud firestore databases update --type=firestore-native + ``` + +4. Next, continue to [Deploying the Autoscaler](#deploying-the-autoscaler). + +### Using Spanner for Autoscaler state + +1. To use Spanner for the Autoscaler state, enable the additional API: + + ```sh + gcloud services enable spanner.googleapis.com + ``` + +2. If you want Terraform to create a Spanner instance (named + `memorystore-autoscaler-state` by default) to store the state, + set the following variable: + + ```sh + export TF_VAR_terraform_spanner_state=true + ``` + + If you already have a Spanner instance where state must be stored, + set the the name of your instance: + + ```sh + export TF_VAR_spanner_state_name= + ``` + + If you want to manage the state of the Autoscaler in your own + Cloud Spanner instance, please create the following table in advance: + + ```sql + CREATE TABLE memorystoreClusterAutoscaler ( + id STRING(MAX), + lastScalingTimestamp TIMESTAMP, + createdOn TIMESTAMP, + updatedOn TIMESTAMP, + lastScalingCompleteTimestamp TIMESTAMP, + scalingOperationId STRING(MAX), + scalingRequestedSize INT64, + scalingPreviousSize INT64, + scalingMethod STRING(MAX), + ) PRIMARY KEY (id) + ``` + +3. Next, continue to [Deploying the Autoscaler](#deploying-the-autoscaler). + +## Deploying the Autoscaler + +1. Set the project ID and region in the corresponding Terraform + environment variables: + + ```sh + export TF_VAR_project_id="${PROJECT_ID}" + export TF_VAR_region="${REGION}" + ``` + +2. By default, a new Memorystore Cluster instance will be created for testing. + If you want to scale an existing Memorystore Cluster instance, set the + following variable: + + ```sh + export TF_VAR_terraform_memorystore_cluster=false + ``` + + Set the following variable to choose the name of a new or existing cluster + to scale: + + ```sh + export TF_VAR_memorystore_cluster_name= + ``` + + If you do not set this variable, `autoscaler-target-memorystore-cluster` + will be used. + + For more information on how to configure your cluster to be managed by + Terraform, see [Importing your Memorystore Cluster instances](#importing-your-memorystore-cluster-instances). + +3. To create a testbench VM with utilities for testing Memorystore, including + generating load, set the following variable: + + ```sh + export TF_VAR_terraform_test_vm=true + ``` + + Note that this option can only be selected when you have chosen to create a + new Memorystore cluster. + +4. Change directory into the Terraform per-project directory and initialize it. + + ```sh + cd "${AUTOSCALER_DIR}" + terraform init + ``` + +5. Import the existing App Engine application into Terraform state: + + ```sh + terraform import module.autoscaler-scheduler.google_app_engine_application.app "${PROJECT_ID}" + ``` + +6. Create the Autoscaler infrastructure. Answer `yes` when prompted, after + reviewing the resources that Terraform intends to create. + + ```sh + terraform apply -parallelism=2 + ``` + + * If you are running this command in Cloud Shell and encounter errors of + the form "`Error: cannot assign requested address`", this is a [known + issue][provider-issue] in the Terraform Google provider, please retry + the command above and include the flag `-parallelism=1`. + +## Connecting to the Test VM + +To connect to the optionally created test VM, run the following command: + +```sh +function memorystore-testbench-ssh { + export TEST_VM_NAME=$(terraform output -raw test_vm_name) + export TEST_VM_ZONE=$(terraform output -raw test_vm_zone) + export PROJECT_ID=$(gcloud config get-value project) + gcloud compute ssh --zone "${TEST_VM_ZONE}" "${TEST_VM_NAME}" --tunnel-through-iap --project "${PROJECT_ID}" +} +``` + +You can then use `memorystore-testbench-ssh` to SSH to the testbench VM via [IAP][iap]. + +## Importing your Memorystore Cluster instances + +If you have existing Memorystore Cluster instances that you want to +[import to be managed by Terraform][terraform-import], follow the instructions +in this section. + +1. List your Memorystore clusters: + + ```sh + gcloud redis clusters list --format="table(name)" + ``` + +2. Set the following variable with the instance name from the output of the + above command that you want to import + + ```sh + MEMORYSTORE_CLUSTER_NAME= + ``` + +3. Create a Terraform config file with an empty + [`google_redis_cluster`][terraform-redis-cluster] resource: + + ```sh + echo "resource \"google_redis_cluster\" \"${MEMORYSTORE_CLUSTER_NAME}\" {}" > "${MEMORYSTORE_CLUSTER_NAME}.tf" + ``` + +4. [Import][terraform-import-usage] the Memorystore Cluster instance into the Terraform + state. + + ```sh + terraform import "google_redis_cluster.${MEMORYSTORE_CLUSTER_NAME}" "${MEMORYSTORE_CLUSTER_NAME}" + ``` + +5. After the import succeeds, update the Terraform config file for your + instance with the actual instance attributes + + ```sh + # TODO fields to exclude + terraform state show -no-color "google_redis_cluster.${MEMORYSTORE_CLUSTER_NAME}" \ + | grep -vE "(id|state).*(=|\{)" \ + > "${MEMORYSTORE_CLUSTER_NAME}.tf" + ``` + +If you have additional Memorystore clusters to import, repeat this process. + +## Next steps + +Your Autoscaler infrastructure is ready, follow the instructions in the main +page to [configure your Autoscaler](../README.md#configuration). + + + +[app-engine-location]: https://cloud.google.com/appengine/docs/locations +[cloud-console]: https://console.cloud.google.com +[cloud-firestore]: https://cloud.google.com/firestore +[cloud-shell]: https://console.cloud.google.com/?cloudshell=true +[cloud-spanner]: https://cloud.google.com/spanner +[enable-billing]: https://cloud.google.com/billing/docs/how-to/modify-project +[firestore-native]: https://cloud.google.com/datastore/docs/firestore-or-datastore#in_native_mode +[iap]: https://cloud.google.com/security/products/iap +[project-selector]: https://console.cloud.google.com/projectselector2/home/dashboard +[provider-issue]: https://github.com/hashicorp/terraform-provider-google/issues/6782 +[region-and-zone]: https://cloud.google.com/compute/docs/regions-zones#locations +[terraform-import-usage]: https://www.terraform.io/docs/import/usage.html +[terraform-import]: https://www.terraform.io/docs/import/index.html +[terraform-redis-cluster]: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/redis_cluster diff --git a/terraform/cloud-functions/per-project/main.tf b/terraform/cloud-functions/per-project/main.tf new file mode 100644 index 0000000..ceca1ac --- /dev/null +++ b/terraform/cloud-functions/per-project/main.tf @@ -0,0 +1,155 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = ">= 6.1.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} + + +resource "google_service_account" "poller_sa" { + account_id = "poller-sa" + display_name = "Memorystore Cluster Autoscaler - Poller SA" +} + +resource "google_service_account" "scaler_sa" { + account_id = "scaler-sa" + display_name = "Memorystore Cluster Autoscaler - Scaler SA" +} + +module "autoscaler-base" { + source = "../../modules/autoscaler-base" + + project_id = var.project_id + poller_sa_email = google_service_account.poller_sa.email + scaler_sa_email = google_service_account.scaler_sa.email +} + +module "autoscaler-functions" { + source = "../../modules/autoscaler-functions" + + project_id = var.project_id + region = var.region + poller_sa_email = google_service_account.poller_sa.email + scaler_sa_email = google_service_account.scaler_sa.email + build_sa_id = module.autoscaler-base.build_sa_id +} + +module "autoscaler-firestore" { + source = "../../modules/autoscaler-firestore" + + project_id = local.app_project_id + poller_sa_email = google_service_account.poller_sa.email + scaler_sa_email = google_service_account.scaler_sa.email +} + +module "autoscaler-spanner" { + source = "../../modules/autoscaler-spanner" + + region = var.region + project_id = var.project_id + terraform_spanner_state = var.terraform_spanner_state + spanner_state_name = var.spanner_state_name + spanner_state_database = var.spanner_state_database + + poller_sa_email = google_service_account.poller_sa.email + scaler_sa_email = google_service_account.scaler_sa.email +} + +module "autoscaler-scheduler" { + source = "../../modules/autoscaler-scheduler" + + project_id = var.project_id + location = var.region + memorystore_cluster_name = var.memorystore_cluster_name + pubsub_topic = module.autoscaler-functions.poller_topic + target_pubsub_topic = module.autoscaler-functions.scaler_topic + + terraform_spanner_state = var.terraform_spanner_state + spanner_state_name = var.spanner_state_name + spanner_state_database = var.spanner_state_database + + // Example of passing config as json + // json_config = base64encode(jsonencode([{ + // "projectId": "${var.project_id}", + // "instanceId": "${module.autoscaler-memorystore-cluster.memorystore_cluster_name}", + // "scalerPubSubTopic": "${module.autoscaler-functions.scaler_topic}", + // "units": "SHARDS", + // "minSize": 3 + // "maxSize": 30, + // "scalingMethod": "LINEAR" + // }])) +} + +module "autoscaler-network" { + count = var.terraform_memorystore_cluster ? 1 : 0 + source = "../../modules/autoscaler-network" + + region = var.region + project_id = var.project_id + ip_range = var.ip_range +} + +module "autoscaler-memorystore-cluster" { + source = "../../modules/autoscaler-memorystore-cluster" + + region = var.region + project_id = var.project_id + memorystore_cluster_name = var.memorystore_cluster_name + + network = var.terraform_memorystore_cluster ? one(module.autoscaler-network).network : null + subnetwork = var.terraform_memorystore_cluster ? one(module.autoscaler-network).subnetwork : null + dns_zone = var.terraform_memorystore_cluster ? one(module.autoscaler-network).dns_zone : null + + terraform_memorystore_cluster = var.terraform_memorystore_cluster + + poller_sa_email = google_service_account.poller_sa.email + scaler_sa_email = google_service_account.scaler_sa.email + + memorystore_shard_count = var.memorystore_shard_count + memorystore_replica_count = var.memorystore_replica_count + + depends_on = [module.autoscaler-network] +} + +module "autoscaler-test-vm" { + count = var.terraform_test_vm && var.terraform_memorystore_cluster ? 1 : 0 + source = "../../modules/autoscaler-test-vm" + + region = var.region + project_id = var.project_id + name = var.terraform_test_vm_name + network = one(module.autoscaler-network).network + subnetwork = one(module.autoscaler-network).subnetwork +} + +module "autoscaler-monitoring" { + count = var.terraform_dashboard ? 1 : 0 + source = "../../modules/autoscaler-monitoring" + + region = var.region + project_id = var.project_id + memorystore_cluster_name = var.memorystore_cluster_name +} diff --git a/terraform/cloud-functions/per-project/outputs.tf b/terraform/cloud-functions/per-project/outputs.tf new file mode 100644 index 0000000..ea96fc8 --- /dev/null +++ b/terraform/cloud-functions/per-project/outputs.tf @@ -0,0 +1,35 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "scheduler_job_id" { + value = module.autoscaler-scheduler.scheduler_job_id + description = "ID of the Scheduler job" +} + +output "memorystore_discovery_endpoint" { + value = module.autoscaler-memorystore-cluster.memorystore_discovery_endpoint != null ? module.autoscaler-memorystore-cluster.memorystore_discovery_endpoint.address : null + description = "Memorystore discovery endpoint (currently single value)" +} + +output "test_vm_zone" { + value = length(module.autoscaler-test-vm) > 0 ? one(module.autoscaler-test-vm).zone : null + description = "Zone of the test VM" +} + +output "test_vm_name" { + value = length(module.autoscaler-test-vm) > 0 ? one(module.autoscaler-test-vm).instance_name : null + description = "Name of the test VM" +} diff --git a/terraform/cloud-functions/per-project/test/go.mod b/terraform/cloud-functions/per-project/test/go.mod new file mode 100644 index 0000000..569b7df --- /dev/null +++ b/terraform/cloud-functions/per-project/test/go.mod @@ -0,0 +1,112 @@ +module github.com/GoogleCloudPlatform/memorystore-cluster-autoscaler + +go 1.23 + +require ( + cloud.google.com/go/redis v1.17.1 + cloud.google.com/go/scheduler v1.11.1 + github.com/gruntwork-io/terratest v0.47.1 + github.com/sethvargo/go-envconfig v1.1.0 + github.com/stretchr/testify v1.9.0 + google.golang.org/protobuf v1.34.2 +) + +require ( + cloud.google.com/go v0.115.1 // indirect + cloud.google.com/go/auth v0.9.3 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect + cloud.google.com/go/compute/metadata v0.5.0 // indirect + cloud.google.com/go/iam v1.2.0 // indirect + cloud.google.com/go/longrunning v0.6.0 // indirect + cloud.google.com/go/storage v1.43.0 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/aws/aws-sdk-go v1.44.122 // indirect + github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.9.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/go-sql-driver/mysql v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.3 // indirect + github.com/googleapis/gax-go/v2 v2.13.0 // indirect + github.com/gruntwork-io/go-commons v0.8.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-getter v1.7.6 // indirect + github.com/hashicorp/go-multierror v1.1.0 // indirect + github.com/hashicorp/go-safetemp v1.0.0 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/hcl/v2 v2.9.1 // indirect + github.com/hashicorp/terraform-json v0.13.0 // indirect + github.com/imdario/mergo v0.3.11 // indirect + github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.15.11 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/moby/spdystream v0.2.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pquerna/otp v1.2.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/tmccombs/hcl2json v0.3.3 // indirect + github.com/ulikunitz/xz v0.5.10 // indirect + github.com/urfave/cli v1.22.2 // indirect + github.com/zclconf/go-cty v1.9.1 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/oauth2 v0.22.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/term v0.23.0 // indirect + golang.org/x/text v0.17.0 // indirect + golang.org/x/time v0.6.0 // indirect + google.golang.org/api v0.196.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.66.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.28.4 // indirect + k8s.io/apimachinery v0.28.4 // indirect + k8s.io/client-go v0.28.4 // indirect + k8s.io/klog/v2 v2.100.1 // indirect + k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect + k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) diff --git a/terraform/cloud-functions/per-project/test/go.sum b/terraform/cloud-functions/per-project/test/go.sum new file mode 100644 index 0000000..8727915 --- /dev/null +++ b/terraform/cloud-functions/per-project/test/go.sum @@ -0,0 +1,1120 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= +cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= +cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= +cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= +cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= +cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= +cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= +cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= +cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= +cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU= +cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA= +cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ= +cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc= +cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw= +cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY= +cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI= +cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4= +cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4= +cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0= +cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ= +cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk= +cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o= +cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s= +cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0= +cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY= +cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw= +cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI= +cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= +cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= +cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= +cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= +cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= +cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA= +cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY= +cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s= +cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM= +cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI= +cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY= +cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI= +cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= +cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= +cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= +cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= +cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= +cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= +cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU= +cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= +cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= +cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I= +cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4= +cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0= +cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs= +cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc= +cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM= +cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ= +cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo= +cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE= +cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I= +cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ= +cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo= +cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo= +cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ= +cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4= +cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0= +cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8= +cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU= +cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU= +cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y= +cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg= +cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk= +cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w= +cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk= +cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg= +cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM= +cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA= +cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o= +cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A= +cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0= +cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0= +cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc= +cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= +cloud.google.com/go/iam v0.5.0/go.mod h1:wPU9Vt0P4UmCux7mqtRu6jcpPAb74cP1fh50J3QpkUc= +cloud.google.com/go/iam v1.2.0 h1:kZKMKVNk/IsSSc/udOb83K0hL/Yh/Gcqpz+oAkoIFN8= +cloud.google.com/go/iam v1.2.0/go.mod h1:zITGuWgsLZxd8OwAlX+eMFgZDXzBm7icj1PVTYG766Q= +cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic= +cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI= +cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8= +cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08= +cloud.google.com/go/longrunning v0.6.0 h1:mM1ZmaNsQsnb+5n1DNPeL0KwQd9jQRqSqSDEkBZr+aI= +cloud.google.com/go/longrunning v0.6.0/go.mod h1:uHzSZqW89h7/pasCWNYdUpwGz3PcVWhrWupreVPYLts= +cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4= +cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w= +cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE= +cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM= +cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY= +cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s= +cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA= +cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o= +cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ= +cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU= +cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY= +cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34= +cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs= +cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg= +cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E= +cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU= +cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0= +cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA= +cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0= +cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4= +cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o= +cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk= +cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7dd5NwwNQUErSgEDit1DLNTdo= +cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg= +cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4= +cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg= +cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c= +cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y= +cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A= +cloud.google.com/go/redis v1.17.1 h1:E7TeGsvyoFB+m59bqFKrQ5GSH7+uW8cUDk6Y7iqGjJ0= +cloud.google.com/go/redis v1.17.1/go.mod h1:YJHeYfSoW/agIMeCvM5rszxu75mVh5DOhbu3AEZEIQM= +cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4= +cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY= +cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s= +cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI= +cloud.google.com/go/scheduler v1.11.1 h1:uGaM4mRrGkJ0LLBMyxD8qbvIko4y+UlSOwJQqRd/lW8= +cloud.google.com/go/scheduler v1.11.1/go.mod h1:ptS76q0oOS8hCHOH4Fb/y8YunPEN8emaDdtw0D7W1VE= +cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA= +cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= +cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= +cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= +cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU= +cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc= +cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs= +cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg= +cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM= +cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= +cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc= +cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= +cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs= +cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0= +cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw= +cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g= +cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU= +cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4= +cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0= +cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo= +cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo= +cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE= +cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg= +cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0= +cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= +github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo= +github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= +github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= +github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0 h1:skJKxRtNmevLqnayafdLe2AsenqRupVmzZSqrvb5caU= +github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M= +github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= +github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0= +github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= +github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= +github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= +github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= +github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= +github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= +github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= +github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= +github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/gruntwork-io/go-commons v0.8.0 h1:k/yypwrPqSeYHevLlEDmvmgQzcyTwrlZGRaxEM6G0ro= +github.com/gruntwork-io/go-commons v0.8.0/go.mod h1:gtp0yTtIBExIZp7vyIV9I0XQkVwiQZze678hvDXof78= +github.com/gruntwork-io/terratest v0.47.1 h1:qOaxnL7Su5+KpDHYUN/ek1jn8ImvCKtOkaY4OSMS4tI= +github.com/gruntwork-io/terratest v0.47.1/go.mod h1:LnYX8BN5WxUMpDr8rtD39oToSL4CBERWSCusbJ0d/64= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-getter v1.7.6 h1:5jHuM+aH373XNtXl9TNTUH5Qd69Trve11tHIrB+6yj4= +github.com/hashicorp/go-getter v1.7.6/go.mod h1:W7TalhMmbPmsSMdNjD0ZskARur/9GJ17cfHTRtXV744= +github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= +github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= +github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl/v2 v2.9.1 h1:eOy4gREY0/ZQHNItlfuEZqtcQbXIxzojlP301hDpnac= +github.com/hashicorp/hcl/v2 v2.9.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= +github.com/hashicorp/terraform-json v0.13.0 h1:Li9L+lKD1FO5RVFRM1mMMIBDoUHslOniyEi5CM+FWGY= +github.com/hashicorp/terraform-json v0.13.0/go.mod h1:y5OdLBCT+rxbwnpxZs9kGL7R9ExU76+cpdY8zHwoazk= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a h1:zPPuIq2jAWWPTrGt70eK/BSch+gFAGrNzecsoENgu2o= +github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.15.11 h1:Lcadnb3RKGin4FYM/orgq0qde+nc15E5Cbqg4B9Sx9c= +github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= +github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 h1:ofNAzWCcyTALn2Zv40+8XitdzCgXY6e9qvXwN9W0YXg= +github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.9.4 h1:xR7vG4IXt5RWx6FfIjyAtsoMAtnc3C/rFXBBd2AjZwE= +github.com/onsi/ginkgo/v2 v2.9.4/go.mod h1:gCQYp2Q+kSoIj7ykSVb9nskRSsR6PUj4AiLywzIhbKM= +github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok= +github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= +github.com/sethvargo/go-envconfig v1.1.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tmccombs/hcl2json v0.3.3 h1:+DLNYqpWE0CsOQiEZu+OZm5ZBImake3wtITYxQ8uLFQ= +github.com/tmccombs/hcl2json v0.3.3/go.mod h1:Y2chtz2x9bAeRTvSibVRVgbLJhLJXKlUeIvjeVdnm4w= +github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= +github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/urfave/cli v1.22.2 h1:gsqYFH8bb9ekPA12kRo0hfjngWQjkJPlN9R0N78BoUo= +github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= +github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= +github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= +github.com/zclconf/go-cty v1.8.1/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= +github.com/zclconf/go-cty v1.9.1 h1:viqrgQwFl5UpSxc046qblj78wZXVDFnSOufaOTER+cc= +github.com/zclconf/go-cty v1.9.1/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= +go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= +google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= +google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= +google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= +google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= +google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= +google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= +google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= +google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= +google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= +google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= +google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= +google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= +google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= +google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= +google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g= +google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= +google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= +google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI= +google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70= +google.golang.org/api v0.196.0 h1:k/RafYqebaIJBO3+SMnfEGtFVlvp5vSgqTUF54UN/zg= +google.golang.org/api v0.196.0/go.mod h1:g9IL21uGkYgvQ5BZg6BAtoGJQIm8r6EgaAbpNey5wBE= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= +google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= +google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE= +google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc= +google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw= +google.golang.org/genproto v0.0.0-20220926165614-551eb538f295/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= +google.golang.org/genproto v0.0.0-20220926220553-6981cbe3cfce/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= +google.golang.org/genproto v0.0.0-20221010155953-15ba04fc1c0e/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U= +google.golang.org/genproto v0.0.0-20221014173430-6e2ab493f96b/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= +google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= +google.golang.org/genproto v0.0.0-20221025140454-527a21cfbd71/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= +google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU= +google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c= +google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +k8s.io/api v0.28.4 h1:8ZBrLjwosLl/NYgv1P7EQLqoO8MGQApnbgH8tu3BMzY= +k8s.io/api v0.28.4/go.mod h1:axWTGrY88s/5YE+JSt4uUi6NMM+gur1en2REMR7IRj0= +k8s.io/apimachinery v0.28.4 h1:zOSJe1mc+GxuMnFzD4Z/U1wst50X28ZNsn5bhgIIao8= +k8s.io/apimachinery v0.28.4/go.mod h1:wI37ncBvfAoswfq626yPTe6Bz1c22L7uaJ8dho83mgg= +k8s.io/client-go v0.28.4 h1:Np5ocjlZcTrkyRJ3+T3PkXDpe4UpatQxj85+xjaD2wY= +k8s.io/client-go v0.28.4/go.mod h1:0VDZFpgoZfelyP5Wqu0/r/TRYcLYuJ2U1KEeoaPa1N4= +k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= +k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 h1:LyMgNKD2P8Wn1iAwQU5OhxCKlKJy0sHc+PcDwFB24dQ= +k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9/go.mod h1:wZK2AVp1uHCp4VamDVgBP2COHZjqD1T68Rf0CM3YjSM= +k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk= +k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/terraform/cloud-functions/per-project/test/per_project_e2e_test.go b/terraform/cloud-functions/per-project/test/per_project_e2e_test.go new file mode 100644 index 0000000..78abdea --- /dev/null +++ b/terraform/cloud-functions/per-project/test/per_project_e2e_test.go @@ -0,0 +1,205 @@ +//go:build e2e + +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + cluster "cloud.google.com/go/redis/cluster/apiv1" + clusterpb "cloud.google.com/go/redis/cluster/apiv1/clusterpb" + scheduler "cloud.google.com/go/scheduler/apiv1beta1" + schedulerpb "cloud.google.com/go/scheduler/apiv1beta1/schedulerpb" + fieldmaskpb "google.golang.org/protobuf/types/known/fieldmaskpb" + + logger "github.com/gruntwork-io/terratest/modules/logger" + retry "github.com/gruntwork-io/terratest/modules/retry" + terraform "github.com/gruntwork-io/terratest/modules/terraform" + test_structure "github.com/gruntwork-io/terratest/modules/test-structure" + + envconfig "github.com/sethvargo/go-envconfig" + assert "github.com/stretchr/testify/assert" +) + +type TestConfig struct { + ProjectId string `env:"PROJECT_ID,required"` +} + +func setAutoscalerConfigMinShards(t *testing.T, schedulerClient *scheduler.CloudSchedulerClient, schedulerJobId string, units int) error { + + ctx := context.Background() + schedulerJobReq := &schedulerpb.GetJobRequest{ + Name: schedulerJobId, + } + assert.NotNil(t, schedulerJobReq) + schedulerJob, err := schedulerClient.GetJob(ctx, schedulerJobReq) + assert.Nil(t, err) + assert.NotNil(t, schedulerJob) + + var schedulerJobBody []map[string]any + schedulerJobBodyRaw := string(schedulerJob.GetPubsubTarget().GetData()) + err = json.Unmarshal([]byte(schedulerJobBodyRaw), &schedulerJobBody) + if err != nil { + logger.Log(t, err) + t.Fatal() + } + + schedulerJobBody[0]["minSize"] = units + schedulerJobBodyUpdate, err := json.Marshal(schedulerJobBody) + if err != nil { + logger.Log(t, err) + t.Fatal() + } + + updateJobRequest := &schedulerpb.UpdateJobRequest{ + Job: &schedulerpb.Job{ + Name: schedulerJob.Name, + Target: &schedulerpb.Job_PubsubTarget{ + PubsubTarget: &schedulerpb.PubsubTarget{ + Data: []byte(schedulerJobBodyUpdate), + }, + }, + }, + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"pubsub_target.data"}, + }, + } + + _, err = schedulerClient.UpdateJob(ctx, updateJobRequest) + if err != nil { + logger.Log(t, err) + return err + } + return nil +} + +func waitForMemorystoreClusterShards(t *testing.T, clusterClient *cluster.CloudRedisClusterClient, clusterId string, targetShards int32, retries int, sleepBetweenRetries time.Duration) error { + + ctx := context.Background() + maxWaitTime := time.Duration(retries) * sleepBetweenRetries + status := fmt.Sprintf("Waiting for up to %.0f seconds for cluster to reach %d shards...", maxWaitTime.Seconds(), targetShards) + + message, err := retry.DoWithRetryE( + t, + status, + retries, + sleepBetweenRetries, + func() (string, error) { + clusterReq := &clusterpb.GetClusterRequest{ + Name: clusterId, + } + cluster, err := clusterClient.GetCluster(ctx, clusterReq) + assert.Nil(t, err) + assert.NotNil(t, cluster) + shards := cluster.GetShardCount() + if shards != targetShards { + return "", fmt.Errorf("currently %d shards", shards) + } + return "Memorystore cluster reached target size", nil + }, + ) + logger.Log(t, message) + return err +} + +func TestPerProjectEndToEndDeployment(t *testing.T) { + + const ( + schedulerJobTfOutput = "scheduler_job_id" + clusterName = "autoscaler-test" + clusterRegion = "us-central1" + clusterStartingShards = 3 + clusterTargetShards = 5 // Skip invalid 4-shard cluster shape + ) + + var config TestConfig + + ctx := context.Background() + err := envconfig.Process(ctx, &config) + if err != nil { + logger.Log(t, "There was an error processing the supplied environment variables:") + logger.Log(t, err) + t.Fatal() + } + + terraformDir := "../" + + test_structure.RunTestStage(t, "setup", func() { + terraformOptions := &terraform.Options{ + TerraformDir: terraformDir, + Vars: map[string]interface{}{ + "project_id": config.ProjectId, + "region": clusterRegion, + "memorystore_cluster_name": clusterName, + "memorystore_shard_count": clusterStartingShards, + }, + } + + test_structure.SaveTerraformOptions(t, terraformDir, terraformOptions) + terraform.Init(t, terraformOptions) + }) + + defer test_structure.RunTestStage(t, "teardown", func() { + terraformOptions := test_structure.LoadTerraformOptions(t, terraformDir) + terraform.Destroy(t, terraformOptions) + }) + + test_structure.RunTestStage(t, "import", func() { + terraformOptions := test_structure.LoadTerraformOptions(t, terraformDir) + terraformArgs := []string{"module.autoscaler-scheduler.google_app_engine_application.app", config.ProjectId} + terraformArgsFormatted := append(terraform.FormatArgs(terraformOptions, "-input=false"), terraformArgs...) + terraformCommand := append([]string{"import"}, terraformArgsFormatted...) + terraform.RunTerraformCommand(t, terraformOptions, terraformCommand...) + }) + + test_structure.RunTestStage(t, "apply", func() { + terraformOptions := test_structure.LoadTerraformOptions(t, terraformDir) + terraform.ApplyAndIdempotent(t, terraformOptions) + }) + + test_structure.RunTestStage(t, "validate", func() { + + // Retries and sleep duration for a total maximum of 15 minutes timeout per operation + const retries = 30 + const sleepBetweenRetries = time.Second * 30 + + terraformOptions := test_structure.LoadTerraformOptions(t, terraformDir) + ctx := context.Background() + + clusterClient, err := cluster.NewCloudRedisClusterClient(ctx) + assert.Nil(t, err) + assert.NotNil(t, clusterClient) + defer clusterClient.Close() + + schedulerJobId := terraform.Output(t, terraformOptions, schedulerJobTfOutput) + schedulerClient, err := scheduler.NewCloudSchedulerClient(ctx) + assert.Nil(t, err) + assert.NotNil(t, schedulerClient) + defer schedulerClient.Close() + + clusterId := fmt.Sprintf("projects/%s/locations/%s/clusters/%s", config.ProjectId, clusterRegion, clusterName) + + assert.Nil(t, waitForMemorystoreClusterShards(t, clusterClient, clusterId, clusterStartingShards, retries, sleepBetweenRetries)) + assert.Nil(t, setAutoscalerConfigMinShards(t, schedulerClient, schedulerJobId, clusterTargetShards)) + assert.Nil(t, waitForMemorystoreClusterShards(t, clusterClient, clusterId, clusterTargetShards, retries, sleepBetweenRetries)) + }) +} diff --git a/terraform/cloud-functions/per-project/variables.tf b/terraform/cloud-functions/per-project/variables.tf new file mode 100644 index 0000000..c1a7185 --- /dev/null +++ b/terraform/cloud-functions/per-project/variables.tf @@ -0,0 +1,97 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "memorystore_cluster_name" { + type = string + default = "autoscaler-target-memorystore-cluster" +} + +variable "memorystore_shard_count" { + type = number + default = 3 +} + +variable "memorystore_replica_count" { + type = number + default = 1 +} + +variable "app_project_id" { + description = "The project where the Memorystore Cluster(s) live. If specified and different than project_id => centralized deployment" + type = string + default = "" +} + +variable "terraform_memorystore_cluster" { + description = "If set to true, Terraform will create a test Memorystore cluster." + type = bool + default = true +} + +variable "terraform_spanner_state" { + description = "If set to true, Terraform will create a Spanner instance for autoscaler state." + type = bool + default = false +} + +variable "spanner_state_name" { + type = string + default = "memorystore-autoscaler-state" +} + +variable "spanner_state_database" { + type = string + default = "memorystore-autoscaler-state" +} + +variable "terraform_test_vm" { + description = "If set to true, Terraform will create a test VM with Memorystore utils installed." + type = bool + default = false +} + +variable "terraform_test_vm_name" { + description = "Name for the optional test VM" + type = string + default = "terraform-test-vm" +} + +variable "terraform_dashboard" { + description = "If set to true, Terraform will create a Cloud Monitoring dashboard including important Memorystore Cluster metrics." + type = bool + default = true +} + +variable "ip_range" { + description = "IP range for the network" + type = string + default = "10.0.0.0/24" +} + +locals { + # By default, these config files produce a per-project deployment + # If you want a centralized deployment instead, then specify + # an app_project_id that is different from project_id + app_project_id = var.app_project_id == "" ? var.project_id : var.app_project_id +} diff --git a/terraform/gke/README.md b/terraform/gke/README.md new file mode 100644 index 0000000..dfea36b --- /dev/null +++ b/terraform/gke/README.md @@ -0,0 +1,698 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Set up the Autoscaler using Terraform configuration files +
+ Home + · + Scaler component + · + Poller component + · + Forwarder component + · + Terraform configuration + · + Monitoring +
+ Cloud Run functions + · + Google Kubernetes Engine +

+ +

+ +## Table of Contents + +* [Table of Contents](#table-of-contents) +* [Overview](#overview) +* [Architecture](#architecture) + * [Pros](#pros) + * [Cons](#cons) +* [Before you begin](#before-you-begin) +* [Preparing the Autoscaler Project](#preparing-the-autoscaler-project) + * [Using Firestore for Autoscaler state](#using-firestore-for-autoscaler-state) + * [Using Spanner for Autoscaler state](#using-spanner-for-autoscaler-state) +* [Creating Autoscaler infrastructure](#creating-autoscaler-infrastructure) +* [Building the Autoscaler](#building-the-autoscaler) +* [Deploying the Autoscaler](#deploying-the-autoscaler) +* [Connecting to the test VM](#connecting-to-the-test-vm) +* [Metrics in GKE deployment](#metrics-in-gke-deployment) +* [Importing your Memorystore Cluster instances](#importing-your-memorystore-cluster-instances) +* [Troubleshooting](#troubleshooting) + +## Overview + +This directory contains Terraform configuration files to quickly set up the +infrastructure for your Autoscaler for a deployment to +[Google Kubernetes Engine (GKE)][gke]. + +This deployment is ideal for independent teams who want to self-manage the +infrastructure and configuration of their own Autoscalers on Kubernetes. + +## Architecture + +![architecture-gke-unified][architecture-gke-unified] + +1. Using a [Kubernetes ConfigMap][kubernetes-configmap] you define which + Memorystore Cluster instances you would like to be managed by + the autoscaler. + +2. Using a [Kubernetes CronJob][kubernetes-cronjob], the autoscaler is + configured to run on a schedule. By default this is every two minutes, + though this is configurable. + +3. When scheduled, an instance of the [Poller][autoscaler-poller] + is created as a [Kubernetes Job][kubernetes-job]. + +4. The Poller queries the [Cloud Monitoring][cloud-monitoring] API to retrieve + the utilization metrics for each Memorystore Cluster instance. + +5. For each Spanner instance, the Poller makes a call to the Scaler via its + API. The request payload contains the utilization metrics for the specific + Memorystore Cluster instance, and some of its corresponding + configuration parameters. + +6. Using the chosen [scaling method][scaling-methods] the Scaler compares + the Memorystore Cluster instance metrics against the recommended + thresholds, plus or minus an [allowed margin][margins] and determines + if the instance should be scaled, and the number of shards/nodes that it + should be scaled to. + +7. The Scaler retrieves the time when the instance was last scaled from the + state data stored in [Cloud Firestore][cloud-firestore] (or alternatively + [Spanner][spanner]) and compares it with the current time. + +8. If the configured cooldown period has passed, then the Scaler requests the + Memorystore Cluster instance to scale out or in. + +9. Autoscaler components publish counters to an [OpenTelemetry Collector][otel-collector], + also running in Kubernetes, which is configured to forward these counters to + [Google Cloud Monitoring][gcm-docs]. See section + [Metrics in GKE deployment](#metrics-in-gke-deployment) + +The GKE deployment has the following pros and cons: + +### Pros + +* **Kubernetes-based**: For teams that may not be able to use Google Cloud + services such as [Cloud Run functions][cloud-functions], this design enables + the use of the Autoscaler. +* **Configuration**: The control over scheduler parameters belongs to the team + that owns the Memorystore Cluster instance, therefore the team has the + highest degree of freedom to adapt the Autoscaler to its needs. +* **Infrastructure**: This design establishes a clear boundary of + responsibility and security over the Autoscaler infrastructure because the + Autoscaler infrastructure. + +### Cons + +* **Infrastructure**: In contrast to the [Cloud Run functions][cloud-functions] + design, some long-lived infrastructure and services are required. +* **Maintenance**: with each team being responsible for the Autoscaler + configuration and infrastructure it may become difficult to make sure that + all Autoscalers across the company follow the same update guidelines. +* **Audit**: because of the high level of control by each team, a centralized + audit may become more complex. + +The Poller and Scaler components are be deployed as a single pod, which runs +as a Kubernetes cron job. This means there are no long-running components. + +## Before you begin + +In this section you prepare your environment. + +1. Open the [Cloud Console][cloud-console] +2. Activate [Cloud Shell][cloud-shell] \ + At the bottom of the Cloud Console, a [Cloud Shell][cloud-shell] + session starts and displays a command-line prompt. Cloud Shell is a shell + environment with the Cloud SDK already installed, including the + gcloud command-line tool, and with values already set for your + current project. It can take a few seconds for the session to initialize. + +3. In Cloud Shell, clone this repository: + + ```sh + gcloud source repos clone memorystore-cluster-autoscaler --project=memorystore-oss-preview + ``` + +4. Change into the directory of the cloned repository, and check out the + `main` branch: + + ```sh + cd memorystore-cluster-autoscaler && git checkout main + ``` + +5. Export a variable for the Autoscaler working directory: + + ```sh + export AUTOSCALER_ROOT="$(pwd)" + ``` + +## Preparing the Autoscaler Project + +In this section you prepare your project for deployment. + +1. Go to the [project selector page][project-selector] in the Cloud Console. + Select or create a Cloud project. + +2. Make sure that billing is enabled for your Google Cloud project. + [Learn how to confirm billing is enabled for your project][enable-billing]. + +3. In Cloud Shell, configure the environment with the ID of your + **autoscaler** project: + + ```sh + export PROJECT_ID= + gcloud config set project ${PROJECT_ID} + ``` + +4. Set the region where the Autoscaler resources will be created: + + ```sh + export REGION=us-central1 + ``` + +5. Enable the required Cloud APIs: + + ```sh + gcloud services enable \ + artifactregistry.googleapis.com \ + cloudbuild.googleapis.com \ + cloudresourcemanager.googleapis.com \ + compute.googleapis.com \ + container.googleapis.com \ + iam.googleapis.com \ + networkconnectivity.googleapis.com \ + pubsub.googleapis.com \ + logging.googleapis.com \ + monitoring.googleapis.com \ + redis.googleapis.com \ + serviceconsumermanagement.googleapis.com + ``` + +6. There are two options for deploying the state store for the Autoscaler: + + 1. Store the state in [Firestore][cloud-firestore] + 2. Store the state in [Spanner][spanner] + + For Firestore, follow the steps in + [Using Firestore for Autoscaler State](#using-firestore-for-autoscaler-state). + For Spanner, follow the steps in [Using Spanner for Autoscaler state](#using-spanner-for-autoscaler-state). + +### Using Firestore for Autoscaler state + +1. To use Firestore for the Autoscaler state, enable the additional APIs: + + ```sh + gcloud services enable \ + appengine.googleapis.com \ + firestore.googleapis.com + ``` + +2. Create a Google App Engine app to enable the API for Firestore: + + ```sh + gcloud app create --region="${REGION}" + ``` + +3. To store the state of the Autoscaler, update the database created with the + Google App Engine app to use [Firestore native mode][firestore-native]. + + ```sh + gcloud firestore databases update --type=firestore-native + ``` + + You will also need to make a minor modification to the Autoscaler + configuration. The required steps to do this are later in these + instructions. + +4. Next, continue to [Creating Autoscaler infrastructure](#creating-autoscaler-infrastructure). + +### Using Spanner for Autoscaler state + +1. To use Spanner for the Autoscaler state, enable the additional API: + + ```sh + gcloud services enable spanner.googleapis.com + ``` + +2. If you want Terraform to create a Spanner instance (named + `memorystore-autoscaler-state` by default) to store the state, + set the following variable: + + ```sh + export TF_VAR_terraform_spanner_state=true + ``` + + If you already have a Spanner instance where state must be stored, + set the the name of your instance: + + ```sh + export TF_VAR_spanner_state_name= + ``` + + If you want to manage the state of the Autoscaler in your own + Cloud Spanner instance, please create the following table in advance: + + ```sql + CREATE TABLE memorystoreClusterAutoscaler ( + id STRING(MAX), + lastScalingTimestamp TIMESTAMP, + createdOn TIMESTAMP, + updatedOn TIMESTAMP, + lastScalingCompleteTimestamp TIMESTAMP, + scalingOperationId STRING(MAX), + scalingRequestedSize INT64, + scalingPreviousSize INT64, + scalingMethod STRING(MAX), + ) PRIMARY KEY (id) + ``` + +3. Next, continue to [Creating Autoscaler infrastructure](#creating-autoscaler-infrastructure). + +## Creating Autoscaler infrastructure + +In this section you deploy the Autoscaler infrastructure. + +1. Set the project ID and region in the corresponding Terraform + environment variables: + + ```sh + export TF_VAR_project_id=${PROJECT_ID} + export TF_VAR_region=${REGION} + ``` + +2. By default, a new Memorystore Cluster instance will be created for testing. + If you want to scale an existing Memorystore Cluster instance, set the + following variable: + + ```sh + export TF_VAR_terraform_memorystore_cluster=false + ``` + + Set the following variable to choose the name of a new or existing cluster + to scale: + + ```sh + export TF_VAR_memorystore_cluster_name= + ``` + + If you do not set this variable, `autoscaler-target-memorystore-cluster` + will be used. + + For more information on how to configure your cluster to be managed by + Terraform, see [Importing your Memorystore Cluster instances](#importing-your-memorystore-cluster-instances). + +3. To create a testbench VM with utilities for testing Memorystore, including + generating load, set the following variable: + + ```sh + export TF_VAR_terraform_test_vm=true + ``` + + Note that this option can only be selected when you have chosen to create a + new Memorystore cluster. + +4. Change directory into the Terraform directory and initialize it: + + ```sh + cd "${AUTOSCALER_ROOT}/terraform/gke/unified" + terraform init + ``` + +5. Create the Autoscaler infrastructure. Answer `yes` when prompted, after + reviewing the resources that Terraform intends to create. + + ```sh + terraform apply + ``` + +If you are running this command in Cloud Shell and encounter errors of the form +"`Error: cannot assign requested address`", this is a +[known issue][provider-issue] in the Terraform Google provider, please retry +with `-parallelism=1`. + +## Building the Autoscaler + +1. Change to the directory that contains the Autoscaler source code: + + ```sh + cd ${AUTOSCALER_ROOT} + ``` + +2. Build the Autoscaler: + + ```sh + gcloud beta builds submit . --config=cloudbuild-unified.yaml --region=${REGION} --service-account="projects/${PROJECT_ID}/serviceAccounts/build-sa@${PROJECT_ID}.iam.gserviceaccount.com" + ``` + +3. Construct the path to the image: + + ```sh + SCALER_PATH="${REGION}-docker.pkg.dev/${PROJECT_ID}/memorystore-cluster-autoscaler/scaler" + ``` + +4. Retrieve the SHA256 hash of the image: + + ```sh + SCALER_SHA=$(gcloud artifacts docker images describe ${SCALER_PATH}:latest --format='value(image_summary.digest)') + ``` + +5. Construct the full path to the image, including the SHA256 hash: + + ```sh + SCALER_IMAGE="${SCALER_PATH}@${SCALER_SHA}" + ``` + +## Deploying the Autoscaler + +1. Retrieve the credentials for the cluster where the Autoscaler will be deployed: + + ```sh + gcloud container clusters get-credentials memorystore-cluster-autoscaler --region=${REGION} + ``` + +2. Prepare the Autoscaler YAML configuration files from their templates by + running the following command: + + ```sh + cd ${AUTOSCALER_ROOT}/kubernetes/unified && \ + for template in $(ls autoscaler-config/*.template) ; do envsubst < ${template} > ${template%.*} ; done + ``` + +3. Deploy the `otel-collector` service so that it is ready to collect metrics: + + ```sh + cd ${AUTOSCALER_ROOT}/kubernetes/unified && \ + kubectl apply -f autoscaler-config/otel-collector.yaml && \ + kubectl apply -f autoscaler-pkg/networkpolicy.yaml && \ + kubectl apply -f autoscaler-pkg/otel-collector/otel-collector.yaml + ``` + +4. Next configure the Kubernetes manifests and deploy the Autoscaler to + the cluster using the following commands: + + ```sh + cd ${AUTOSCALER_ROOT}/kubernetes/unified && \ + kpt fn eval --image gcr.io/kpt-fn/apply-setters:v0.1.1 autoscaler-pkg -- \ + scaler_image=${SCALER_IMAGE} && kubectl apply -f autoscaler-pkg/ --recursive + ``` + +5. Next, to see how the Autoscaler is configured, run the following command to + output the example configuration: + + ```sh + cat autoscaler-config/autoscaler-config.yaml + ``` + + This file configures the instance of the autoscaler that you + scheduled in the previous step. + + You can autoscale multiple Spanner instances on a single schedule by + including multiple YAML stanzas in any of the scheduled configurations. For + the schema of the configuration, see the + [Poller configuration][autoscaler-config-params] section. + +6. If you have chosen to use Firestore to hold the Autoscaler state as described + above, edit the above file, and remove the following lines: + + ```yaml + stateDatabase: + name: spanner + instanceId: memorystore-autoscaler-state + databaseId: memorystore-autoscaler-state + ``` + + **Note:** If you do not remove these lines, the Autoscaler will attempt to + use the above non-existent Spanner database for its state store, which will + result in errors. Please see the [Troubleshooting](#troubleshooting) + section for more details. + + If you have chosen to use your own Spanner instance, please edit the above + configuration file accordingly. + +7. To configure the Autoscaler and begin scaling operations, run the following + command: + + ```sh + kubectl apply -f autoscaler-config/ + ``` + +8. Any changes made to the configuration files and applied with `kubectl + apply` will update the Autoscaler configuration. + +9. You can view logs for the Autoscaler components via `kubectl` or the [Cloud + Logging][cloud-console-logging] interface in the Google Cloud console. + +## Connecting to the Test VM + +To connect to the optionally created test VM, run the following command: + +```sh +function memorystore-testbench-ssh { + export TEST_VM_NAME=$(terraform output -raw test_vm_name) + export TEST_VM_ZONE=$(terraform output -raw test_vm_zone) + export PROJECT_ID=$(gcloud config get-value project) + gcloud compute ssh --zone "${TEST_VM_ZONE}" "${TEST_VM_NAME}" --tunnel-through-iap --project "${PROJECT_ID}" +} +``` + +## Configuration + +After deploying the Autoscaler, you can configure its parameters by +editing the [Kubernetes ConfigMap][kubernetes-configmap]. + +The configuration is defined as a YAML array. Each element in the array +represents a Memorystore Cluster instance that will managed by the Autoscaler +cron job that specifies this ConfigMap. + +You can create multiple autoscaler cron jobs specifying different ConfigMaps. +This is useful for example if you want to have an instance configured with the +linear method for normal operations, but also have another Autoscaler +configuration with the direct method for planned batch workloads. + +You can find the details about the configuration parameters and their default +values in the [Poller component page][autoscaler-configuration]. + +Modifying the ConfigMap can either be done by editing it in the Cloud Console, +or by modifying the YAML source file and applying the changes using the +`kubectl` tool: + +### Using Cloud Console + +1. Open the + [Kubernetes Secrets and ConfigMaps page](https://console.cloud.google.com/kubernetes/config) + in the Cloud Console. + +2. Select the `autoscaler-config` Config Map. + +3. Click **Edit** on the top bar. + +4. The configuration is stored in the `data/autoscaler-config.yaml` literal + block scaler value as a YAML array. + + Each element in the array represents the configuration parameters for a + Memorystore Cluster instance that will be managed by the Kubernetes cron job + referencing this ConfigMap. + +5. Modify the configuration and click **Save** + + The ConfigMap is updated and the new configuration will be used next time the + job runs. + +### Using the ConfigMap definition file + +1. Go to the directory specifying the Autoscaler Kubernetes configuration: + + ```shell + cd ${AUTOSCALER_ROOT}/kubernetes/unified + ``` + +2. Edit the file `autoscaler-config/autoscaler-config.yaml` + +3. The configuration is stored in the `data/autoscaler-config.yaml` literal + block scaler value as a YAML array. + + Each element in the array represents a Memorystore Cluster instance that will + be managed by the Kubernetes cron job referencing this ConfigMap. + +4. Modify and save this file. + +5. Apply the changes to the ConfigMap using the command: + + ```shell + kubectl apply -f autoscaler-config/autoscaler-config.yaml + ``` + + The ConfigMap is updated and the configuration will be used next time the + job runs. + +## Metrics in GKE deployment + +Unlike in a Cloud Run functions deployment, in a GKE deployment, the counters +generated by the `poller` and `scaler` components are forwarded to the +[OpenTelemetry Collector (`otel-collector`)][otel-collector] service. +This service is specified by an the environmental variable `OTEL_COLLECTOR_URL` +passed to the poller and scaler workloads. + +This collector is run as a [service](../../kubernetes/unified/autoscaler-pkg/otel-collector/otel-collector.yaml) +to receive metrics as gRPC messages on port 4317, then export them to Google +Cloud Monitoring. This configuration is defined in a +[ConfigMap](../../kubernetes/unified/autoscaler-config/otel-collector.yaml.template). + +Metrics can be sent to other exporters by modifying the Collector ConfigMap. + +A [NetworkPolicy rule](../../kubernetes/unified/autoscaler-pkg/networkpolicy.yaml) +is also configured to allow traffic from the Autoscaler workloads +(labelled with `otel-submitter:true`) to the `otel-collector` service. + +If the environment variable `OTEL_COLLECTOR_URL` is not specified, the metrics +will be sent directly to Google Cloud Monitoring. + +To allow Google Cloud Monitoring to distinguish metrics from different instances +of the Autosaler components, the Kubernetes Pod name is passed to the poller and +scaler componnents via the environmental variable `K8S_POD_NAME`. If this +variable is not specified, and if the Pod name attribute is not appended to the +metrics by configuring the +[Kubernetes Attributes Processor](https://opentelemetry.io/docs/kubernetes/collector/components/#kubernetes-attributes-processor) +in the OpenTelemetry Collector, then there will be Send TimeSeries errors +reported when the Collector exports the metrics to Gogole Cloud Monitoring. + +## Importing your Memorystore Cluster instances + +If you have existing Memorystore Cluster instances that you want to +[import to be managed by Terraform][terraform-import], follow the instructions +in this section. + +1. List your Memorystore clusters: + + ```sh + gcloud redis clusters list --format="table(name)" + ``` + +2. Set the following variable with the instance name from the output of the + above command that you want to import + + ```sh + MEMORYSTORE_CLUSTER_NAME= + ``` + +3. Create a Terraform config file with an empty + [`google_redis_cluster`][terraform-redis-cluster] resource: + + ```sh + echo "resource \"google_redis_cluster\" \"${MEMORYSTORE_CLUSTER_NAME}\" {}" > "${MEMORYSTORE_CLUSTER_NAME}.tf" + ``` + +4. [Import][terraform-import-usage] the Memorystore Cluster instance into the Terraform + state. + + ```sh + terraform import "google_redis_cluster.${MEMORYSTORE_CLUSTER_NAME}" "${MEMORYSTORE_CLUSTER_NAME}" + ``` + +5. After the import succeeds, update the Terraform config file for your + instance with the actual instance attributes + + ```sh + # TODO fields to exclude + terraform state show -no-color "google_redis_cluster.${MEMORYSTORE_CLUSTER_NAME}" \ + | grep -vE "(id|state).*(=|\{)" \ + > "${MEMORYSTORE_CLUSTER_NAME}.tf" + ``` + +If you have additional Memorystore clusters to import, repeat this process. + +## Troubleshooting + +This section contains guidance on what to do if you encounter issues when +following the instructions above. + +### If the GKE cluster is not successfully created + +1. Check there are no [Organizational Policy][organizational-policy] rules + that may conflict with cluster creation. + +### If you do not see scaling operations as expected + +1. The first step if you are encountering scaling issues is to check the logs + for the Autoscaler in [Cloud Logging][cloud-console-logging]. To retrieve + the logs for the Autoscaler components, use the following query: + + ```terminal + resource.type="k8s_container" + resource.labels.namespace_name="memorystore-cluster-autoscaler" + resource.labels.container_name="scaler" + ``` + + If you do not see any log entries, check that you have selected the correct + time period to display in the Cloud Logging console, and that the GKE + cluster nodes have the correct permissions to write logs to the Cloud + Logging API ([roles/logging.logWriter][logging-iam-role]). + +### If the Poller fails to run successfully + +1. If you have chosen to use Firestore for Autoscaler state and you see the + following error in the logs: + + ```sh + Error: 5 NOT_FOUND: Database not found: projects//instances/memorystore-autoscaler-state/databases/memorystore-autoscaler-state + ``` + + Edit the file `${AUTOSCALER_ROOT}/autoscaler-config/autoscaler-config.yaml` + and remove the following stanza: + + ```yaml + stateDatabase: + name: spanner + instanceId: memorystore-autoscaler-state + databaseId: memorystore-autoscaler-state + ``` + +2. Check the formatting of the YAML configration file: + + ```sh + cat ${AUTOSCALER_ROOT}/autoscaler-config/autoscaler-config.yaml + ``` + +3. Validate the contents of the YAML configuraration file: + + ```sh + npm install + npm run validate-config-file -- ${AUTOSCALER_ROOT}/autoscaler-config/autoscaler-config.yaml + ``` + + +[architecture-gke-unified]: ../../resources/architecture-gke-unified.png +[autoscaler-config-params]: ../../src/poller/README.md#configuration-parameters +[autoscaler-poller]: ../../src/poller/README.md +[autoscaler-configuration]: ../../src/poller/README.md#configuration-parameters +[cloud-console-logging]: https://console.cloud.google.com/logs/query +[cloud-console]: https://console.cloud.google.com +[cloud-firestore]: https://cloud.google.com/firestore +[cloud-functions]: https://cloud.google.com/functions +[cloud-monitoring]: https://cloud.google.com/monitoring +[cloud-shell]: https://console.cloud.google.com/?cloudshell=true +[enable-billing]: https://cloud.google.com/billing/docs/how-to/modify-project +[firestore-native]: https://cloud.google.com/datastore/docs/firestore-or-datastore#in_native_mode +[gcm-docs]: https://cloud.google.com/monitoring/docs +[gke]: https://cloud.google.com/kubernetes-engine +[kubernetes-configmap]: https://kubernetes.io/docs/concepts/configuration/configmap/ +[kubernetes-cronjob]: https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/ +[kubernetes-job]: https://kubernetes.io/docs/concepts/workloads/controllers/job/ +[logging-iam-role]: https://cloud.google.com/logging/docs/access-control#logging.logWriter +[margins]: ../../src/poller/README.md#margins +[organizational-policy]: https://cloud.google.com/resource-manager/docs/organization-policy/overview +[otel-collector]: https://opentelemetry.io/docs/collector/ +[project-selector]: https://console.cloud.google.com/projectselector2/home/dashboard +[provider-issue]: https://github.com/hashicorp/terraform-provider-google/issues/6782 +[scaling-methods]: ../../src/scaler/README.md#scaling-methods +[spanner]: https://cloud.google.com/spanner +[terraform-import-usage]: https://www.terraform.io/docs/import/usage.html +[terraform-import]: https://www.terraform.io/docs/import/index.html +[terraform-redis-cluster]: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/redis_cluster diff --git a/terraform/gke/unified/.terraform.lock.hcl b/terraform/gke/unified/.terraform.lock.hcl new file mode 100644 index 0000000..e776298 --- /dev/null +++ b/terraform/gke/unified/.terraform.lock.hcl @@ -0,0 +1,141 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/external" { + version = "2.3.4" + constraints = ">= 2.2.2" + hashes = [ + "h1:XWkRZOLKMjci9/JAtE8X8fWOt7A4u+9mgXSUjc4Wuyo=", + "zh:037fd82cd86227359bc010672cd174235e2d337601d4686f526d0f53c87447cb", + "zh:0ea1db63d6173d01f2fa8eb8989f0809a55135a0d8d424b08ba5dabad73095fa", + "zh:17a4d0a306566f2e45778fbac48744b6fd9c958aaa359e79f144c6358cb93af0", + "zh:298e5408ab17fd2e90d2cd6d406c6d02344fe610de5b7dae943a58b958e76691", + "zh:38ecfd29ee0785fd93164812dcbe0664ebbe5417473f3b2658087ca5a0286ecb", + "zh:59f6a6f31acf66f4ea3667a555a70eba5d406c6e6d93c2c641b81d63261eeace", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:ad0279dfd09d713db0c18469f585e58d04748ca72d9ada83883492e0dd13bd58", + "zh:c69f66fd21f5e2c8ecf7ca68d9091c40f19ad913aef21e3ce23836e91b8cbb5f", + "zh:d4a56f8c48aa86fc8e0c233d56850f5783f322d6336f3bf1916e293246b6b5d4", + "zh:f2b394ebd4af33f343835517e80fc876f79361f4688220833bc3c77655dd2202", + "zh:f31982f29f12834e5d21e010856eddd19d59cd8f449adf470655bfd19354377e", + ] +} + +provider "registry.terraform.io/hashicorp/google" { + version = "6.1.0" + constraints = ">= 3.39.0, >= 3.53.0, >= 5.40.0, != 5.44.0, >= 6.1.0, < 6.2.0, < 7.0.0" + hashes = [ + "h1:okppWOAoIPz45VkydzAA74HRLgEKvP4CFXypPU228j8=", + "zh:2463510438c97c59e06ab1fb1ef76221c844abd1bc404c439401fc256e9928ab", + "zh:2afd9b76a81c51632bd54d3cc3bdc2685e8d89b8ace8ca7578b1ae42880228b5", + "zh:51e2fb64c7c8258ac0ec7315d488e5c655b392bf565f9bee2922ee72f6abfb90", + "zh:85aa39bad51132810ee6cd369f426614abff59cb0274fc737d087c17afa9b5ee", + "zh:989669bfed5ca7bf4d960eb9f27a62cbe2578ca2907da7c74fc93edae9a497fa", + "zh:a26665782e90ef3fd322d6a23a1de383c81ae93395e7c2bd9648a1aa85c69876", + "zh:d5e1b785b4c8569b91153eeba89280ffbbe7a0aaabb708833ada67544aeed057", + "zh:d748c69eab6acc4ab7ec369b3bd3ddd5d2e4120d99570743dafde74934959a20", + "zh:eb853ab5c4c0d3e536b8c77abf844b7893ac355967c95b6e0d39b12526e67989", + "zh:f4b50f0ae082412ba189041b6ac540523b7d6463905fed63be67eec03e1539b9", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f6e7adcfafe267d9c657a6c087388f7e0c1e3be4dc179a9a823f75c830a499b7", + ] +} + +provider "registry.terraform.io/hashicorp/google-beta" { + version = "6.1.0" + constraints = ">= 5.40.0, != 5.44.0, < 6.2.0, < 7.0.0" + hashes = [ + "h1:AEOTKPFbO8SKMOCBhzANkGST9yjGKO6OXjEScROE8Ro=", + "zh:3ea706aa701755ed212a13ebfc0332a587738add77f7433449ec0b0bde9ba6f3", + "zh:5025a74240983a7a55c1496b124da4193f23dec751dec203e1801439e6c232af", + "zh:535fe811841e7ce06050b66f4d32d5812df17cf942c15d3063c8d29197733938", + "zh:66e81f66c2bc4e2d325b537f9e9e51ea4ca4d1015b8c7ee8bdca74c6fc1f41b6", + "zh:71ebb3ba56666ba38424acebb5b149df10cb31f68e268e7be6ec66046c9e83b3", + "zh:8c798beb2516f726324289a06eb6956c88905c2a385e4cf18830c5210d9abc64", + "zh:9110f27d1c5cf2b797162c17ab75c47c856f8273fb66dbf26b3d2fe5b1a9b105", + "zh:a85f6ed96ade11563eaa3c8ab25e36c4a8abea8f3de2c59a2a318a110903d110", + "zh:aec1733cbe018418f62182b06f982ecaa32888398f93072cf5300523e9c6763d", + "zh:b58eb8b422eb6c576f1b597c1bcda618ef143d6acc9663730e9b4ce55e035814", + "zh:b5984d64c9bf7ac5e27b9fdbcc44fd206b320259007b5c7fc6e3c975b0762478", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "2.32.0" + constraints = "~> 2.10, ~> 2.13, >= 2.32.0" + hashes = [ + "h1:HqeU0sZBh+2loFYqPMFx7jJamNUPEykyqJ9+CkMCYE0=", + "zh:0e715d7fb13a8ad569a5fdc937b488590633f6942e986196fdb17cd7b8f7720e", + "zh:495fc23acfe508ed981e60af9a3758218b0967993065e10a297fdbc210874974", + "zh:4b930a8619910ef528bc90dae739cb4236b9b76ce41367281e3bc3cf586101c7", + "zh:5344405fde7b1febf0734052052268ee24e7220818155702907d9ece1c0697c7", + "zh:92ee11e8c23bbac3536df7b124456407f35c6c2468bc0dbab15c3fc9f414bd0e", + "zh:a45488fe8d5bb59c49380f398da5d109a4ac02ebc10824567dabb87f6102fda8", + "zh:a4a0b57cf719a4c91f642436882b7bea24d659c08a5b6f4214ce4fe6a0204caa", + "zh:b7a27a6d11ba956a2d7b0f7389a46ec857ebe46ae3aeee537250e66cac15bf03", + "zh:bf94ce389028b686bfa70a90f536e81bb776c5c20ab70138bbe5c3d0a04c4253", + "zh:d965b2608da0212e26a65a0b3f33c5baae46cbe839196be15d93f70061516908", + "zh:f441fc793d03057a17af8bdca8b26d54916645bc5c148f54e22a54ed39089e83", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.2.3" + constraints = ">= 2.1.0" + hashes = [ + "h1:+AnORRgFbRO6qqcfaQyeX80W0eX3VmjadjnUFUJTiXo=", + "zh:22d062e5278d872fe7aed834f5577ba0a5afe34a3bdac2b81f828d8d3e6706d2", + "zh:23dead00493ad863729495dc212fd6c29b8293e707b055ce5ba21ee453ce552d", + "zh:28299accf21763ca1ca144d8f660688d7c2ad0b105b7202554ca60b02a3856d3", + "zh:55c9e8a9ac25a7652df8c51a8a9a422bd67d784061b1de2dc9fe6c3cb4e77f2f", + "zh:756586535d11698a216291c06b9ed8a5cc6a4ec43eee1ee09ecd5c6a9e297ac1", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:9d5eea62fdb587eeb96a8c4d782459f4e6b73baeece4d04b4a40e44faaee9301", + "zh:a6355f596a3fb8fc85c2fb054ab14e722991533f87f928e7169a486462c74670", + "zh:b5a65a789cff4ada58a5baffc76cb9767dc26ec6b45c00d2ec8b1b027f6db4ed", + "zh:db5ab669cf11d0e9f81dc380a6fdfcac437aea3d69109c7aef1a5426639d2d65", + "zh:de655d251c470197bcbb5ac45d289595295acb8f829f6c781d4a75c8c8b7c7dd", + "zh:f5c68199f2e6076bce92a12230434782bf768103a427e9bb9abee99b116af7b5", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.6.3" + constraints = ">= 2.1.0" + hashes = [ + "h1:Fnaec9vA8sZ8BXVlN3Xn9Jz3zghSETIKg7ch8oXhxno=", + "zh:04ceb65210251339f07cd4611885d242cd4d0c7306e86dda9785396807c00451", + "zh:448f56199f3e99ff75d5c0afacae867ee795e4dfda6cb5f8e3b2a72ec3583dd8", + "zh:4b4c11ccfba7319e901df2dac836b1ae8f12185e37249e8d870ee10bb87a13fe", + "zh:4fa45c44c0de582c2edb8a2e054f55124520c16a39b2dfc0355929063b6395b1", + "zh:588508280501a06259e023b0695f6a18149a3816d259655c424d068982cbdd36", + "zh:737c4d99a87d2a4d1ac0a54a73d2cb62974ccb2edbd234f333abd079a32ebc9e", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a357ab512e5ebc6d1fda1382503109766e21bbfdfaa9ccda43d313c122069b30", + "zh:c51bfb15e7d52cc1a2eaec2a903ac2aff15d162c172b1b4c17675190e8147615", + "zh:e0951ee6fa9df90433728b96381fb867e3db98f66f735e0c3e24f8f16903f0ad", + "zh:e3cdcb4e73740621dabd82ee6a37d6cfce7fee2a03d8074df65086760f5cf556", + "zh:eff58323099f1bd9a0bec7cb04f717e7f1b2774c7d612bf7581797e1622613a0", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.12.1" + hashes = [ + "h1:6BhxSYBJdBBKyuqatOGkuPKVenfx6UmLdiI13Pb3his=", + "zh:090023137df8effe8804e81c65f636dadf8f9d35b79c3afff282d39367ba44b2", + "zh:26f1e458358ba55f6558613f1427dcfa6ae2be5119b722d0b3adb27cd001efea", + "zh:272ccc73a03384b72b964918c7afeb22c2e6be22460d92b150aaf28f29a7d511", + "zh:438b8c74f5ed62fe921bd1078abe628a6675e44912933100ea4fa26863e340e9", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:85c8bd8eefc4afc33445de2ee7fbf33a7807bc34eb3734b8eefa4e98e4cddf38", + "zh:98bbe309c9ff5b2352de6a047e0ec6c7e3764b4ed3dfd370839c4be2fbfff869", + "zh:9c7bf8c56da1b124e0e2f3210a1915e778bab2be924481af684695b52672891e", + "zh:d2200f7f6ab8ecb8373cda796b864ad4867f5c255cff9d3b032f666e4c78f625", + "zh:d8c7926feaddfdc08d5ebb41b03445166df8c125417b28d64712dccd9feef136", + "zh:e2412a192fc340c61b373d6c20c9d805d7d3dee6c720c34db23c2a8ff0abd71b", + "zh:e6ac6bba391afe728a099df344dbd6481425b06d61697522017b8f7a59957d44", + ] +} diff --git a/terraform/gke/unified/README.md b/terraform/gke/unified/README.md new file mode 100644 index 0000000..a8a8652 --- /dev/null +++ b/terraform/gke/unified/README.md @@ -0,0 +1,37 @@ +
+

+

OSS Memorystore Cluster Autoscaler

+ Autoscaler + +

+ + Set up the Autoscaler in GKE using Terraform configuration files +
+ Home + · + Scaler component + · + Poller component + · + Forwarder component + · + Terraform configuration + · + Monitoring +
+ Cloud Run functions + · + Google Kubernetes Engine +

+ +

+ +## Overview + +This directory contains Terraform configuration files to quickly set +up the infrastructure for your Autoscaler for a unified deployment to +[Google Kubernetes Engine (GKE)][gke]. + +Please see the documentation [here](../README.md). + +[gke]: https://cloud.google.com/kubernetes-engine diff --git a/terraform/gke/unified/main.tf b/terraform/gke/unified/main.tf new file mode 100644 index 0000000..8b6a55b --- /dev/null +++ b/terraform/gke/unified/main.tf @@ -0,0 +1,132 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = ">= 6.1.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.32.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +resource "google_service_account" "autoscaler_sa" { + account_id = "scaler-sa" + display_name = "Memorystore Cluster Autoscaler - Scaler SA" +} + +module "autoscaler-base" { + source = "../../modules/autoscaler-base" + + project_id = var.project_id + poller_sa_email = google_service_account.autoscaler_sa.email + scaler_sa_email = google_service_account.autoscaler_sa.email +} + +module "autoscaler-gke" { + source = "../../modules/autoscaler-gke" + + region = var.region + project_id = var.project_id + name = "memorystore-cluster-autoscaler" + network = module.autoscaler-network.network_name + subnetwork = module.autoscaler-network.subnetwork_name + ip_range_master = "10.1.0.0/28" + ip_range_pods = "" + ip_range_services = "" + poller_sa_email = google_service_account.autoscaler_sa.email + scaler_sa_email = google_service_account.autoscaler_sa.email +} + +module "autoscaler-firestore" { + source = "../../modules/autoscaler-firestore" + + project_id = var.project_id + poller_sa_email = google_service_account.autoscaler_sa.email + scaler_sa_email = google_service_account.autoscaler_sa.email +} + +module "autoscaler-spanner" { + source = "../../modules/autoscaler-spanner" + + region = var.region + project_id = var.project_id + terraform_spanner_state = var.terraform_spanner_state + spanner_state_name = var.spanner_state_name + spanner_state_database = var.spanner_state_database + + poller_sa_email = google_service_account.autoscaler_sa.email + scaler_sa_email = google_service_account.autoscaler_sa.email +} + +module "autoscaler-network" { + source = "../../modules/autoscaler-network" + + region = var.region + project_id = var.project_id + ip_range = var.ip_range +} + +module "autoscaler-memorystore-cluster" { + source = "../../modules/autoscaler-memorystore-cluster" + + region = var.region + project_id = var.project_id + memorystore_cluster_name = var.memorystore_cluster_name + + network = module.autoscaler-network.network + subnetwork = module.autoscaler-network.subnetwork + dns_zone = module.autoscaler-network.dns_zone + + terraform_memorystore_cluster = var.terraform_memorystore_cluster + + poller_sa_email = google_service_account.autoscaler_sa.email + scaler_sa_email = google_service_account.autoscaler_sa.email + + memorystore_shard_count = var.memorystore_shard_count + memorystore_replica_count = var.memorystore_replica_count + + depends_on = [module.autoscaler-network] +} + +module "autoscaler-test-vm" { + count = var.terraform_test_vm && var.terraform_memorystore_cluster ? 1 : 0 + source = "../../modules/autoscaler-test-vm" + + region = var.region + project_id = var.project_id + name = var.terraform_test_vm_name + network = module.autoscaler-network.network + subnetwork = module.autoscaler-network.subnetwork +} + +module "autoscaler-monitoring" { + count = var.terraform_dashboard ? 1 : 0 + source = "../../modules/autoscaler-monitoring" + + region = var.region + project_id = var.project_id + memorystore_cluster_name = var.memorystore_cluster_name +} diff --git a/terraform/gke/unified/outputs.tf b/terraform/gke/unified/outputs.tf new file mode 100644 index 0000000..219340e --- /dev/null +++ b/terraform/gke/unified/outputs.tf @@ -0,0 +1,30 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "memorystore_discovery_endpoint" { + value = module.autoscaler-memorystore-cluster.memorystore_discovery_endpoint != null ? module.autoscaler-memorystore-cluster.memorystore_discovery_endpoint.address : null + description = "Memorystore discovery endpoint (currently single value)" +} + +output "test_vm_zone" { + value = length(module.autoscaler-test-vm) > 0 ? one(module.autoscaler-test-vm).zone : null + description = "Zone of the test VM" +} + +output "test_vm_name" { + value = length(module.autoscaler-test-vm) > 0 ? one(module.autoscaler-test-vm).instance_name : null + description = "Name of the test VM" +} diff --git a/terraform/gke/unified/variables.tf b/terraform/gke/unified/variables.tf new file mode 100644 index 0000000..2f3d52a --- /dev/null +++ b/terraform/gke/unified/variables.tf @@ -0,0 +1,84 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "memorystore_cluster_name" { + type = string + default = "autoscaler-target-memorystore-cluster" +} + +variable "memorystore_shard_count" { + type = number + default = 3 +} + +variable "memorystore_replica_count" { + type = number + default = 1 +} + +variable "terraform_memorystore_cluster" { + description = "If set to true, Terraform will create a test Memorystore cluster." + type = bool + default = true +} + +variable "terraform_spanner_state" { + description = "If set to true, Terraform will create a Spanner instance for autoscaler state." + type = bool + default = false +} + +variable "spanner_state_name" { + type = string + default = "memorystore-autoscaler-state" +} + +variable "spanner_state_database" { + type = string + default = "memorystore-autoscaler-state" +} + +variable "terraform_test_vm" { + description = "If set to true, Terraform will create a test VM with Memorystore utils installed." + type = bool + default = false +} + +variable "terraform_test_vm_name" { + description = "Name for the optional test VM" + type = string + default = "terraform-test-vm" +} + +variable "terraform_dashboard" { + description = "If set to true, Terraform will create a Cloud Monitoring dashboard including important Memorystore Cluster metrics." + type = bool + default = true +} + +variable "ip_range" { + description = "IP range for the network" + type = string + default = "10.0.0.0/24" +} diff --git a/terraform/modules/autoscaler-base/main.tf b/terraform/modules/autoscaler-base/main.tf new file mode 100644 index 0000000..3610e31 --- /dev/null +++ b/terraform/modules/autoscaler-base/main.tf @@ -0,0 +1,76 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +resource "google_pubsub_topic" "downstream_topic" { + name = "downstream-topic" + + depends_on = [google_pubsub_schema.scaler_downstream_pubsub_schema] + + schema_settings { + schema = google_pubsub_schema.scaler_downstream_pubsub_schema.id + encoding = "JSON" + } + + lifecycle { + replace_triggered_by = [google_pubsub_schema.scaler_downstream_pubsub_schema] + } +} + +resource "google_pubsub_topic_iam_member" "scaler_downstream_pub_iam" { + project = var.project_id + topic = google_pubsub_topic.downstream_topic.name + role = "roles/pubsub.publisher" + member = "serviceAccount:${var.scaler_sa_email}" +} + +resource "google_pubsub_schema" "scaler_downstream_pubsub_schema" { + name = "downstream-schema" + type = "PROTOCOL_BUFFER" + definition = file("${path.module}/../../../src/scaler/scaler-core/downstream.schema.proto") +} + +resource "google_project_iam_member" "metrics_publisher_iam_poller" { + project = var.project_id + role = "roles/monitoring.metricWriter" + member = "serviceAccount:${var.poller_sa_email}" +} + +resource "google_project_iam_member" "metrics_publisher_iam_scaler" { + project = var.project_id + role = "roles/monitoring.metricWriter" + member = "serviceAccount:${var.scaler_sa_email}" +} + +resource "google_service_account" "build_sa" { + account_id = "build-sa" + display_name = "Autoscaler - Cloud Build Builder Service Account" +} + +resource "google_project_iam_member" "build_iam" { + for_each = toset([ + "roles/artifactregistry.writer", + "roles/logging.logWriter", + "roles/storage.objectViewer", + ]) + project = var.project_id + role = each.value + member = "serviceAccount:${google_service_account.build_sa.email}" +} + +resource "time_sleep" "wait_for_iam" { + depends_on = [google_project_iam_member.build_iam] + create_duration = "90s" +} diff --git a/terraform/modules/autoscaler-base/outputs.tf b/terraform/modules/autoscaler-base/outputs.tf new file mode 100644 index 0000000..2e5f1a2 --- /dev/null +++ b/terraform/modules/autoscaler-base/outputs.tf @@ -0,0 +1,27 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "build_sa_id" { + value = google_service_account.build_sa.id + description = "Service account ID for Builder SA" + depends_on = [time_sleep.wait_for_iam] +} + +output "build_sa_email" { + value = google_service_account.build_sa.email + description = "Service account email for Builder SA" + depends_on = [time_sleep.wait_for_iam] +} diff --git a/terraform/modules/autoscaler-base/variables.tf b/terraform/modules/autoscaler-base/variables.tf new file mode 100644 index 0000000..4cc1719 --- /dev/null +++ b/terraform/modules/autoscaler-base/variables.tf @@ -0,0 +1,27 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "poller_sa_email" { + type = string +} + +variable "scaler_sa_email" { + type = string +} diff --git a/terraform/modules/autoscaler-firestore/main.tf b/terraform/modules/autoscaler-firestore/main.tf new file mode 100644 index 0000000..4a9af06 --- /dev/null +++ b/terraform/modules/autoscaler-firestore/main.tf @@ -0,0 +1,29 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * While the Firestore database is created using the gcloud CLI, the + * Terraform-created service account used by the Scaler needs read/write + * permissions to the instance in the appropriate project if Spanner + * is not being used to hold state. + */ + +resource "google_project_iam_member" "scaler_sa_firestore" { + + project = var.project_id + role = "roles/datastore.user" + member = "serviceAccount:${var.scaler_sa_email}" +} diff --git a/terraform/modules/autoscaler-firestore/variables.tf b/terraform/modules/autoscaler-firestore/variables.tf new file mode 100644 index 0000000..4cc1719 --- /dev/null +++ b/terraform/modules/autoscaler-firestore/variables.tf @@ -0,0 +1,27 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "poller_sa_email" { + type = string +} + +variable "scaler_sa_email" { + type = string +} diff --git a/terraform/modules/autoscaler-forwarder/main.tf b/terraform/modules/autoscaler-forwarder/main.tf new file mode 100644 index 0000000..820c120 --- /dev/null +++ b/terraform/modules/autoscaler-forwarder/main.tf @@ -0,0 +1,127 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +resource "google_service_account" "build_sa" { + account_id = "build-sa" + display_name = "Autoscaler - Cloud Build Builder Service Account" +} + +resource "google_project_iam_member" "build_iam" { + for_each = toset([ + "roles/artifactregistry.writer", + "roles/logging.logWriter", + "roles/storage.objectViewer", + ]) + project = var.project_id + role = each.value + member = "serviceAccount:${google_service_account.build_sa.email}" +} + +resource "time_sleep" "wait_for_iam" { + depends_on = [google_project_iam_member.build_iam] + create_duration = "90s" +} + +resource "google_service_account" "forwarder_sa" { + account_id = "forwarder-sa" + display_name = "Autoscaler - PubSub Forwarder SA" +} + +resource "google_pubsub_topic" "forwarder_topic" { + name = "forwarder-topic" +} + +resource "google_pubsub_topic_iam_member" "forwader_pubsub_sub_binding" { + project = var.project_id + topic = google_pubsub_topic.forwarder_topic.name + role = "roles/pubsub.subscriber" + member = "serviceAccount:${google_service_account.forwarder_sa.email}" +} + +resource "google_storage_bucket" "bucket_gcf_source" { + name = "${var.project_id}-gcf-source" + storage_class = "REGIONAL" + location = var.region + force_destroy = "true" + uniform_bucket_level_access = var.uniform_bucket_level_access +} + +data "archive_file" "local_forwarder_source" { + type = "zip" + source_dir = abspath("${path.module}/../../..") + output_path = "${var.local_output_path}/forwarder.zip" + excludes = [ + "node_modules", + "terraform", + "resources", + ".github", + "kubernetes", + "gke" + ] +} + +resource "google_storage_bucket_object" "gcs_functions_forwarder_source" { + name = "forwarder.${data.archive_file.local_forwarder_source.output_md5}.zip" + bucket = google_storage_bucket.bucket_gcf_source.name + source = data.archive_file.local_forwarder_source.output_path +} + +resource "google_cloudfunctions2_function" "forwarder_function" { + name = "tf-forwarder-function" + project = var.project_id + location = var.region + + build_config { + runtime = "nodejs${var.nodejs_version}" + entry_point = "forwardFromPubSub" + source { + storage_source { + bucket = google_storage_bucket.bucket_gcf_source.name + object = google_storage_bucket_object.gcs_functions_forwarder_source.name + } + } + service_account = google_service_account.build_sa.id + } + + service_config { + available_memory = "256M" + ingress_settings = "ALLOW_INTERNAL_AND_GCLB" + service_account_email = google_service_account.forwarder_sa.email + environment_variables = { + POLLER_TOPIC = var.target_pubsub_topic + } + } + + event_trigger { + event_type = "google.cloud.pubsub.topic.v1.messagePublished" + pubsub_topic = google_pubsub_topic.forwarder_topic.id + retry_policy = "RETRY_POLICY_RETRY" + service_account_email = google_service_account.forwarder_sa.email + } + + depends_on = [ + time_sleep.wait_for_iam, + google_pubsub_topic_iam_member.forwader_pubsub_sub_binding + ] +} + +resource "google_cloud_run_service_iam_member" "cloud_run_forwarder_invoker" { + project = google_cloudfunctions2_function.forwarder_function.project + location = google_cloudfunctions2_function.forwarder_function.location + service = google_cloudfunctions2_function.forwarder_function.name + role = "roles/run.invoker" + member = "serviceAccount:${google_service_account.forwarder_sa.email}" +} diff --git a/terraform/modules/autoscaler-forwarder/outputs.tf b/terraform/modules/autoscaler-forwarder/outputs.tf new file mode 100644 index 0000000..2bbb2f2 --- /dev/null +++ b/terraform/modules/autoscaler-forwarder/outputs.tf @@ -0,0 +1,25 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "forwarder_topic" { + value = google_pubsub_topic.forwarder_topic.id + description = "PubSub topic used by the forwarder function" +} + +output "forwarder_sa_email" { + value = google_service_account.forwarder_sa.email + description = "Email of the forwarder service account" +} diff --git a/terraform/modules/autoscaler-forwarder/variables.tf b/terraform/modules/autoscaler-forwarder/variables.tf new file mode 100644 index 0000000..be64862 --- /dev/null +++ b/terraform/modules/autoscaler-forwarder/variables.tf @@ -0,0 +1,42 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "nodejs_version" { + type = string + default = "20" +} + +variable "local_output_path" { + type = string + default = "build" +} + +variable "target_pubsub_topic" { + type = string +} + +variable "uniform_bucket_level_access" { + type = bool + default = true +} diff --git a/terraform/modules/autoscaler-functions/main.tf b/terraform/modules/autoscaler-functions/main.tf new file mode 100644 index 0000000..fe1c719 --- /dev/null +++ b/terraform/modules/autoscaler-functions/main.tf @@ -0,0 +1,174 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + provider_meta "google" { + module_name = "cloud-solutions/memorystore-cluster-autoscaler-deploy-cf-v1.0" + } +} + +// PubSub + +resource "google_pubsub_topic" "poller_topic" { + name = "poller-topic" +} + +resource "google_pubsub_topic_iam_member" "poller_pubsub_sub_iam" { + project = var.project_id + topic = google_pubsub_topic.poller_topic.name + role = "roles/pubsub.subscriber" + member = "serviceAccount:${var.poller_sa_email}" +} + +resource "google_pubsub_topic_iam_member" "forwarder_pubsub_pub_iam" { + for_each = toset(var.forwarder_sa_emails) + + project = var.project_id + topic = google_pubsub_topic.poller_topic.name + role = "roles/pubsub.publisher" + member = each.key +} + +resource "google_pubsub_topic" "scaler_topic" { + name = "scaler-topic" +} + +resource "google_pubsub_topic_iam_member" "poller_pubsub_pub_iam" { + project = var.project_id + topic = google_pubsub_topic.scaler_topic.name + role = "roles/pubsub.publisher" + member = "serviceAccount:${var.poller_sa_email}" +} + +resource "google_pubsub_topic_iam_member" "scaler_pubsub_sub_iam" { + project = var.project_id + topic = google_pubsub_topic.scaler_topic.name + role = "roles/pubsub.subscriber" + member = "serviceAccount:${var.scaler_sa_email}" +} + +// Cloud Run functions + +resource "google_storage_bucket" "bucket_gcf_source" { + name = "${var.project_id}-gcf-source" + storage_class = "REGIONAL" + location = var.region + force_destroy = "true" + uniform_bucket_level_access = var.uniform_bucket_level_access +} + +data "archive_file" "local_source" { + type = "zip" + source_dir = abspath("${path.module}/../../..") + output_path = "${var.local_output_path}/src.zip" + excludes = [".git", ".github", ".husky", ".nyc_output", ".vscode", "kubernetes", "node_modules", "resources", "terraform"] +} + +resource "google_storage_bucket_object" "gcs_functions_source" { + name = "src.${data.archive_file.local_source.output_md5}.zip" + bucket = google_storage_bucket.bucket_gcf_source.name + source = data.archive_file.local_source.output_path +} + +resource "google_cloudfunctions2_function" "poller_function" { + name = "tf-poller-function" + project = var.project_id + location = var.region + + build_config { + runtime = "nodejs${var.nodejs_version}" + entry_point = "checkMemorystoreClusterScaleMetricsPubSub" + source { + storage_source { + bucket = google_storage_bucket.bucket_gcf_source.name + object = google_storage_bucket_object.gcs_functions_source.name + } + } + service_account = var.build_sa_id + } + + service_config { + available_memory = "256M" + ingress_settings = "ALLOW_INTERNAL_AND_GCLB" + service_account_email = var.poller_sa_email + } + + event_trigger { + event_type = "google.cloud.pubsub.topic.v1.messagePublished" + pubsub_topic = google_pubsub_topic.poller_topic.id + retry_policy = "RETRY_POLICY_RETRY" + service_account_email = var.poller_sa_email + } + + lifecycle { + ignore_changes = [ + service_config[0].max_instance_count + ] + } +} + +resource "google_cloudfunctions2_function" "scaler_function" { + name = "tf-scaler-function" + project = var.project_id + location = var.region + + build_config { + runtime = "nodejs${var.nodejs_version}" + entry_point = "scaleMemorystoreClusterPubSub" + source { + storage_source { + bucket = google_storage_bucket.bucket_gcf_source.name + object = google_storage_bucket_object.gcs_functions_source.name + } + } + service_account = var.build_sa_id + } + + service_config { + available_memory = "256M" + ingress_settings = "ALLOW_INTERNAL_AND_GCLB" + service_account_email = var.scaler_sa_email + } + + event_trigger { + event_type = "google.cloud.pubsub.topic.v1.messagePublished" + pubsub_topic = google_pubsub_topic.scaler_topic.id + retry_policy = "RETRY_POLICY_RETRY" + service_account_email = var.scaler_sa_email + } + + lifecycle { + ignore_changes = [ + service_config[0].max_instance_count + ] + } +} + +resource "google_cloud_run_service_iam_member" "cloud_run_poller_invoker" { + project = google_cloudfunctions2_function.poller_function.project + location = google_cloudfunctions2_function.poller_function.location + service = google_cloudfunctions2_function.poller_function.name + role = "roles/run.invoker" + member = "serviceAccount:${var.poller_sa_email}" +} + +resource "google_cloud_run_service_iam_member" "cloud_run_scaler_invoker" { + project = google_cloudfunctions2_function.scaler_function.project + location = google_cloudfunctions2_function.scaler_function.location + service = google_cloudfunctions2_function.scaler_function.name + role = "roles/run.invoker" + member = "serviceAccount:${var.scaler_sa_email}" +} diff --git a/terraform/modules/autoscaler-functions/outputs.tf b/terraform/modules/autoscaler-functions/outputs.tf new file mode 100644 index 0000000..959a9c6 --- /dev/null +++ b/terraform/modules/autoscaler-functions/outputs.tf @@ -0,0 +1,25 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "poller_topic" { + value = google_pubsub_topic.poller_topic.id + description = "PubSub topic used by the poller function" +} + +output "scaler_topic" { + value = google_pubsub_topic.scaler_topic.id + description = "PubSub topic used by the scaler function" +} diff --git a/terraform/modules/autoscaler-functions/variables.tf b/terraform/modules/autoscaler-functions/variables.tf new file mode 100644 index 0000000..11b7522 --- /dev/null +++ b/terraform/modules/autoscaler-functions/variables.tf @@ -0,0 +1,56 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "nodejs_version" { + type = string + default = "20" +} + +variable "local_output_path" { + type = string + default = "build" +} + +variable "uniform_bucket_level_access" { + type = bool + default = true +} + +variable "poller_sa_email" { + type = string +} + +variable "scaler_sa_email" { + type = string +} +variable "build_sa_id" { + type = string + // projects/{{project}}/serviceAccounts/{{email}} +} + +variable "forwarder_sa_emails" { + type = list(string) + // Example ["serviceAccount:forwarder_sa@app-project.iam.gserviceaccount.com"] + default = [] +} diff --git a/terraform/modules/autoscaler-gke/main.tf b/terraform/modules/autoscaler-gke/main.tf new file mode 100644 index 0000000..9c450ea --- /dev/null +++ b/terraform/modules/autoscaler-gke/main.tf @@ -0,0 +1,155 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + provider_meta "google" { + module_name = "cloud-solutions/memorystore-cluster-autoscaler-deploy-gke-v1.0" + } +} + +locals { + poller_sa_name = element(split("@", var.poller_sa_email), 0) + scaler_sa_name = element(split("@", var.scaler_sa_email), 0) +} + +resource "google_service_account" "otel_collector_service_account" { + project = var.project_id + account_id = var.otel_collector_sa_name + display_name = "Memorystore Cluster Autoscaler - SA for OpenTelemetry Collector in ${var.name}" +} + +resource "google_project_iam_member" "metrics_publisher_otel_collector" { + project = var.project_id + role = "roles/monitoring.metricWriter" + member = "serviceAccount:${google_service_account.otel_collector_service_account.email}" +} + +resource "google_service_account" "gke_cluster_service_account" { + project = var.project_id + account_id = "cluster-sa" + display_name = "Memorystore Cluster Autoscaler - cluster SA for ${var.name}" +} + +resource "google_project_iam_member" "cluster_iam_logginglogwriter" { + project = var.project_id + role = "roles/logging.logWriter" + member = "serviceAccount:${google_service_account.gke_cluster_service_account.email}" +} + +resource "google_project_iam_member" "cluster_iam_monitoringmetricwriter" { + project = var.project_id + role = "roles/monitoring.metricWriter" + member = "serviceAccount:${google_service_account.gke_cluster_service_account.email}" +} + +resource "google_project_iam_member" "cluster_iam_monitoringviewer" { + project = var.project_id + role = "roles/monitoring.viewer" + member = "serviceAccount:${google_service_account.gke_cluster_service_account.email}" +} + +resource "google_project_iam_member" "cluster_iam_resourcemetadatawriter" { + project = var.project_id + role = "roles/stackdriver.resourceMetadata.writer" + member = "serviceAccount:${google_service_account.gke_cluster_service_account.email}" +} + +resource "google_project_iam_member" "cluster_iam_artifactregistryreader" { + project = var.project_id + role = "roles/artifactregistry.reader" + member = "serviceAccount:${google_service_account.gke_cluster_service_account.email}" +} + + +resource "google_artifact_registry_repository" "autoscaler_artifact_repo" { + location = var.region + repository_id = "memorystore-cluster-autoscaler" + description = "Image registry for Memorystore Cluster Autoscaler" + format = "DOCKER" +} + +data "google_client_config" "default" {} + +provider "kubernetes" { + host = "https://${module.autoscaler-gke.endpoint}" + token = data.google_client_config.default.access_token + cluster_ca_certificate = base64decode(module.autoscaler-gke.ca_certificate) +} + +resource "kubernetes_namespace" "autoscaler_namespace" { + metadata { + name = var.namespace + } +} + +module "workload_identity_poller" { + count = var.unified_components ? 0 : 1 + source = "terraform-google-modules/kubernetes-engine/google//modules/workload-identity" + version = ">= 33.0.3" + project_id = var.project_id + namespace = var.namespace + use_existing_k8s_sa = false + use_existing_gcp_sa = true + name = local.poller_sa_name + depends_on = [kubernetes_namespace.autoscaler_namespace, var.poller_sa_email] +} + +module "workload_identity_scaler" { + source = "terraform-google-modules/kubernetes-engine/google//modules/workload-identity" + version = ">= 33.0.3" + project_id = var.project_id + namespace = var.namespace + use_existing_k8s_sa = false + use_existing_gcp_sa = true + name = local.scaler_sa_name + depends_on = [kubernetes_namespace.autoscaler_namespace, var.scaler_sa_email] +} + +module "workload_identity_otel_collector" { + source = "terraform-google-modules/kubernetes-engine/google//modules/workload-identity" + version = ">= 33.0.3" + project_id = var.project_id + namespace = var.namespace + use_existing_k8s_sa = false + use_existing_gcp_sa = true + name = var.otel_collector_sa_name + depends_on = [kubernetes_namespace.autoscaler_namespace, google_service_account.otel_collector_service_account] +} + +module "autoscaler-gke" { + source = "terraform-google-modules/kubernetes-engine/google//modules/beta-autopilot-private-cluster" + version = ">= 33.0.3" + project_id = var.project_id + name = var.name + region = var.region + network = var.network + subnetwork = var.subnetwork + master_ipv4_cidr_block = var.ip_range_master + ip_range_pods = var.ip_range_pods + ip_range_services = var.ip_range_services + enable_private_nodes = true + regional = true + create_service_account = false + deletion_protection = false + service_account = google_service_account.gke_cluster_service_account.email + + master_authorized_networks = [ + { + cidr_block = "0.0.0.0/0" + display_name = "Public" + }, + ] +} diff --git a/terraform/modules/autoscaler-gke/outputs.tf b/terraform/modules/autoscaler-gke/outputs.tf new file mode 100644 index 0000000..4128d42 --- /dev/null +++ b/terraform/modules/autoscaler-gke/outputs.tf @@ -0,0 +1,35 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "service_account" { + value = module.autoscaler-gke.service_account + description = "Service account used to create the cluster and node pool(s)" +} + +output "region" { + value = module.autoscaler-gke.region + description = "Region for development cluster" +} + +output "cluster-name" { + value = module.autoscaler-gke.name + description = "Cluster Name" +} + +output "endpoint" { + value = module.autoscaler-gke.endpoint + description = "Cluster endpoint used to identify the cluster" +} diff --git a/terraform/modules/autoscaler-gke/variables.tf b/terraform/modules/autoscaler-gke/variables.tf new file mode 100644 index 0000000..c33c952 --- /dev/null +++ b/terraform/modules/autoscaler-gke/variables.tf @@ -0,0 +1,72 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + description = "Project ID where the cluster will run" +} + +variable "region" { + description = "The name of the region to run the cluster" +} + +variable "name" { + description = "A unique name for the resource" +} + +variable "network" { + description = "The name of the network to use" +} + +variable "subnetwork" { + description = "The name of the subnet to use" +} + +variable "ip_range_master" { + description = "The range for the private master" +} + +variable "ip_range_pods" { + description = "The secondary range for the pods" +} + +variable "ip_range_services" { + description = "The secondary range for the services" +} + +variable "namespace" { + description = "The namespace to use for the services" + default = "memorystore-cluster-autoscaler" +} + +variable "poller_sa_email" { + type = string +} + +variable "scaler_sa_email" { + type = string +} + +variable "otel_collector_sa_name" { + type = string + description = "The name of the service account and workload identity to be created and used by the OpenTelemetry Collector workload" + default = "otel-collector-sa" +} + +variable "unified_components" { + description = "Whether Poller and Scaler are unified" + type = bool + default = true +} diff --git a/terraform/modules/autoscaler-memorystore-cluster/main.tf b/terraform/modules/autoscaler-memorystore-cluster/main.tf new file mode 100644 index 0000000..4e004f6 --- /dev/null +++ b/terraform/modules/autoscaler-memorystore-cluster/main.tf @@ -0,0 +1,99 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + provider_meta "google" { + module_name = "cloud-solutions/memorystore-cluster-autoscaler-deploy-cluster-v1.0" + } +} + +resource "google_redis_cluster" "memorystore_cluster" { + count = var.terraform_memorystore_cluster ? 1 : 0 + name = var.memorystore_cluster_name + shard_count = var.memorystore_shard_count + psc_configs { + network = var.network + } + region = var.region + replica_count = var.memorystore_replica_count + transit_encryption_mode = "TRANSIT_ENCRYPTION_MODE_DISABLED" + authorization_mode = "AUTH_MODE_DISABLED" + + zone_distribution_config { + mode = "MULTI_ZONE" + } + + deletion_protection_enabled = false + + lifecycle { + ignore_changes = [shard_count, replica_count] + } +} + +resource "random_id" "role_suffix" { + byte_length = 4 +} + +# Limited role for Poller +resource "google_project_iam_custom_role" "metrics_viewer_iam_role" { + project = var.project_id + role_id = "memorystoreClusterAutoscalerMetricsViewer_${random_id.role_suffix.hex}" + title = "Memorystore Cluster Autoscaler Metrics Viewer Role" + description = "Allows a principal to get Memorystore Cluster instances and view time series metrics" + permissions = [ + "redis.clusters.list", + "redis.clusters.get", + "monitoring.timeSeries.list" + ] +} + +# Assign custom role to Poller +resource "google_project_iam_member" "poller_metrics_viewer_iam" { + role = google_project_iam_custom_role.metrics_viewer_iam_role.name + project = var.project_id + member = "serviceAccount:${var.poller_sa_email}" +} + +# Limited role for Scaler +resource "google_project_iam_custom_role" "capacity_manager_iam_role" { + project = var.project_id + role_id = "memorystoreClusterAutoscalerCapacityManager_${random_id.role_suffix.hex}" + title = "Memorystore Cluster Autoscaler Capacity Manager Role" + description = "Allows a principal to scale Memorystore Cluster instances" + permissions = [ + "redis.clusters.get", + "redis.clusters.update", + "redis.operations.get" + ] +} + +# Assign custom role to Scaler +resource "google_project_iam_member" "scaler_update_capacity_iam" { + role = google_project_iam_custom_role.capacity_manager_iam_role.name + project = var.project_id + member = "serviceAccount:${var.scaler_sa_email}" +} + +resource "google_dns_record_set" "memorystore_cluster" { + count = var.terraform_memorystore_cluster ? 1 : 0 + name = "cluster.${var.dns_zone.dns_name}" + type = "A" + ttl = 300 + + managed_zone = var.dns_zone.name + + rrdatas = [one(google_redis_cluster.memorystore_cluster).discovery_endpoints[0].address] +} diff --git a/terraform/modules/autoscaler-memorystore-cluster/outputs.tf b/terraform/modules/autoscaler-memorystore-cluster/outputs.tf new file mode 100644 index 0000000..2e30a6c --- /dev/null +++ b/terraform/modules/autoscaler-memorystore-cluster/outputs.tf @@ -0,0 +1,20 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "memorystore_discovery_endpoint" { + value = length(google_redis_cluster.memorystore_cluster) > 0 ? one(google_redis_cluster.memorystore_cluster).discovery_endpoints[0] : null + description = "Memorystore discovery endpoint (currently single value)" +} diff --git a/terraform/modules/autoscaler-memorystore-cluster/variables.tf b/terraform/modules/autoscaler-memorystore-cluster/variables.tf new file mode 100644 index 0000000..de23500 --- /dev/null +++ b/terraform/modules/autoscaler-memorystore-cluster/variables.tf @@ -0,0 +1,67 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string + description = "Project ID where the cluster will run" +} + +variable "memorystore_cluster_name" { + type = string + description = "A unique name for the cluster" +} + +variable "region" { + type = string + description = "The name of the region to run the cluster" +} + +variable "network" { + type = string + description = "The VPC network to host the cluster in" +} + +variable "subnetwork" { + type = string + description = "The subnetwork to host the cluster in" +} + +variable "poller_sa_email" { + type = string + description = "The email of the poller service account" +} + +variable "scaler_sa_email" { + type = string + description = "The email of the scaler service account" +} + +variable "terraform_memorystore_cluster" { + type = bool + description = "If set to true, Terraform will create a test Memorystore cluster" +} + +variable "dns_zone" { + description = "The DNS zone to use" +} + +variable "memorystore_shard_count" { + type = number +} + +variable "memorystore_replica_count" { + type = number +} diff --git a/terraform/modules/autoscaler-monitoring/dashboard.json.tftpl b/terraform/modules/autoscaler-monitoring/dashboard.json.tftpl new file mode 100644 index 0000000..bf07486 --- /dev/null +++ b/terraform/modules/autoscaler-monitoring/dashboard.json.tftpl @@ -0,0 +1,322 @@ +{ + "dashboardFilters": [], + "displayName": "Memorystore Cluster - Autoscaler Dashboard", + "labels": {}, + "mosaicLayout": { + "columns": 12, + "tiles": [ + { + "height": 4, + "widget": { + "title": "CPU Utilization", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "breakdowns": [], + "dimensions": [], + "legendTemplate": "$${metric.labels.role} (average)", + "measures": [], + "minAlignmentPeriod": "120s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "alignmentPeriod": "120s", + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"redis.googleapis.com/cluster/cpu/average_utilization\" resource.type=\"redis.googleapis.com/Cluster\" resource.label.\"cluster_id\"=\"${memorystore_cluster_name}\" resource.label.\"location\"=\"${region}\" metric.labels.\"role\"=\"primary\"" + } + } + }, + { + "breakdowns": [], + "dimensions": [], + "legendTemplate": "$${metric.labels.role} (maximum)", + "measures": [], + "minAlignmentPeriod": "120s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "alignmentPeriod": "120s", + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"redis.googleapis.com/cluster/cpu/maximum_utilization\" resource.type=\"redis.googleapis.com/Cluster\" resource.label.\"cluster_id\"=\"${memorystore_cluster_name}\" resource.label.\"location\"=\"${region}\" metric.labels.\"role\"=\"primary\"" + } + } + } + ], + "thresholds": [ + { + "label": "Average threshold out", + "targetAxis": "Y1", + "value": "0.8" + }, + { + "label": "Max threshold out", + "targetAxis": "Y1", + "value": "0.75" + }, + { + "label": "Average threshold in", + "targetAxis": "Y1", + "value": "0.75" + }, + { + "label": "Max threshold in", + "targetAxis": "Y1", + "value": "0.7" + } + ], + "yAxis": { + "label": "", + "scale": "LINEAR" + } + } + }, + "width": 6 + }, + { + "height": 4, + "widget": { + "title": "Usable Memory Utilization", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "breakdowns": [], + "dimensions": [], + "legendTemplate": "average", + "measures": [], + "minAlignmentPeriod": "120s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "alignmentPeriod": "120s", + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"redis.googleapis.com/cluster/memory/average_utilization\" resource.type=\"redis.googleapis.com/Cluster\" resource.label.\"cluster_id\"=\"${memorystore_cluster_name}\" resource.label.\"location\"=\"${region}\"" + } + } + }, + { + "breakdowns": [], + "dimensions": [], + "legendTemplate": "maximum", + "measures": [], + "minAlignmentPeriod": "120s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "alignmentPeriod": "120s", + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"redis.googleapis.com/cluster/memory/maximum_utilization\" resource.type=\"redis.googleapis.com/Cluster\" resource.label.\"cluster_id\"=\"${memorystore_cluster_name}\" resource.label.\"location\"=\"${region}\"" + } + } + } + ], + "thresholds": [ + { + "label": "Average threshold out", + "targetAxis": "Y1", + "value": "0.8" + }, + { + "label": "Max threshold out", + "targetAxis": "Y1", + "value": "0.9" + }, + { + "label": "Average threshold in", + "targetAxis": "Y1", + "value": "0.7" + }, + { + "label": "Max threshold in", + "targetAxis": "Y1", + "value": "0.6" + } + ], + "yAxis": { + "label": "", + "scale": "LINEAR" + } + } + }, + "width": 6, + "xPos": 6 + }, + { + "height": 4, + "widget": { + "title": "Evicted Keys", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "breakdowns": [], + "dimensions": [], + "measures": [], + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "alignmentPeriod": "60s", + "crossSeriesReducer": "REDUCE_MEAN", + "groupByFields": [], + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"redis.googleapis.com/cluster/stats/average_evicted_keys\" resource.type=\"redis.googleapis.com/Cluster\" resource.label.\"cluster_id\"=\"${memorystore_cluster_name}\" resource.label.\"location\"=\"${region}\"" + } + } + }, + { + "breakdowns": [], + "dimensions": [], + "measures": [], + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "alignmentPeriod": "60s", + "crossSeriesReducer": "REDUCE_MEAN", + "groupByFields": [], + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"redis.googleapis.com/cluster/stats/maximum_evicted_keys\" resource.type=\"redis.googleapis.com/Cluster\" resource.label.\"cluster_id\"=\"${memorystore_cluster_name}\" resource.label.\"location\"=\"${region}\"" + } + } + } + ], + "thresholds": [], + "yAxis": { + "label": "", + "scale": "LINEAR" + } + } + }, + "width": 6, + "yPos": 4 + }, + { + "height": 4, + "widget": { + "title": "Memory Size and Usage", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "breakdowns": [], + "dimensions": [], + "measures": [], + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "alignmentPeriod": "60s", + "crossSeriesReducer": "REDUCE_MEAN", + "groupByFields": [], + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"redis.googleapis.com/cluster/memory/size\" resource.type=\"redis.googleapis.com/Cluster\" resource.label.\"cluster_id\"=\"${memorystore_cluster_name}\" resource.label.\"location\"=\"${region}\"" + } + } + }, + { + "breakdowns": [], + "dimensions": [], + "measures": [], + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "alignmentPeriod": "60s", + "crossSeriesReducer": "REDUCE_MEAN", + "groupByFields": [], + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"redis.googleapis.com/cluster/memory/total_used_memory\" resource.type=\"redis.googleapis.com/Cluster\" resource.label.\"cluster_id\"=\"${memorystore_cluster_name}\" resource.label.\"location\"=\"${region}\"" + } + } + } + ], + "thresholds": [], + "yAxis": { + "label": "", + "scale": "LINEAR" + } + } + }, + "width": 6, + "xPos": 6, + "yPos": 4 + }, + { + "height": 4, + "widget": { + "title": "Scaling Duration", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "breakdowns": [], + "dimensions": [], + "measures": [], + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "alignmentPeriod": "60s", + "crossSeriesReducer": "REDUCE_PERCENTILE_99", + "groupByFields": [ + "metric.label.\"scaling_method\"", + "metric.label.\"scaling_direction\"" + ], + "perSeriesAligner": "ALIGN_DELTA" + }, + "filter": "metric.type=\"workload.googleapis.com/googlecloudplatform/memorystore-cluster-autoscaler/scaler/scaling-duration\" metric.label.\"memorystore_cluster_instance_id\"=\"${memorystore_cluster_name}\"" + } + } + } + ], + "thresholds": [], + "yAxis": { + "label": "", + "scale": "LINEAR" + } + } + }, + "width": 6, + "yPos": 8 + } + ] + } +} diff --git a/terraform/modules/autoscaler-monitoring/main.tf b/terraform/modules/autoscaler-monitoring/main.tf new file mode 100644 index 0000000..1c90a55 --- /dev/null +++ b/terraform/modules/autoscaler-monitoring/main.tf @@ -0,0 +1,30 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +resource "google_monitoring_dashboard" "dashboard" { + project = var.project_id + + dashboard_json = templatefile("${path.module}/dashboard.json.tftpl", { + region = var.region + memorystore_cluster_name = var.memorystore_cluster_name + }) + + lifecycle { + ignore_changes = [ + dashboard_json + ] + } +} diff --git a/terraform/modules/autoscaler-monitoring/outputs.tf b/terraform/modules/autoscaler-monitoring/outputs.tf new file mode 100644 index 0000000..4f0b157 --- /dev/null +++ b/terraform/modules/autoscaler-monitoring/outputs.tf @@ -0,0 +1,20 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "dashboard_id" { + value = google_monitoring_dashboard.dashboard.id + description = "Dashboard ID of important Memorystore Cluster metrics." +} diff --git a/terraform/modules/autoscaler-monitoring/variables.tf b/terraform/modules/autoscaler-monitoring/variables.tf new file mode 100644 index 0000000..c684d0f --- /dev/null +++ b/terraform/modules/autoscaler-monitoring/variables.tf @@ -0,0 +1,27 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "memorystore_cluster_name" { + type = string +} diff --git a/terraform/modules/autoscaler-network/main.tf b/terraform/modules/autoscaler-network/main.tf new file mode 100644 index 0000000..f9d0f32 --- /dev/null +++ b/terraform/modules/autoscaler-network/main.tf @@ -0,0 +1,82 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + provider_meta "google" { + module_name = "cloud-solutions/memorystore-cluster-autoscaler-deploy-network-v1.0" + } +} + +resource "google_compute_network" "autoscaler_network" { + name = "memorystore-cluster-autoscaler-network" + auto_create_subnetworks = false +} + +resource "google_compute_subnetwork" "autoscaler_subnetwork" { + name = "memorystore-cluster-autoscaler-subnetwork" + network = google_compute_network.autoscaler_network.id + ip_cidr_range = var.ip_range + private_ip_google_access = true +} + +resource "google_compute_router" "router" { + name = "app-router" + region = var.region + network = google_compute_network.autoscaler_network.id +} + +resource "google_compute_router_nat" "nat" { + name = "memorystore-cluster-autoscaler-nat" + router = google_compute_router.router.name + region = google_compute_router.router.region + nat_ip_allocate_option = "AUTO_ONLY" + source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES" + + log_config { + enable = true + filter = "ERRORS_ONLY" + } +} + +resource "google_network_connectivity_service_connection_policy" "policy" { + name = "memorystore-cluster-autoscaler-policy" + location = var.region + service_class = "gcp-memorystore-redis" + description = "Basic service connection policy" + network = google_compute_network.autoscaler_network.id + project = var.project_id + + psc_config { + subnetworks = [google_compute_subnetwork.autoscaler_subnetwork.id] + } +} + +resource "google_dns_managed_zone" "private_zone" { + name = "memorystore-cluster-autoscaler-private-zone" + dns_name = "memorystore.private." + description = "Private DNS zone for Memorystore Cluster Autoscaler" + + visibility = "private" + labels = { + "managed-by" = "terraform" + } + + private_visibility_config { + networks { + network_url = google_compute_network.autoscaler_network.self_link + } + } +} diff --git a/terraform/modules/autoscaler-network/outputs.tf b/terraform/modules/autoscaler-network/outputs.tf new file mode 100644 index 0000000..7853c7f --- /dev/null +++ b/terraform/modules/autoscaler-network/outputs.tf @@ -0,0 +1,35 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "network" { + value = google_compute_network.autoscaler_network.id +} + +output "subnetwork" { + value = google_compute_subnetwork.autoscaler_subnetwork.id +} + +output "network_name" { + value = google_compute_network.autoscaler_network.name +} + +output "subnetwork_name" { + value = google_compute_subnetwork.autoscaler_subnetwork.name +} + +output "dns_zone" { + value = google_dns_managed_zone.private_zone +} diff --git a/terraform/modules/autoscaler-network/variables.tf b/terraform/modules/autoscaler-network/variables.tf new file mode 100644 index 0000000..6dba98d --- /dev/null +++ b/terraform/modules/autoscaler-network/variables.tf @@ -0,0 +1,30 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string + description = "Project ID where the cluster will run" +} + +variable "region" { + type = string + description = "The name of the region to create the network" +} + +variable "ip_range" { + type = string + description = "The range for the network" +} diff --git a/terraform/modules/autoscaler-scheduler/main.tf b/terraform/modules/autoscaler-scheduler/main.tf new file mode 100644 index 0000000..ab3446c --- /dev/null +++ b/terraform/modules/autoscaler-scheduler/main.tf @@ -0,0 +1,85 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +locals { + config = var.json_config != "" ? var.json_config : base64encode(jsonencode([ + merge({ + "projectId" : "${var.project_id}", + "regionId" : "${var.location}", + "clusterId" : "${var.memorystore_cluster_name}", + "scalerPubSubTopic" : "${var.target_pubsub_topic}", + "units" : "${var.units}", + "minSize" : var.min_size, + "maxSize" : var.max_size, + "scalingProfile" : "${var.scaling_profile}", + "scalingMethod" : "${var.scaling_method}", + "stateDatabase" : var.terraform_spanner_state ? { + "name" : "spanner", + "instanceId" : "${var.spanner_state_name}", + "databaseId" : "${var.spanner_state_database}", + } : { + "name" : "firestore", + } + }, + var.state_project_id != null ? { + "stateProjectId" : "${var.state_project_id}" + } : {}) + ])) +} + +resource "google_app_engine_application" "app" { + project = var.project_id + location_id = var.location == "us-central1" ? "us-central" : var.location +} + +resource "google_cloud_scheduler_job" "poller_job" { + name = "poll-cluster-metrics" + description = "Poll metrics for the configured Memorystore cluster" + schedule = var.schedule + time_zone = var.time_zone + + pubsub_target { + topic_name = var.pubsub_topic + data = local.config + } + + retry_config { + retry_count = 0 + max_backoff_duration = "3600s" + max_retry_duration = "0s" + max_doublings = 5 + min_backoff_duration = "5s" + } + + depends_on = [google_app_engine_application.app] + + /** + * Uncomment this stanza if you would prefer to manage the Cloud Scheduler + * configuration manually following its initial creation, i.e. using the + * Google Cloud Web Console, the gcloud CLI, or any other non-Terraform + * mechanism. Without this change, the Terraform configuration will remain + * the source of truth, and any direct modifications to the Cloud Scheduler + * configuration will be reset on the next Terraform run. Please see the + * following link for more details: + * + * https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle#ignore_changes + */ + /* + lifecycle { + ignore_changes = [pubsub_target[0].data] + } + */ +} diff --git a/terraform/modules/autoscaler-scheduler/outputs.tf b/terraform/modules/autoscaler-scheduler/outputs.tf new file mode 100644 index 0000000..1f2ffa3 --- /dev/null +++ b/terraform/modules/autoscaler-scheduler/outputs.tf @@ -0,0 +1,20 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "scheduler_job_id" { + value = google_cloud_scheduler_job.poller_job.id + description = "ID of the Scheduler job" +} diff --git a/terraform/modules/autoscaler-scheduler/variables.tf b/terraform/modules/autoscaler-scheduler/variables.tf new file mode 100644 index 0000000..c01caad --- /dev/null +++ b/terraform/modules/autoscaler-scheduler/variables.tf @@ -0,0 +1,105 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string +} + +variable "location" { + type = string +} + +variable "schedule" { + type = string + default = "*/1 * * * *" +} + +variable "time_zone" { + type = string + default = "Etc/UTC" +} + +variable "pubsub_topic" { + type = string +} + +variable "memorystore_cluster_name" { + type = string +} + +variable "target_pubsub_topic" { + type = string +} + +variable "units" { + type = string + default = "SHARDS" + description = "The measure that Memorystore Cluster size is being specified in. Currently supported values are: \"SHARDS\". " +} + +variable "min_size" { + type = number + default = 3 + description = "Minimum size that the Memorystore Cluster can be scaled in to." +} + +variable "max_size" { + type = number + default = 10 + description = "Maximum size that the Memorystore Cluster can be scaled out to." +} + +variable "scaling_profile" { + type = string + default = "CPU_AND_MEMORY" + description = "Scaling profile to be used for the Memorystore Cluster: CPU_AND_MEMORY, CPU, MEMORY" +} + +variable "scaling_method" { + type = string + default = "STEPWISE" + description = "Algorithm that should be used to manage the scaling of the cluster: STEPWISE, LINEAR, DIRECT" +} + +variable "terraform_spanner_state" { + description = "If set to true, Terraform will create a Cloud Spanner DB to hold the Autoscaler state." + type = bool + default = false +} + +variable "spanner_state_name" { + type = string + nullable = true + default = null +} + +variable "spanner_state_database" { + type = string + nullable = true + default = null +} + +variable "state_project_id" { + type = string + nullable = true + default = null +} + +variable "json_config" { + type = string + default = "" + description = "Base 64 encoded json that is the autoscaler configuration for the Cloud Scheduler payload. Using this allows for setting autoscaler configuration for multiple Memorystore Clusters and parameters that are not directly exposed through variables." +} diff --git a/terraform/modules/autoscaler-spanner/main.tf b/terraform/modules/autoscaler-spanner/main.tf new file mode 100644 index 0000000..66d9e5d --- /dev/null +++ b/terraform/modules/autoscaler-spanner/main.tf @@ -0,0 +1,65 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +resource "google_spanner_instance" "state_spanner_instance" { + count = var.terraform_spanner_state ? 1 : 0 + + name = var.spanner_state_name + config = "regional-${var.region}" + display_name = var.spanner_state_name + project = var.project_id + + processing_units = var.spanner_state_processing_units +} + +resource "google_spanner_database" "state_database" { + count = var.terraform_spanner_state ? 1 : 0 + + instance = var.spanner_state_name + name = var.spanner_state_database + ddl = [ + < /etc/security/limits.d/test-vm-limits.conf +root soft nofile 1048576 +root hard nofile 1048576 +* soft nofile 1048576 +* hard nofile 1048576 +EOF + +# Write instructional banner +cat << 'EOF' > /etc/motd + + __ ___ __ ______ __ __ __ + / |/ /__ ____ ___ ____ _______ _______/ /_____ ________ /_ __/__ _____/ /_/ /_ ___ ____ _____/ /_ + / /|_/ / _ \/ __ `__ \/ __ \/ ___/ / / / ___/ __/ __ \/ ___/ _ \ / / / _ \/ ___/ __/ __ \/ _ \/ __ \/ ___/ __ \ + / / / / __/ / / / / / /_/ / / / /_/ (__ ) /_/ /_/ / / / __/ / / / __(__ ) /_/ /_/ / __/ / / / /__/ / / / +/_/ /_/\___/_/ /_/ /_/\____/_/ \__, /____/\__/\____/_/ \___/ /_/ \___/____/\__/_.___/\___/_/ /_/\___/_/ /_/ + /____/ + + Generate CPU load: $ memorystore-cpu-load + Bulk-write to increase memory utilisation (1GB): $ memorystore-write-1gb + Bulk-write to increase memory utilisation (10GB): $ memorystore-write-10gb + Flush all keys: $ memorystore-flush-all + Connect to the cluster in interactive mode: $ redis-cli -c -h cluster.memorystore.private + + Functions are defined in /etc/profile.d/memorystore-functions.sh. + + Note that the underlying utilities may take a few minutes to become available on first boot. + +EOF + +# Install dependencies +apt-get update && apt-get upgrade -y +apt-get install redis-tools build-essential autoconf automake libpcre3-dev libevent-dev pkg-config zlib1g-dev git libssl-dev htop -y + +# Install utility for load generation +export reddissim_version='v0.1.7' +export golang_version='1.21.4' +cd /root +git clone https://github.com/maguec/RedisSim.git && cd RedisSim +git checkout ${reddissim_version} +wget https://go.dev/dl/go${golang_version}.linux-amd64.tar.gz +tar -C /usr/local -xzf go${golang_version}.linux-amd64.tar.gz +export HOME=/root +export GOPATH=${HOME}/go +export GOMODCACHE=${GOPATH}/pkg/mod +export PATH=$PATH:/usr/local/go/bin +make +mv RedisSim /usr/local/bin/RedisSim + +# Define commands and make available +memorystore_cpu_load_cmd="RedisSim cpukill --clients 100 --size 1000 --loop-forever --server cluster.memorystore.private --cluster" +memorystore_write_1gb_cmd="RedisSim stringfill --prefix \$(date +'%s') --clients 100 --size 1000 --string-count 1000000 --server cluster.memorystore.private --cluster" +memorystore_write_10gb_cmd="RedisSim stringfill --prefix \$(date +'%s') --clients 100 --size 1000 --string-count 10000000 --server cluster.memorystore.private --cluster" +memorystore_flush_all_cmd="redis-cli --cluster call --cluster-only-masters cluster.memorystore.private:6379 FLUSHALL" + +cat << EOF > /etc/profile.d/memorystore-functions.sh +function memorystore-cpu-load () { + ${memorystore_cpu_load_cmd} +} +function memorystore-write-10gb () { + ${memorystore_write_10gb_cmd} +} +function memorystore-write-1gb () { + ${memorystore_write_1gb_cmd} +} +function memorystore-flush-all () { + ${memorystore_flush_all_cmd} +} +export -f memorystore-cpu-load +export -f memorystore-write-10gb +export -f memorystore-write-1gb +export -f memorystore-flush-all +EOF diff --git a/terraform/modules/autoscaler-test-vm/variables.tf b/terraform/modules/autoscaler-test-vm/variables.tf new file mode 100644 index 0000000..8f5820b --- /dev/null +++ b/terraform/modules/autoscaler-test-vm/variables.tf @@ -0,0 +1,51 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string + description = "Project ID where the cluster will run" +} + +variable "name" { + type = string + description = "A unique name for the resource" +} + +variable "region" { + type = string + description = "The name of the region to run the cluster" +} + +variable "network" { + type = string + description = "The subnetwork to host the cluster in" +} + +variable "subnetwork" { + type = string + description = "The subnetwork to host the cluster in" +} + +variable "machine_type" { + type = string + description = "The machine type to use for the test VM" + default = "c3-standard-4" +} + +variable "machine_image" { + type = string + default = "debian-cloud/debian-12" +}