diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a998cbc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +### Description + +_Provide a description of what has been changed_ + +### Checklist + +_Please check if applicable_ + +- [ ] Tests have been added (if applicable, ie. when cli codes are added or modified) +- [ ] Relevant docs have been added or modified (if applicable, ie. when new features are added or current features are modified) + +Relevant issue # diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..252882a --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,50 @@ +name: CI/CD for main branch + +on: + push: + branches: + - 'main' + pull_request: + branches: + - 'main' + types: [opened, synchronize] + +permissions: + contents: 'read' + actions: 'read' + +jobs: + check-lint: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + cache: false + + - name: lint + uses: golangci/golangci-lint-action@v3 + # NOTE: lint target list is defined at https://golangci-lint.run/usage/linters/#enabled-by-default-linters + with: + # Require: The version of golangci-lint to use. + # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. + # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. + version: v1.53 + args: --timeout=10m + + check-test: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + cache: false + + - name: test + run: | + go test -v ./... -timeout 120s + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73731f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.go-version diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0d829d2 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,12 @@ +linters: + enable: + - lll +linters-settings: + lll: + # Max line length, lines longer will be reported. + # '\t' is counted as 1 character by default, and can be changed with the tab-width option. + # Default: 120. + line-length: 120 + # Tab width in spaces. + # Default: 1 + tab-width: 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c242f14 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.jp.md b/README.jp.md new file mode 100644 index 0000000..2768a93 --- /dev/null +++ b/README.jp.md @@ -0,0 +1,94 @@ +# Gatling Commander +## Gatling Commanderとは? +Gatling Commanderは、[Gatling Operator](https://github.com/st-tech/gatling-operator)を使用した負荷試験実施における一連の作業を自動化するCLIツールです。 +Gatling Operatorとは、オープンソースの負荷試験ツールである[Gatling](https://gatling.io/)を利用して、自動分散負荷試験を行うためのKubernetes Operatorです。 +## 特徴 +負荷試験シナリオを設定ファイルに記述すれば、自動的に負荷試験を実施し結果を記録することができます。 + +Gatling Commanderにより次の作業が自動化されます。 +- 負荷試験ごとのシナリオに応じたGatlingオブジェクトの作成 +- Gatling Imageのビルド +- 過負荷時の負荷試験自動停止 +- 負荷試験ごとにGatling Report、コンテナメトリクスを記録 +- 実行中の負荷試験の実施状況確認 + +またGatling Commanderでは、設定ファイルに複数の負荷試験シナリオを記述可能です。 + +設定ファイルの作成後に、`gatling-commander`コマンドを実行すると、Gatling Commanderは全ての負荷試験を実施し、結果を[Google Sheets](https://www.google.com/sheets/about/)に書き込みます。 +また、負荷試験の完了ステータスを[Slack](https://slack.com)通知するように設定することも可能です。 + +設定ファイルの各フィールドの説明は[User Guide](./docs/user-guide.jp.md)に記載しています。 + +以下は設定ファイル(`config/config.yaml`)の記入例です。 + +```yaml +gatlingContextName: gatling-cluster-context-name +imageRepository: gatling-image-stored-repository-url +imagePrefix: gatlinge-image-name-prefix +imageURL: "" # (Optional) specify image url when using pre build gatling container image +baseManifest: config/base_manifest.yaml +gatlingDockerfileDir: gatling +startupTimeoutSec: 1800 # 30min +execTimeoutSec: 10800 # 3h +slackConfig: + webhookURL: slack-webhook-url + mentionText: <@targetMemberID> +services: + - name: sample-service + spreadsheetID: sample-sheets-id + failFast: false + targetPercentile: 99 # (%ile) + targetLatency: 500 # (ms) + targetPodConfig: + contextName: target-pod-context-name + namespace: sample-namespace + labelKey: run + labelValue: sample-api + containerName: sample-api + scenarioSpecs: + - name: case-1 + subName: 10rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "10" + - name: DURATION + value: "180" + - name: case-2 + subName: 20rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "20" + - name: DURATION + value: "180" + +``` + +## 必須条件 +Gatling CommanderはGatling Operatorを使った負荷試験での利用を前提としています。 +利用時はまず、[Gatling OperatorのQuick Start Guide](https://github.com/st-tech/gatling-operator/blob/main/docs/quickstart-guide.md)を参考にGatling Operatorを利用可能な環境を構築してください。 + +## Google Cloud以外の環境での利用 +Gatling Operatorがサポートしている実行環境のうち、Gatling Commanderでは現状[Google Cloud](https://cloud.google.com/)での利用のみサポートしています。 + +## クイックスタート +- [Quick Start Guide](./docs/quickstart-guide.jp.md) + +## ドキュメント +- [User Guide](./docs/user-guide.jp.md) +- [Developer Guide](./docs/developer.jp.md) + +## Contributing +IssueやPull Requestの作成など、コントリビューションは誰でも歓迎です。コントリビューターは[Contributor Covenant](https://contributor-covenant.org/)を遵守することを期待します。 + +## License +Gatling CommanderはMITライセンスを適応してオープンソースとして公開しています。[LICENSE](./LICENSE) を参照してください。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0d6a5a --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Gatling Commander +日本語版READMEは[こちら](./README.jp.md) +## What is Gatling Commander ? +Gatling Commander is a CLI tool that automates a series of tasks in the execution of load test using [Gatling Operator](https://github.com/st-tech/gatling-operator). +Gatling Operator is a Kubernetes Operator for running automated distributed Gatling load test. + +## Features +By writing load test scenarios in the configuration file, Gatling Commander automatically run load test and record the results. + +Gatling Commander automates the following tasks. +- Create Gatling objects for each load test +- Build Gatling image +- Stop load test when result latency exceeds a predefined threshold +- Record Gatling Report and target container metrics for each load test +- Check running load test status + +In addition, Gatling Commander allow to have multiple load test scenarios in the configuration file. + +After preparing the configuration file, run the `gatling-commander` command, this will automatically run all load test and record the results to [Google Sheets](https://www.google.com/sheets/about/). +Gatling Commander notify load test finished status to [Slack](https://slack.com) as configured in the configuration file. + +Please refer to [User Guide](./docs/user-guide.md) about details of each field in the configuration. + +Here is an example of how to fill out the configuration file (`config.yaml`). +```yaml +gatlingContextName: gatling-cluster-context-name +imageRepository: gatling-image-stored-repository-url +imagePrefix: gatlinge-image-name-prefix +imageURL: "" # (Optional) specify image url when using pre build gatling container image +baseManifest: config/base_manifest.yaml +gatlingDockerfileDir: gatling +startupTimeoutSec: 1800 # 30min +execTimeoutSec: 10800 # 3h +slackConfig: + webhookURL: slack-webhook-url + mentionText: <@targetMemberID> +services: + - name: sample-service + spreadsheetID: sample-sheets-id + failFast: false + targetPercentile: 99 # (%ile) + targetLatency: 500 # (ms) + targetPodConfig: + contextName: target-pod-context-name + namespace: sample-namespace + labelKey: run + labelValue: sample-api + containerName: sample-api + scenarioSpecs: + - name: case-1 + subName: 10rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "10" + - name: DURATION + value: "180" + - name: case-2 + subName: 20rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "20" + - name: DURATION + value: "180" + +``` + +## Requirements +Gatling Commander is intended for use in load test with the Gatling Operator. +When using Gatling Commander, please create an environment in which the Gatling Operator can be used first. Information about how to setup Gatling Operator environment, please refer to the Gatling Operator [Quick Start Guide](https://github.com/st-tech/gatling-operator/blob/main/docs/quickstart-guide.md). + +## Quick Start +- [Quick Start Guide](./docs/quickstart-guide.md) + +## Documentations +- [User Guide](./docs/user-guide.md) +- [Developer Guide](./docs/developer.md) + +## Contributing +Please make a GitHub issue or pull request to help us improve this CLI. We expect contributors to comply with the [Contributor Covenant](https://contributor-covenant.org/). + + +## License +Gatling Commander is available as open source under the terms of the MIT License. For more details, see the [LICENSE](./LICENSE) file. diff --git a/config/base_manifest.yaml b/config/base_manifest.yaml new file mode 100644 index 0000000..9d2cd7f --- /dev/null +++ b/config/base_manifest.yaml @@ -0,0 +1,47 @@ +apiVersion: gatling-operator.tech.zozo.com/v1alpha1 +kind: Gatling +metadata: + name: # will be overrided by services[].name field value in config.yaml. ex: sample-service + namespace: gatling +spec: + generateReport: true + generateLocalReport: true + notifyReport: false + cleanupAfterJobDone: false + podSpec: + gatlingImage: # will be overrided by built Gatling Image URL or imageURL field value in config.yaml. ex: asia-docker.pkg.dev/project_id/foo/bar/gatlinge-image-name-prefix-YYYYMMDD + rcloneImage: rclone/rclone + resources: + requests: + cpu: "7000m" + memory: "4G" + limits: + cpu: "7000m" + memory: "4G" + serviceAccountName: "gatling-operator-worker-service-account" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cloud.google.com/gke-nodepool + operator: In + values: + - "gatling-operator-worker-pool" + tolerations: + - key: "dedicated" + operator: "Equal" + value: "gatling-operator-worker-pool" + effect: "NoSchedule" + cloudStorageSpec: + provider: "gcp" + bucket: "report-storage-bucket-name" + notificationServiceSpec: + provider: "slack" + secretName: "gatling-notification-slack-secrets" + testScenarioSpec: + parallelism: # will be overrided by services[].scenarioSpecs[].testScenarioSpec.parallelism field value. ex: 1 + simulationClass: # will be overrided by services[].scenarioSpecs[].testScenarioSpec.simulationClass field value. ex: SampleSimulation + env: # will be overrided by services[].scenarioSpecs[].testScenarioSpec.env[] field value. ex: `env: [{name: ENV, value: "dev"}, {name: CONCURRENCY, value: "20"}]` + - name: + value: diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..aed8b42 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,48 @@ +gatlingContextName: gatling-cluster-context-name +imageRepository: gatling-image-stored-repository-url +imagePrefix: gatlinge-image-name-prefix +imageURL: "" # (Optional) specify image url when using pre build gatling container image +baseManifest: config/base_manifest.yaml +gatlingDockerfileDir: gatling +startupTimeoutSec: 1800 # 30min +execTimeoutSec: 10800 # 3h +slackConfig: + webhookURL: slack-webhook-url + mentionText: <@targetMemberID> +services: + - name: sample-service + spreadsheetID: sample-sheets-id + failFast: false + targetPercentile: + targetLatency: + targetPodConfig: + contextName: target-pod-context-name + namespace: sample-namespace + labelKey: run + labelValue: sample-api + containerName: sample-api + scenarioSpecs: + - name: case-1 + subName: 10rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "10" + - name: DURATION + value: "180" + - name: case-2 + subName: 20rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "20" + - name: DURATION + value: "180" diff --git a/docs/developer.jp.md b/docs/developer.jp.md new file mode 100644 index 0000000..140b121 --- /dev/null +++ b/docs/developer.jp.md @@ -0,0 +1,97 @@ +# Gatling Commanderの開発方法 +- [Gatling Commanderの開発方法](#gatling-commanderの開発方法) + - [ローカルでの実行](#ローカルでの実行) + - [テストの実行](#テストの実行) + - [lint・コードフォーマットの実行](#lintコードフォーマットの実行) + - [コードフォーマット](#コードフォーマット) + - [lint](#lint) + - [ローカル環境での実行](#ローカル環境での実行) + - [CIでの実行](#ciでの実行) + - [パッケージの追加方法](#パッケージの追加方法) + - [GoDoc](#godoc) + - [CI](#ci) + - [gatlingディレクトリについて](#gatlingディレクトリについて) + +開発時に必要な情報について記載します。 + +## ローカルでの実行 +ローカル環境での実行には、プロジェクトルートディレクトリ配下で次のコマンドを実行します。 +``` +go run main.go exec --config "config/config.yaml" +``` +上記コマンドを動作させるにあたり、事前準備が必要になります。 +事前準備については[Quick Start Guide](./quickstart-guide.jp.md)を参照してください。 + +## テストの実行 +プロジェクトルートディレクトリで次のコマンドを実行することで単体テストが実行できます。 +テスト時に無限ループとなることを避けるため、コマンド実行時に引数でタイムアウト時間を指定することを推奨します。 + +``` +go test -v ./... -timeout 120s +``` + +## lint・コードフォーマットの実行 +### コードフォーマット +プロジェクトルートディレクトリで次のコマンドを実行することでコードがフォーマットされます。 + +``` +go fmt ./... +``` + +### lint +lintには[golangci-lint](https://github.com/golangci/golangci-lint)を利用しています。 +Macであれば次のようにbrewでインストールできます。 +``` +brew install golangci-lint +``` +その他のインストール方法は[公式ドキュメント](https://golangci-lint.run/usage/install/)を参照ください。 + +#### ローカル環境での実行 +``` +golangci-lint run ./... +``` + +#### CIでの実行 +Pull RequestのPush時にCIでlintのチェックが走ります。 +golangci-lintにより問題のある箇所にコメントが付けられます。内容を確認して修正してください。 + +## パッケージの追加方法 +パッケージ追加の際はimport文を追加し、次のコマンドを実行することで依存関係の解決とインストールがされます。 +パッケージ管理にはgo.modファイルとgo.sumファイルを利用しています。どちらも次のコマンドで更新されるため、手動で編集することはありません。 + +``` +go mod tidy +``` + +Go自体のバージョンを更新する際は、新しいバージョンのGoを環境にインストールし、次のコマンドでgo.modの更新を行なってください。 + +``` +go mod tidy -go=${VERSION} +``` + +## GoDoc +次のコマンドによりGoDocでパッケージのドキュメントを提供するWebサーバーが起動します。 + +```bash +# 事前準備 +ln -s $(pwd) ${GOROOT}/src + +# ドキュメントの表示 +go run golang.org/x/tools/cmd/godoc -http=:6060 +``` + +コマンド実行後に`localhost:6060`にアクセスすることで、モジュールで利用しているパッケージのドキュメントを閲覧できます。 + +デフォルトではexportされている関数、変数のドキュメントのみ表示されます。 +全ての関数、変数のドキュメントを表示するには`localhost:6060?m=all`へアクセスします。 + +## CI +Pull RequestのPush時に`main.yaml`に記載のワークフローがトリガされます。 +CIでは次の項目のチェックを行なっています。 +- lint +- test + +## gatlingディレクトリについて +`gatling`ディレクトリはGatling Operatorの実行に必要なファイルを含むディレクトリです。 +更新する際は[st-tech/gatling-operator/gatling](https://github.com/st-tech/gatling-operator/tree/main/gatling)をコピーしてください。 +詳細は[What is this `gatling` directory?](../gatling/README.md)を参照してください。 diff --git a/docs/developer.md b/docs/developer.md new file mode 100644 index 0000000..7fa4ba1 --- /dev/null +++ b/docs/developer.md @@ -0,0 +1,97 @@ +# How to develop Gatling Commander +日本語版Developer Guideは[こちら](./developer.jp.md) + +- [How to develop Gatling Commander](#how-to-develop-gatling-commander) + - [Run command when developing](#run-command-when-developing) + - [Run unit test](#run-unit-test) + - [Run lint and code format](#run-lint-and-code-format) + - [Code format](#code-format) + - [lint](#lint) + - [Run lint in local environment](#run-lint-in-local-environment) + - [Check lint in CI](#check-lint-in-ci) + - [Add package](#add-package) + - [GoDoc](#godoc) + - [CI](#ci) + - [Abount gatling directory](#abount-gatling-directory) + +This describes the information about how to develop Gatling Commander. + +## Run command when developing +In your local environment, you can run with this command in project root directory. +```bash +go run main.go exec --config "config/config.yaml" +``` +By running this command, some preparation is needed. Information about how to preparate for run Gatling Commander, please refer to [Quick Start Guide](./quickstart-guide.md). + +## Run unit test +By running this command in project root directory, all unit tests will be run. +It is recommended to set timeout with command arguments to avoid infinite loop when running unit tests. + +```bash +go test -v ./... -timeout 120s +``` + +## Run lint and code format +### Code format +By running this command in project root directory, code will be formatted. + +```bash +go fmt ./... +``` + +### lint +We use [golangci-lint](https://github.com/golangci/golangci-lint) for lint. +If you are on a Mac, you can install it with brew as follows. + +```bash +brew install golangci-lint +``` +About other way, please refer to golangci-lint [installation document](https://golangci-lint.run/usage/install/). + +#### Run lint in local environment +```bash +golangci-lint run ./... +``` + +#### Check lint in CI +When pushing a Pull Request to head branch, GitHub Actions workflow will be run and check lint with golangci-lint. +If something goes wrong during the lint check, golangci-lint will add a comment to the line abount what failed to lint. Please check the details and correct the commented line. + +## Add package +When adding Go packages, add an import statement and execute the following command to resolve dependencies and install the packages. +The `go.mod` and `go.sum` files are used to manage dependencies. Both are updated by the following command, so you don't need to update them by yourself. + +```bash +go mod tidy +``` + +To update the Go version, please install a newer version of Go and update the `go.mod` file with the following command. + +```bash +go mod tidy -go=${VERSION} +``` + +## GoDoc +To read package documents with GoDoc, please run the following command and run webserver. + +```bash +# preparation +ln -s $(pwd) ${GOROOT}/src + +# run webserver +go run golang.org/x/tools/cmd/godoc -http=:6060 +``` + +After the webserver is started, the documentation for the package used by Gatling Commander is displayed with access to `localhost:6060`. + +By default, only documents of exported functions and variables are displayed. To see documents of all functions and variables, access to `localhost:6060?m=all`. + +## CI +We use GitHub Actions as CI. The workflow described in `main.yaml` is triggered when a Pull Request is pushed. +This workflow checks the following items. +- lint +- test + +## Abount gatling directory +The `gatling` directory has some files which is necessary to run Gatling Operator. +Please copy [st-tech/gatling-operator/gatling](https://github.com/st-tech/gatling-operator/tree/main/gatling) when you update. For more information about this directory, please refer to [What is this `gatling` directory?](../gatling/README.md). diff --git a/docs/quickstart-guide.jp.md b/docs/quickstart-guide.jp.md new file mode 100644 index 0000000..31bd05e --- /dev/null +++ b/docs/quickstart-guide.jp.md @@ -0,0 +1,236 @@ +# Gatling Commander クイックスタートガイド +- [Gatling Commander クイックスタートガイド](#gatling-commander-クイックスタートガイド) + - [事前準備](#事前準備) + - [モジュールのインストール](#モジュールのインストール) + - [ツールのインストール](#ツールのインストール) + - [Gatling Operatorの環境構築](#gatling-operatorの環境構築) + - [Google Sheetsの作成](#google-sheetsの作成) + - [負荷試験設定ファイルの作成](#負荷試験設定ファイルの作成) + - [Gatlingリソースのマニフェスト作成](#gatlingリソースのマニフェスト作成) + - [Sheets APIへの認証](#sheets-apiへの認証) + - [負荷試験の実行](#負荷試験の実行) + - [負荷試験結果の出力](#負荷試験結果の出力) + - [負荷試験実行の中止](#負荷試験実行の中止) + - [負荷試験終了の通知](#負荷試験終了の通知) + - [閾値による負荷試験実行の中止](#閾値による負荷試験実行の中止) + - [Failした際の中止](#failした際の中止) + - [目標レイテンシを上回った際の中止](#目標レイテンシを上回った際の中止) + +Gatling Commanderをすぐに利用するため、実行環境の作成と実行方法について最小限の情報を記載しています。 +設定ファイル、権限・認証に関する詳しい説明は[User Guide](./user-guide.jp.md)を参照してください。 + +## 事前準備 +### モジュールのインストール +```bash +go install github.com/st-tech/gatling-commander@latest +``` +### ツールのインストール +- [Gatling Operator](https://github.com/st-tech/gatling-operator/tree/main) +- [Docker](https://www.docker.com/) +- [Go](https://go.dev/) + - version: 1.20 +- [Google Sheets](https://www.google.com/intl/ja_jp/sheets/about/) + - 負荷試験結果の書き込み先として事前にシートの作成が必要です +- [Google Cloud Project](https://cloud.google.com/resource-manager/docs/creating-managing-projects) + - Google Sheetsの認証に必要です + +### Gatling Operatorの環境構築 +Gatling Commanderは[Gatling Operator](https://github.com/st-tech/gatling-operator)の利用を前提としています。 +[Gatling OperatorのQuick Start Guide](https://github.com/st-tech/gatling-operator/blob/main/docs/quickstart-guide.md)を参考にGatling Operatorを利用可能な環境構築を行なってください。 + +コマンド実行を行うディレクトリ内に`gatling`ディレクトリを作成し、Gatling Operator実行時に必要なファイルのコピーや作成を行なってください。 +詳細は[What is this `gatling` directory?](../gatling/README.md)を参照してください。 + +### Google Sheetsの作成 +負荷試験結果の記録先として[Google Sheets](https://www.google.com/intl/ja_jp/sheets/about/)を利用しています。 +記録先のシートには、既存のシート・新規作成のシートの両方が利用可能です。 + +次の作業を実施して記録先のGoogle SheetsのIDの取得と編集者権限の付与をしてください。 + +- IDの取得 + - 負荷試験結果の記録を行うGoogle Sheetsを開き、URLの{ID}に該当する文字列をコピーしてください + - https://docs.google.com/spreadsheets/d/{ID}/edit#gid=0 + - コピーした文字列は`config.yaml`の`services[].spreadsheetID`に設定してください +- シートの権限付与 + - Gatling Commanderを利用する際に、認証するアカウントへGoogle Sheetsの編集者権限を付与してください + - 記録先のシートのUIから共有ボタンをクリックし、対象のアカウントへ編集者権限を付与できます + +### 負荷試験設定ファイルの作成 +負荷試験の設定値は`config/config.yaml`に記述します。 +また、[Gatlingリソースのマニフェスト作成](#Gatlingリソースのマニフェスト作成)で後述する`base_manifest.yaml`のうち、``と記載のあるフィールドは`config.yaml`に記述したフィールドの値により上書きされます。 + +`config.yaml`の各フィールドに設定する値の詳細は[User Guide](./user-guide.jp.md)を参照してください。 + +以下は`config.yaml`のサンプルです。 + +```yaml +gatlingContextName: gatling-cluster-context-name +imageRepository: gatling-image-stored-repository-url +imagePrefix: gatlinge-image-name-prefix +imageURL: "" # (Optional) specify image url when using pre build gatling container image +baseManifest: config/base_manifest.yaml +gatlingDockerfileDir: gatling +startupTimeoutSec: 1800 # 30min +execTimeoutSec: 10800 # 3h +slackConfig: + webhookURL: slack-webhook-url + mentionText: <@targetMemberID> +services: + - name: sample-service + spreadsheetID: sample-sheets-id + failFast: false + targetPercentile: + targetLatency: + targetPodConfig: + contextName: target-pod-context-name + namespace: sample-namespace + labelKey: run + labelValue: sample-api + containerName: sample-api + scenarioSpecs: + - name: case-1 + subName: 10rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "10" + - name: DURATION + value: "180" + - name: case-2 + subName: 20rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "20" + - name: DURATION + value: "180" + +``` + +### Gatlingリソースのマニフェスト作成 +Gatling CommanderではGatling Operatorで利用するKubernetesのCustom ResourceであるGatlingリソースのオブジェクトを作成して負荷試験を行います。 + +`base_manifest.yaml`はGatlingリソースのKubernetesマニフェストです。 +`base_manifest.yaml`にはGatlingリソースについて、負荷試験ごとに共通の値を記述します。 + +`base_manifest.yaml`に``と記載があるフィールドは、負荷試験ごとに異なる値が設定されます。こちらのフィールドの値は、Gatling Commanderの実行時に`config.yaml`の値でそれぞれ置き換えられます。 +そのため`base_manifest.yaml`での値の設定は不要です。 + +`config/base_manifest.yaml`の記述については、[Gatling Operatorのサンプル](https://github.com/st-tech/gatling-operator/blob/main/config/samples/gatling-operator_v1alpha1_gatling01.yaml)を参考に、利用する環境に合わせて作成してください。 +詳細については[User Guide](./user-guide.jp.md)を参照してください。 + +以下は`base_manifest.yaml`のサンプルです。 + + +```yaml +apiVersion: gatling-operator.tech.zozo.com/v1alpha1 +kind: Gatling +metadata: + name: # will be overrided by services[].name field value in config.yaml. ex: sample-service + namespace: gatling +spec: + generateReport: true + generateLocalReport: true + notifyReport: false + cleanupAfterJobDone: false + podSpec: + gatlingImage: # will be overrided by built Gatling Image URL or imageURL field value in config.yaml. ex: asia-docker.pkg.dev/project_id/foo/bar/gatlinge-image-name-prefix-YYYYMMDD + rcloneImage: rclone/rclone + resources: + requests: + cpu: "7000m" + memory: "4G" + limits: + cpu: "7000m" + memory: "4G" + serviceAccountName: "gatling-operator-worker-service-account" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cloud.google.com/gke-nodepool + operator: In + values: + - "gatling-operator-worker-pool" + tolerations: + - key: "dedicated" + operator: "Equal" + value: "gatling-operator-worker-pool" + effect: "NoSchedule" + cloudStorageSpec: + provider: "gcp" + bucket: "report-storage-bucket-name" + notificationServiceSpec: + provider: "slack" + secretName: "gatling-notification-slack-secrets" + testScenarioSpec: + parallelism: # will be overrided by services[].scenarioSpecs[].testScenarioSpec.parallelism field value. ex: 1 + simulationClass: # will be overrided by services[].scenarioSpecs[].testScenarioSpec.simulationClass field value. ex: SampleSimulation + env: # will be overrided by services[].scenarioSpecs[].testScenarioSpec.env[] field value. ex: `env: [{name: ENV, value: "dev"}, {name: CONCURRENCY, value: "20"}]` + - name: + value: + +``` + +### Sheets APIへの認証 +負荷試験結果はGoogle Sheetsに記録されます。 +記録にはシートの編集者権限が必要になるため、Google Cloud Projectで[Google Sheets API](https://developers.google.com/sheets/api/guides/concepts)を有効化し、利用するGoogleアカウント・サービスアカウントを認証してください。 +```bash +gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/spreadsheets +``` + +## 負荷試験の実行 +次のコマンドにより負荷試験が実行されます。 +```bash +gatling-commander exec --config "config/config.yaml" +``` +`--skip-build`オプションを指定するとGatling Imageのbuildをスキップできます。 +このオプションを使用するには、`config.yaml`で`imageURL`に予めbuildしたGatling ImageのURLを設定する必要があります。 +`--skip-build`オプションを指定しない場合は、常に新しいGatling Imageがbuildされます。 + +`config.yaml`の`services`には各serviceごとの設定値を配列で記述します。 +`config.yaml`の`services[].scenarioSpecs`には負荷試験ごとの設定値を配列で記述します。 + +`config.yaml`の`services`に記載した各serviceごとの負荷試験群は並行で実行されます。 +service内の`scenarioSpecs`に記載した負荷試験は記載順に順次実行されます。 + +## 負荷試験結果の出力 +負荷試験結果は`config.yaml`で指定したGoogle Sheetsに記録されます。 +記録用のシートはGatling Commanderにより作成され、`config.yaml`の`services[].name` + `実行日`の形式で作成されます。(例:`sample-service-20231113`) + +同一のservice名を持ち、同じ日付に実施された負荷試験の記録用シートは同名であるため、既存のシートに追記する形で記録されます。 +追記される結果は一番下の行に追加されます。 + +## 負荷試験実行の中止 +`ctrl + c`で実行中のGatling Commanderのプロセスを終了することで、負荷試験実行を中断することができます。 +中断すると実行中のGatling Objectは直ちに削除されます。 + +## 負荷試験終了の通知 +`config.yaml`の`slackConfig.webhookURL`にSlackのWebhook URLを指定することで、負荷試験が終了した際にSlackに通知できます。 +SlackのWebhook URLについては[Slack APIの公式ドキュメント](https://api.slack.com/messaging/webhooks)を参考にコンソールから取得してください。 + +## 閾値による負荷試験実行の中止 +service内の`scenarioSpecs`に指定した負荷試験は順次実行されます。 +負荷試験実行後にGatling Reportの結果に応じて、同一serviceでの以降の負荷試験を中止できます。 + +### Failした際の中止 +Gatlingの負荷試験では、負荷試験シナリオで指定した以外のレスポンスが返された場合にfailとして扱われます。 +`config.yaml`の`failFast`を`true`に設定すると負荷試験結果にfailedが含まれた場合に、同一serviceでの以降の負荷試験を実施しません。 + +### 目標レイテンシを上回った際の中止 +Gatling Commanderではserviceごとに目標レイテンシの閾値を設定し、閾値を超えた場合に以降の負荷試験を中止できます。 +レイテンシの閾値チェックを行うには、`config.yaml`の`targetLatency`・`targetPercentile`の両方を設定します。 + +- targetPercentile + - 閾値のパーセンタイル値を指定してください。値は[50, 75, 95, 99]の中から指定可能です +- targetLatency + - レイテンシの閾値をミリ秒で指定してください diff --git a/docs/quickstart-guide.md b/docs/quickstart-guide.md new file mode 100644 index 0000000..72fed67 --- /dev/null +++ b/docs/quickstart-guide.md @@ -0,0 +1,234 @@ +# Quick Start Guide +日本語版Quick Start Guideは[こちら](./quickstart-guide.jp.md) + +- [Quick Start Guide](#quick-start-guide) + - [Create execution environment](#create-execution-environment) + - [Install CLI module](#install-cli-module) + - [Install required tools](#install-required-tools) + - [Create Gatling Operator execution environment](#create-gatling-operator-execution-environment) + - [Create Google Sheets](#create-google-sheets) + - [Create configuration file for load test](#create-configuration-file-for-load-test) + - [Create Kubernetes Manifest of Gatling Resource](#create-kubernetes-manifest-of-gatling-resource) + - [Authentication for Google Sheets](#authentication-for-google-sheets) + - [Run load test](#run-load-test) + - [Record load test results](#record-load-test-results) + - [Interruput running load test](#interruput-running-load-test) + - [Notify load test finish](#notify-load-test-finish) + - [Discontinuation of load test execution due to threshold value](#discontinuation-of-load-test-execution-due-to-threshold-value) + - [Discontinuation when load test failed](#discontinuation-when-load-test-failed) + - [Discontinuation when target latency is exceeded](#discontinuation-when-target-latency-is-exceeded) + +This describes the minimal information abount how to create the execution environment and and run CLI quickly. More information about configuration files, privileges and authentication, please refer to [User Guide](./user-guide.md). + +## Create execution environment +### Install CLI module +You can install Gatling Commander with the following command. +```bash +go install github.com/st-tech/gatling-commander@latest +``` +### Install required tools +- [Gatling Operator](https://github.com/st-tech/gatling-operator/tree/main) +- [Docker](https://www.docker.com/) +- [Go](https://go.dev/) + - version: 1.20 +- [Google Sheets](https://www.google.com/intl/ja_jp/sheets/about/) + - Google Sheets is required for recording load test results +- [Google Cloud Project](https://cloud.google.com/resource-manager/docs/creating-managing-projects) + - Google Cloud Project is required for accessing Google Sheets by [Google Sheets API](https://developers.google.com/sheets/api/guides/concepts) + +### Create Gatling Operator execution environment +Gatling Commander is intended for use in load test with the [Gatling Operator](https://github.com/st-tech/gatling-operator). +When using Gatling Commander, please create an environment in which the Gatling Operator can be used first. Information about how to setup Gatling Operator environment, please refer to the Gatling Operator [Quick Start Guide](https://github.com/st-tech/gatling-operator/blob/main/docs/quickstart-guide.md). + +Please create `gatling` directory in directory which run Gatling Commander command, and create required file for Gatling Operator execution. For more information about this directory, please refer to [What is this `gatling` directory?](../gatling/README.md). + +### Create Google Sheets +Gatling Commander record the load test results to Google Sheets. Both existing and newly created sheet can be used for the recording destination. + +Please get Google Sheets ID and grant editor role by doing the following tasks. + +- Get Google Sheets ID + - Open Google Sheets to record the load test results and copy the string corresponding to {ID} in the URL. + - https://docs.google.com/spreadsheets/d/{ID}/edit#gid=0 + - Set copied string to `services[].spreadsheetID` in `config.yaml` +- Grant role for editing the sheet + - Please grant Google Sheets editor role for using Gatling Commander to the account to be authenticated. + - Click the Share button in the UI of the sheet you are recording and grant editor role to the target account. + +### Create configuration file for load test +Configuration values for the load test are written in `config/config.yaml`. +Also, in the `base_manifest.yaml` described below in [Create Kubernetes Manifest of Gatling Resource](#create-kubernetes-manifest-of-gatling-resource), fields marked `` will be overwritten by the corresponding value of the field in `config.yaml`. + +More information about each field of `config.yaml`, please refer to [User Guide](./user-guide.md). + +Here is the example of `config.yaml`. + +```yaml +gatlingContextName: gatling-cluster-context-name +imageRepository: gatling-image-stored-repository-url +imagePrefix: gatlinge-image-name-prefix +imageURL: "" # (Optional) specify image url when using pre build gatling container image +baseManifest: config/base_manifest.yaml +gatlingDockerfileDir: gatling +startupTimeoutSec: 1800 # 30min +execTimeoutSec: 10800 # 3h +slackConfig: + webhookURL: slack-webhook-url + mentionText: <@targetMemberID> +services: + - name: sample-service + spreadsheetID: sample-sheets-id + failFast: false + targetPercentile: + targetLatency: + targetPodConfig: + contextName: target-pod-context-name + namespace: sample-namespace + labelKey: run + labelValue: sample-api + containerName: sample-api + scenarioSpecs: + - name: case-1 + subName: 10rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "10" + - name: DURATION + value: "180" + - name: case-2 + subName: 20rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "dev" + - name: CONCURRENCY + value: "20" + - name: DURATION + value: "180" + +``` + +### Create Kubernetes Manifest of Gatling Resource +Gatling Commander creates an object for a Gatling Resource, a Kubernetes Custom Resource used by the Gatling Operator, to run load test. + +The `base_manifest.yaml` is Kubernetes manifest for Gatling Resource. +The `base_manifest.yaml` has the common values for each load test for the Gatling Resource. + +Fields marked `` in `base_manifest.yaml` are set to different values for each loadtest. The value of this field will be replaced by the corresponding value in `config.yaml` respectively when Gatling Commander is run. +Therefore, setting values to fields marked `` in `base_manifest.yaml` is not necessary. + +For information on how to write Kubernetes manifest for Gatling Resource (`config/base_manifest.yaml`), see the [samples YAML file](https://github.com/st-tech/gatling-operator/blob/main/config/samples/gatling-operator_v1alpha1_gatling01.yaml) which is provided in st-tech/gatling-operator repository, and create manifest for your environment. + +For more information, please refer to [User Guide](./user-guide.md) for details. + +The following is a sample of `base_manifest.yaml`. + +```yaml +apiVersion: gatling-operator.tech.zozo.com/v1alpha1 +kind: Gatling +metadata: + name: # will be overrided by services[].name field value in config.yaml. ex: sample-service + namespace: gatling +spec: + generateReport: true + generateLocalReport: true + notifyReport: false + cleanupAfterJobDone: false + podSpec: + gatlingImage: # will be overrided by built Gatling Image URL or imageURL field value in config.yaml. ex: asia-docker.pkg.dev/project_id/foo/bar/gatlinge-image-name-prefix-YYYYMMDD + rcloneImage: rclone/rclone + resources: + requests: + cpu: "7000m" + memory: "4G" + limits: + cpu: "7000m" + memory: "4G" + serviceAccountName: "gatling-operator-worker-service-account" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cloud.google.com/gke-nodepool + operator: In + values: + - "gatling-operator-worker-pool" + tolerations: + - key: "dedicated" + operator: "Equal" + value: "gatling-operator-worker-pool" + effect: "NoSchedule" + cloudStorageSpec: + provider: "gcp" + bucket: "report-storage-bucket-name" + notificationServiceSpec: + provider: "slack" + secretName: "gatling-notification-slack-secrets" + testScenarioSpec: + parallelism: # will be overrided by services[].scenarioSpecs[].testScenarioSpec.parallelism field value. ex: 1 + simulationClass: # will be overrided by services[].scenarioSpecs[].testScenarioSpec.simulationClass field value. ex: SampleSimulation + env: # will be overrided by services[].scenarioSpecs[].testScenarioSpec.env[] field value. ex: `env: [{name: ENV, value: "dev"}, {name: CONCURRENCY, value: "20"}]` + - name: + value: + +``` + +### Authentication for Google Sheets +Load test results are recorded in Google Sheets. +For recording the results, activate [Google Sheets API](https://developers.google.com/sheets/api/guides/concepts) in Google Cloud Project, and authenticate your Google Account or Service Account which has sheet editor role. +```bash +gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/spreadsheets +``` + +## Run load test +The following Gatling Commander command will run load test which written in configuration. +```bash +gatling-commander exec --config "config/config.yaml" +``` +The `--skip-build` option allows you to skip building a Gatling Image. To use this option, you must set `imageURL` in `config.yaml` to the URL of the Gatling Image you have built. +If the `--skip-build` option is not specified, a new Gatling Image will always be built. + +In `config.yaml`, `services` is an array of configuration values for each service. +`services[].scenarioSpecs` in `config.yaml` describes an array of configuration values for each load test. + +The load tests for each service listed in `services` in `config.yaml` are executed in parallel. +The load tests listed in `scenarioSpecs` in service are executed in the order in which they are listed. + +## Record load test results +The load test results are recorded in Google Sheets specified in `config.yaml`. +The sheets for recording are created by Gatling Commander and are in the format of `services[].name` + `date at runtime` in `config.yaml`. (e.g. `sample-service-20231113`) + +If there is load test run with same service name and same date, the results will be recorded to the same sheet. In that case, the results will be appended to the bottom row. + +## Interruput running load test +You can interruput the load test run by terminating the running Gatling Commander process with `ctrl + c`. +Upon interruption, the running Gatling object will be deleted immediately. + +## Notify load test finish +By specifying the Slack webhook URL in `slackConfig.webhookURL` in `config.yaml`, you can notify Slack when the load test is finished. +For Slack's Webhook URL, please refer to [Slack API documentation](https://api.slack.com/messaging/webhooks) to get it from the console. + +## Discontinuation of load test execution due to threshold value +The load tests specified in `scenarioSpecs` in the service are executed sequentially. +By setting threshold values in config.yaml, subsequent load tests in the same service can be discontinued according to the results of the Gatling Report after the load test is executed. + +### Discontinuation when load test failed +In Gatling load tests, if returned response if different from the one specified in the load test scenario is returned, it is treated as a fail. +If `failFast` in `config.yaml` is set to `true`, subsequent load tests on the same service will not be performed if the load test results include a failed response. + +### Discontinuation when target latency is exceeded +Gatling Commander allows you to set a target latency threshold for each service and discontinue subsequent load tests if exceeds the threshold. +To perform a latency threshold check, set both `targetLatency` and `targetPercentile` in `config.yaml`. + +- targetPercentile + - Specify the percentile value of the threshold. Values can be specified from [50, 75, 95, 99] +- targetLatency + - Specify latency threshold in milliseconds diff --git a/docs/user-guide.jp.md b/docs/user-guide.jp.md new file mode 100644 index 0000000..d21a092 --- /dev/null +++ b/docs/user-guide.jp.md @@ -0,0 +1,152 @@ +# Gatling Commander ユーザーガイド +- [Gatling Commander ユーザーガイド](#gatling-commander-ユーザーガイド) + - [設定ファイルの概要](#設定ファイルの概要) + - [負荷試験設定](#負荷試験設定) + - [`config.yaml`の階層](#configyamlの階層) + - [負荷試験全体の設定値](#負荷試験全体の設定値) + - [serviceの設定値](#serviceの設定値) + - [負荷試験シナリオの設定値](#負荷試験シナリオの設定値) + - [Gatling リソースのマニフェスト](#gatling-リソースのマニフェスト) + - [権限と認証](#権限と認証) + - [docker imageをpull・pushできる権限](#docker-imageをpullpushできる権限) + - [Kubernetesクラスタでオブジェクトを読み取り・書き込み・削除できる権限](#kubernetesクラスタでオブジェクトを読み取り書き込み削除できる権限) + - [Cloud Storageからの読み取り権限](#cloud-storageからの読み取り権限) + - [Google Sheetsへの読み取り・書き込み権限](#google-sheetsへの読み取り書き込み権限) + - [Google Sheets APIの認証](#google-sheets-apiの認証) + +Gatling Commanderを利用する際の設定ファイルの書き方や認証について説明しています。 +ツールのインストールや[Gatling Operator](https://github.com/st-tech/gatling-operator)のセットアップ、負荷試験シナリオの作成などの事前準備・実行方法については[Quick Start Guide](./quickstart-guide.jp.md)を参照してください。 +## 設定ファイルの概要 +Gatling Commanderでは、設定ファイルとして次の2種類のYAMLファイルを用意する必要があります。 +- config.yaml +- base_manifest.yaml + +負荷試験の設定値は`config.yaml`に記載します。 + +`base_manifest.yaml`にはGatlingリソースのKubernetesマニフェストのうち、負荷試験ごとに共通の値を記述します。 + +`base_manifest.yaml`に``と記載があるフィールドは、負荷試験ごとに異なる値が設定されます。こちらのフィールドの値は、Gatling Commanderの実行時に`config.yaml`の値でそれぞれ置き換えられます。 +そのため`base_manifest.yaml`での値の設定は不要です。 + +`config.yaml`・`base_manifest.yaml`の保存場所、ファイル名は任意の値を指定可能です。 +参照する`config.yaml`のパスについては、コマンド実行時に`--config`オプションの値を指定してください。 +参照する`base_manifest.yaml`のパスについては、`config.yaml`の`baseManifest`に設定してください。 + +### 負荷試験設定 +`config.yaml`の各フィールドについて説明します。 + +#### `config.yaml`の階層 +`config.yaml`は階層構造となっています。 + +Gatling Commanderでは、個々の負荷試験のグループとしてserviceを定義します。 +serviceは同一の負荷試験対象に関する1つ以上の負荷試験シナリオを持ちます。 +同一serviceの負荷試験の結果は、`config.yaml`の`services[].spreadsheetID`で指定した[Google Sheets](https://www.google.com/sheets/about/)に記録されます。 + +個々の負荷試験シナリオ設定は`config.yaml`の`testScenarioSpec`に定義します。これはGatling Operatorのみを利用して負荷試験を行う場合に設定するGatling Objectの`testScenarioSpec`の値と同じです。 + +`config.yaml`のトップレベルのフィールドでは負荷試験全体で共通の設定値を指定し、`config.yaml`の`services`には各serviceごとの設定値を指定します。 +また`config.yaml`の`services[].testScenarioSpec`には負荷試験ごとの設定値を指定します。 + +このように`config.yaml`では、`負荷試験全体に共通の設定値 -> serviceごとの設定値 -> 負荷試験ごとのシナリオ`と設定値がネストされた階層構造で構成されています。 + +#### 負荷試験全体の設定値 +`config.yaml`のうち、負荷試験全体で共通の設定値について説明します。 + +| Field | Description | +| --- | --- | +| `gatlingContextName` _string_ | (Required) Context name of Kubernetes cluster which Gatling Pod running in. | +| `imageRepository` _string_ | (Required) Container image repository url in which Gatling image is stored. | +| `imagePrefix` _string_ | (Required) String which is used to add built Gatling image name prefix. | +| `imageURL` _string_ | (Optional) Container image URL. When you run `exec` subcommand with `--skip-build` arguments, you must fill this field to specify Gatling image. | +| `baseManifest` _string_ | (Required) Path of Gatling Kubernetes manifest. | +| `gatlingDockerfileDir` _string_ | (Required) Path of directory in which Dockerfile for Gatling image is stored. | +| `startupTimeoutSec` _integer_ | (Required) Timeout seconds threshold about each Gatling Job startup. | +| `execTimeoutSec` _integer_ | (Required) Timeout seconds threshold about each Gatling Job running. | +| `slackConfig.webhookURL` _string_ | (Optional) Slack webhook url for notification. If set this value, finished CLI will be notified. | +| `slackConfig.mentionText` _string_ | (Optional) Slack mention target. If set member_id to this field, CLI notification mention user who has the member_id. The webhookURL field must be specified with this field value. | +| `services` _[]object_ | (Required) This field has some services setting values. | + +#### serviceの設定値 +`config.yaml`のうち、serviceごとの設定値について説明します。 + +| Field | Description | +| --- | --- | +| `name` _string_ | (Required) Service name. Please specify any value. Used in Gatling object metadata name and so on. | +| `spreadsheetID` _string_ | (Required) Google Sheets ID to which load test result will be written. | +| `failFast` _boolean_ | (Required) The flag determining whether start next load test or not when current load test result failed item value count exceeds 0. | +| `targetPercentile` _integer_ | (Optional) Threshold of latency percentile, specify this field value from [50, 75, 95, 99]. If this field value is set, CLI check current load test result specified percentile value and whether decide to start next load test or not. The targetLatency field must be specified with this field value. | +| `targetLatency` _integer_ | (Optional) Threshold of latency milliseconds, this field must be specified with targetPercentile. | +| `targetPodConfig.contextName` _string_ | (Required) Context name of Kubernetes cluster which loadtest target Pod running in. | +| `targetPodConfig.namespace` _string_ | (Required) Kubernetes namespace in which load test target Pod is running. | +| `targetPodConfig.labelKey` _string_ | (Required) Metadata Labels key of load test target Pod. | +| `targetPodConfig.labelValue` _string_ | (Required) Metadata Labels value of load test target Pod. | +| `targetPodConfig.containerName` _string_ | (Required) Name of load test target container name which is running in load test target Pod. | +| `scenarioSpecs` _[]object_ | (Required) This field has some scenarioSpecs setting values. | + +#### 負荷試験シナリオの設定値 +`config.yaml`のうち、個々の負荷試験シナリオごとの設定値について説明します。 + +| Field | Description | +| --- | --- | +| `name` _string_ | (Required) Load test name which is used as Google Sheets name and so on. | +| `subName` _string_ | (Required) Load test sub name which is used in load test result row subName column. | +| `testScenarioSpec` _object_ | (Required) Gatling object testScenarioSpec field. Please refer gatling-operator document [TestScenarioSpec](https://github.com/st-tech/gatling-operator/blob/main/docs/api.md#testscenariospec). | + +### Gatling リソースのマニフェスト +`base_manifest.yaml`にはGatlingリソースのKubernetesマニフェストのうち、負荷試験ごとに共通する値を設定するフィールドを記述します。 +GatlingリソースのKubernetesマニフェストのフィールドについては、[Gatling OperatorのAPI Reference](https://github.com/st-tech/gatling-operator/blob/main/docs/api.md#gatling)を参照してください。 + +`base_manifest.yaml`に``と記載があるフィールドは、負荷試験ごとに異なる値が設定されます。 +これらのフィールドの値は、Gatling Commanderの実行時に`config.yaml`の値でそれぞれ置き換えられます。 +そのため、`base_manifest.yaml`での値の変更は不要です。 + +`base_manifest.yaml`のうち、`config.yaml`の値で置き換えられるフィールドについて説明します。 + +| Field | Description | +| --- | --- | +| `metadata.name` _string_ | Overwritten by service name loaded from `services[].name` field value in `config.yaml` | +| `spec.podSpec.gatlingImage` _string_ | Overwritten by built Gatling image URL or image URL loaded from `imageURL` field value in `config.yaml` | +| `spec.testScenarioSpec.parallelism` _interger_ | Overwritten by `services[].scenarioSpecs[].testScenarioSpec.parallelism` field value in `config.yaml` | +| `spec.testScenarioSpec.simulationClass` _string_ | Overwritten by `services[].scenarioSpecs[].testScenarioSpec.simulationClass` field value in `config.yaml` | +| `spec.testScenarioSpec.env[]` _[]dict_ | Overwritten by `services[].scenarioSpecs[].testScenarioSpec.env[]` field value in `config.yaml` | + +## 権限と認証 +Gatling Commanderの実行には次の権限が必要です。 + +- docker imageをpull・pushできる権限 +- Kubernetesクラスタでオブジェクトの取得・作成・削除ができる権限 +- [Cloud Storage](https://cloud.google.com/storage)からの読み取り権限 +- Google Sheetsへの読み取り・書き込み権限 + +### docker imageをpull・pushできる権限 +`config.yaml`の`imageURL`を指定しない場合、新しくGatling Imageをbuildし指定したImage Repositoryにpushします。 +Gatling Commanderでは現状Google Cloudのみでの利用をサポートしており、[Google Artifact Registry](https://cloud.google.com/artifact-registry)・[Google Container Registry](https://cloud.google.com/container-registry/docs/overview)が利用可能です。 + +Gatling Imageのbuild・pushを行う場合は、Gatling Commanderの実行環境で認証されるアカウントにImageをpushするために必要な権限を付与してください。 + +### Kubernetesクラスタでオブジェクトを読み取り・書き込み・削除できる権限 +Gatling Commanderでは指定したクラスタでGatling Objectの作成・取得・削除や負荷試験対象のPodのメトリクスの取得を行います。 + +Kubernetesの認証情報は`$HOME/.kube/config`を参照して取得しています。 +Gatling Commanderの実行環境で認証されるアカウントにKubernetesオブジェクトの取得・作成・削除ができる権限を付与してください。 + +### Cloud Storageからの読み取り権限 +Gatling Operatorの仕様として、負荷試験実行後にGatling Reportが`cloudStorageSpec`で設定した`provider`の`bucket`に出力されます。 +Gatling Commanderでは設定した`bucket`にアップロードされたGatling Reportを取得し、対象の項目を読み取ってGoogle Sheetsに記録します。 + +Gatling Commanderでは現状Google Cloudのみでの利用をサポートしており、Google Cloud StorageにアップロードされたGatling Reportから負荷試験結果の読み取りを行います。 + +Gatling Commanderの実行環境で認証されるアカウントにファイルを取得するために必要な権限付与してください。 + +### Google Sheetsへの読み取り・書き込み権限 +Gatling Commanderでは負荷試験結果を指定されたGoogle Sheetsに記録します。 +Gatling Commanderの実行環境で認証されるアカウントに、対象のGoogle Sheetsの編集者権限を付与してください。 + +#### Google Sheets APIの認証 +Gatling CommanderでGoogle Sheetsを操作する際には[Google Sheets API](https://developers.google.com/sheets/api/guides/concepts)を利用します。Google Cloud Projectがない場合はProjectを作成し、Google Sheets APIを有効化してください。 +Google Sheetsのシートを作成後、認証するアカウントへシートの編集権限を付与してください。 + +次のコマンドを実行し、Google Sheetsの認証を行なってください。 +```bash +gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/spreadsheets +``` diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..3b5d548 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,153 @@ +# User Guide +日本語版User Guideは[こちら](./user-guide.jp.md) + +- [User Guide](#user-guide) + - [About configuration file](#about-configuration-file) + - [Configuration of load test (`config.yaml`)](#configuration-of-load-test-configyaml) + - [Hierarchy of `config.yaml`](#hierarchy-of-configyaml) + - [Configuration values common to the entire load test](#configuration-values-common-to-the-entire-load-test) + - [Configuration values for each service](#configuration-values-for-each-service) + - [Configuration values for each load test scenario](#configuration-values-for-each-load-test-scenario) + - [Manifest of Gatling Resource](#manifest-of-gatling-resource) + - [Required Role and Authentication](#required-role-and-authentication) + - [Roles to pull and push docker images](#roles-to-pull-and-push-docker-images) + - [Roles to get, create, and delete objects in a Kubernetes cluster](#roles-to-get-create-and-delete-objects-in-a-kubernetes-cluster) + - [Roles to read from Cloud Storage](#roles-to-read-from-cloud-storage) + - [Roles to read, write Google Sheets](#roles-to-read-write-google-sheets) + - [Authentication of Google Sheets API](#authentication-of-google-sheets-api) + +This describes the information about how to write configuration files and authentication when using Gatling Commander. +Please refer to the [Quick Start Guide](./quickstart-guide.md) for information on how to install this tool, set up the [Gatling Operator](https://github.com/st-tech/gatling-operator), and prepare and run the load test scenario. +## About configuration file +Gatling Commander requires the following two types of YAML files as configuration files. +- config.yaml +- base_manifest.yaml + +Configuration values for the load test are written in `config/config.yaml`. + +The `base_manifest.yaml` describes the common values for each load test in the Kubernetes manifest of the Gatling Resource. + +Fields marked `` in `base_manifest.yaml` are set to different values for each load test. The value of this field will be replaced by the value in `config.yaml` respectively when Gatling Commander runs. +Therefore, setting values to fields marked `` in `base_manifest.yaml` is not necessary. + +The location and file name of `config.yaml` and `base_manifest.yaml` can be any value. +For the `config.yaml` path, specify the value of the `--config` option when executing the command. +For the `base_manifest.yaml` path, set path value to `baseManifest` in `config.yaml`. + +### Configuration of load test (`config.yaml`) +This describes about each field in `config.yaml` + +#### Hierarchy of `config.yaml` +The `config.yaml` has a hierarchical structure. + +In Gatling Commander, a service is defined as a group of load test. +A service has one or more load test scenarios for the same target. +The results of the load test for the same service are recorded in [Google Sheets](https://www.google.com/sheets/about/) specified by `services[].spreadsheetID` in `config.yaml`. + +The individual load test scenario settings are defined in the `testScenarioSpec` in `config.yaml`. This is the same as the value of `testScenarioSpec` of the Gatling Object, which is required for a load test with the Gatling Operator. + +The top-level field in `config.yaml` specifies common configuration values for the entire load test, and the `services` in `config.yaml` specify configuration values for each service. +Also, the `services[].testScenarioSpec` in `config.yaml` specifies the setting values for each load test. + +Thus, `config.yaml` consists of a nested hierarchical structure of configuration values: `configuration values common to the entire load test -> configuration values for each service -> scenario for each load test`. + +#### Configuration values common to the entire load test +This section describes the configuration values in the `config.yaml` that are common to the entire load test. + +| Field | Description | +| --- | --- | +| `gatlingContextName` _string_ | (Required) Context name of Kubernetes cluster which Gatling Pod running in. | +| `imageRepository` _string_ | (Required) Container image repository url in which Gatling image is stored. | +| `imagePrefix` _string_ | (Required) String which is used to add built Gatling image name prefix. | +| `imageURL` _string_ | (Optional) Container image URL. When you run `exec` subcommand with `--skip-build` arguments, you must fill this field to specify Gatling image. | +| `baseManifest` _string_ | (Required) Path of Gatling Kubernetes manifest. | +| `gatlingDockerfileDir` _string_ | (Required) Path of directory in which Dockerfile for Gatling image is stored. | +| `startupTimeoutSec` _integer_ | (Required) Timeout seconds threshold about each Gatling Job startup. | +| `execTimeoutSec` _integer_ | (Required) Timeout seconds threshold about each Gatling Job running. | +| `slackConfig.webhookURL` _string_ | (Optional) Slack webhook url for notification. If set this value, finished CLI will be notified. | +| `slackConfig.mentionText` _string_ | (Optional) Slack mention target. If set member_id to this field, CLI notification mention user who has the member_id. The webhookURL field must be specified with this field value. | +| `services` _[]object_ | (Required) This field has some services setting values. | + +#### Configuration values for each service +This section describes the configuration values for each service in `config.yaml`. + +| Field | Description | +| --- | --- | +| `name` _string_ | (Required) Service name. Please specify any value. Used in Gatling object metadata name and so on. | +| `spreadsheetID` _string_ | (Required) Google Sheets ID to which load test result will be written. | +| `failFast` _boolean_ | (Required) The flag determining whether to start next load test or not when current load test result failed item count exceeds 0. | +| `targetPercentile` _integer_ | (Optional) Threshold of latency percentile, specify this field value from [50, 75, 95, 99]. If this field value is set, CLI check current load test result specified percentile value and decide whether to start next load test or not. The targetLatency field must be specified with this field value. | +| `targetLatency` _integer_ | (Optional) Threshold of latency milliseconds, this field must be specified with targetPercentile. | +| `targetPodConfig.contextName` _string_ | (Required) Context name of Kubernetes cluster in which loadtest target Pod running. | +| `targetPodConfig.namespace` _string_ | (Required) Kubernetes namespace in which load test target Pod is running. | +| `targetPodConfig.labelKey` _string_ | (Required) Metadata Labels key of load test target Pod. | +| `targetPodConfig.labelValue` _string_ | (Required) Metadata Labels value of load test target Pod. | +| `targetPodConfig.containerName` _string_ | (Required) Name of load test target container name which is running in load test target Pod. | +| `scenarioSpecs` _[]object_ | (Required) This field has some scenarioSpecs setting values. | + +#### Configuration values for each load test scenario +This section describes the configuration values in `config.yaml` for each individual load test scenario. + +| Field | Description | +| --- | --- | +| `name` _string_ | (Required) Load test name which is used as Google Sheets name and so on. | +| `subName` _string_ | (Required) Load test sub name which is used in load test result row subName column. | +| `testScenarioSpec` _object_ | (Required) Gatling object testScenarioSpec field. Please refer gatling-operator document [TestScenarioSpec](https://github.com/st-tech/gatling-operator/blob/main/docs/api.md#testscenariospec). | + +### Manifest of Gatling Resource +The `base_manifest.yaml` describes the fields in the Kubernetes manifest of the Gatling Resource that set common values for each load test. +For more information about the fields in the Kubernetes manifest of the Gatling Resource, see [Gatling Operator API Reference](https://github.com/st-tech/gatling-operator/blob/main/docs/api.md#gatling). + +Fields marked `` in `base_manifest.yaml` are set to different values for each loadtest. The value of this field will be replaced by the corresponding value in `config.yaml` respectively when Gatling Commander is run. +Therefore, setting values to fields marked `` in `base_manifest.yaml` is not necessary. + +This section describes the fields in `base_manifest.yaml` that are replaced by values in `config.yaml`. + +| Field | Description | +| --- | --- | +| `metadata.name` _string_ | Overwritten by service name loaded from `services[].name` field value in `config.yaml` | +| `spec.podSpec.gatlingImage` _string_ | Overwritten by built Gatling image URL or image URL loaded from `imageURL` field value in `config.yaml` | +| `spec.testScenarioSpec.parallelism` _interger_ | Overwritten by `services[].scenarioSpecs[].testScenarioSpec.parallelism` field value in `config.yaml` | +| `spec.testScenarioSpec.simulationClass` _string_ | Overwritten by `services[].scenarioSpecs[].testScenarioSpec.simulationClass` field value in `config.yaml` | +| `spec.testScenarioSpec.env[]` _[]dict_ | Overwritten by `services[].scenarioSpecs[].testScenarioSpec.env[]` field value in `config.yaml` | + +## Required Role and Authentication +The following roles are required to run Gatling Commander. + +- Roles to pull and push docker images +- Roles to get, create, and delete objects in a Kubernetes cluster +- Roles to read from Cloud Storage +- Roles to read, write Google Sheets + +### Roles to pull and push docker images +If you do not specify `imageURL` in `config.yaml`, it will build a new Gatling Image and push it to the specified Image Repository. +Gatling Commander currently supports use with Google Cloud only, [Google Artifact Registry](https://cloud.google.com/artifact-registry) and [Google Container Registry](https://cloud.google.com/container-registry/docs/overview) are available. + +For building and pushing Gatling Image, please grant the account that is necessary roles to push the Image to an account that is used in the Gatling Commander execution environment. + +### Roles to get, create, and delete objects in a Kubernetes cluster +Gatling Commander creates, gets, and deletes Gatling Objects on the specified cluster and fetches metrics for the pods under load test. + +Gatling Commander obtains Kubernetes authentication by referring to `$HOME/.kube/config`. +The account used in the execution environment of Gatling Commander must be authorized to get, create and delete Kubernetes objects. + +### Roles to read from Cloud Storage +Gatling Operator creates and uploads a Gatling Report to a `provider` `bucket` specified place which are set in the `cloudStorageSpec` of Gatling manifest. +Gatling Commander gets the Gatling Report uploaded to the configured `bucket`, reads the target items, and records them in Google Sheets. + +Gatling Commander currently supports use with Google Cloud only and reads load test results from Gatling Reports uploaded to Google Cloud Storage. + +Please grant the necessary roles to get the Gatling Reports file to the account that is used in the execution environment of Gatling Commander. + +### Roles to read, write Google Sheets +Gatling Commander records the load test results in the specified Google Sheets. +Please grant the editor privilege of the target Google Sheets to the account used in the execution environment of Gatling Commander. + +#### Authentication of Google Sheets API +Gatling Commander use [Google Sheets API](https://developers.google.com/sheets/api/guides/concepts) for manipulating Google Sheets. If you do not have a Google Cloud Project, create one and activate the Google Sheets API. +After creating a Google Sheets sheet, grant the role to edit the sheet to the account used in the execution environment of Gatling Commander. + +Please execute the following command to authenticate Google Sheets. +```bash +gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/spreadsheets +``` diff --git a/gatling/Dockerfile b/gatling/Dockerfile new file mode 100644 index 0000000..f910a5f --- /dev/null +++ b/gatling/Dockerfile @@ -0,0 +1,45 @@ +# This is modified based on the original file: +# https://github.com/denvazh/gatling/tree/master/3.2.1 +# +# Gatling is a highly capable load test tool. +# +# Documentation: https://gatling.io/docs/3.2/ +# Cheat sheet: https://gatling.io/docs/3.2/cheat-sheet/ + +FROM --platform=linux/x86_64 openjdk:8-jdk-alpine + +# working directory for gatling +WORKDIR /opt + +# gating version +ENV GATLING_VERSION 3.2.1 + +# create directory for gatling install +RUN mkdir -p gatling + +# install gatling +RUN apk add --update wget bash libc6-compat && \ + mkdir -p /tmp/downloads && \ + wget -q -O /tmp/downloads/gatling-$GATLING_VERSION.zip \ + https://repo1.maven.org/maven2/io/gatling/highcharts/gatling-charts-highcharts-bundle/$GATLING_VERSION/gatling-charts-highcharts-bundle-$GATLING_VERSION-bundle.zip && \ + mkdir -p /tmp/archive && cd /tmp/archive && \ + unzip /tmp/downloads/gatling-$GATLING_VERSION.zip && \ + mv /tmp/archive/gatling-charts-highcharts-bundle-$GATLING_VERSION/* /opt/gatling/ && \ + rm -rf /opt/gatling/user-files/simulations/computerdatabase /tmp/* + +# change context to gatling directory +WORKDIR /opt/gatling + +# set directories below to be mountable from host +VOLUME ["/opt/gatling/conf", "/opt/gatling/results", "/opt/gatling/user-files"] + +# copy local files to gatling directory +COPY user-files/simulations user-files/simulations +COPY user-files/resources user-files/resources +COPY conf conf + +# set environment variables +ENV PATH /opt/gatling/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ENV GATLING_HOME /opt/gatling + +ENTRYPOINT ["gatling.sh"] diff --git a/gatling/README.md b/gatling/README.md new file mode 100644 index 0000000..19d8730 --- /dev/null +++ b/gatling/README.md @@ -0,0 +1,3 @@ +# What is this `gatling` directory? +This Directory is copied from [st-tech/gatling-operator/gatling](https://github.com/st-tech/gatling-operator/tree/main/gatling) +These files are same as need to run gatling operator. Please refer [Gatling Operator User Guide](https://github.com/st-tech/gatling-operator/blob/main/docs/user-guide.md). diff --git a/gatling/conf/gatling.conf b/gatling/conf/gatling.conf new file mode 100644 index 0000000..69ee676 --- /dev/null +++ b/gatling/conf/gatling.conf @@ -0,0 +1,127 @@ +######################### +# Gatling Configuration # +######################### + +# This file contains all the settings configurable for Gatling with their default values + +gatling { + core { + #outputDirectoryBaseName = "" # The prefix for each simulation result folder (then suffixed by the report generation timestamp) + #runDescription = "" # The description for this simulation run, displayed in each report + #encoding = "utf-8" # Encoding to use throughout Gatling for file and string manipulation + #simulationClass = "" # The FQCN of the simulation to run (when used in conjunction with noReports, the simulation for which assertions will be validated) + #elFileBodiesCacheMaxCapacity = 200 # Cache size for request body EL templates, set to 0 to disable + #rawFileBodiesCacheMaxCapacity = 200 # Cache size for request body Raw templates, set to 0 to disable + #rawFileBodiesInMemoryMaxSize = 1000 # Below this limit, raw file bodies will be cached in memory + #pebbleFileBodiesCacheMaxCapacity = 200 # Cache size for request body Peeble templates, set to 0 to disable + #feederAdaptiveLoadModeThreshold = 100 # File size threshold (in MB). Below load eagerly in memory, above use batch mode with default buffer size + #shutdownTimeout = 10000 # Milliseconds to wait for the actor system to shutdown + extract { + regex { + #cacheMaxCapacity = 200 # Cache size for the compiled regexes, set to 0 to disable caching + } + xpath { + #cacheMaxCapacity = 200 # Cache size for the compiled XPath queries, set to 0 to disable caching + } + jsonPath { + #cacheMaxCapacity = 200 # Cache size for the compiled jsonPath queries, set to 0 to disable caching + } + css { + #cacheMaxCapacity = 200 # Cache size for the compiled CSS selectors queries, set to 0 to disable caching + } + } + directory { + #simulations = "" # If set, directory where simulation classes are located + #resources = "" # If set, directory where resources, such as feeder files and request bodies, are located + #reportsOnly = "" # If set, name of report folder to look for in order to generate its report + #binaries = "" # If set, name of the folder where compiles classes are located: Defaults to GATLING_HOME/target. + #results = results # Name of the folder where all reports folder are located + } + } + socket { + #connectTimeout = 10000 # Timeout in millis for establishing a TCP socket + #tcpNoDelay = true + #soKeepAlive = false # if TCP keepalive configured at OS level should be used + #soReuseAddress = false + } + netty { + #useNativeTransport = true # if Netty native transport should be used instead of Java NIO + #allocator = "pooled" # switch to unpooled for unpooled ByteBufAllocator + #maxThreadLocalCharBufferSize = 200000 # Netty's default is 16k + } + ssl { + #useOpenSsl = true # if OpenSSL should be used instead of JSSE (only the latter can be debugged with -Djava.net.debug=ssl) + #useOpenSslFinalizers = false # if OpenSSL contexts should be freed with Finalizer or if using RefCounted is fine + #handshakeTimeout = 10000 # TLS handshake timeout in millis + #useInsecureTrustManager = true # Use an insecure TrustManager that trusts all server certificates + #enabledProtocols = [] # Array of enabled protocols for HTTPS, if empty use Netty's defaults + #enabledCipherSuites = [] # Array of enabled cipher suites for HTTPS, if empty enable all available ciphers + #sessionCacheSize = 0 # SSLSession cache size, set to 0 to use JDK's default + #sessionTimeout = 0 # SSLSession timeout in seconds, set to 0 to use JDK's default (24h) + #enableSni = true # When set to true, enable Server Name indication (SNI) + keyStore { + #type = "" # Type of SSLContext's KeyManagers store + #file = "" # Location of SSLContext's KeyManagers store + #password = "" # Password for SSLContext's KeyManagers store + #algorithm = "" # Algorithm used SSLContext's KeyManagers store + } + trustStore { + #type = "" # Type of SSLContext's TrustManagers store + #file = "" # Location of SSLContext's TrustManagers store + #password = "" # Password for SSLContext's TrustManagers store + #algorithm = "" # Algorithm used by SSLContext's TrustManagers store + } + } + charting { + #noReports = false # When set to true, don't generate HTML reports + #maxPlotPerSeries = 1000 # Number of points per graph in Gatling reports + #useGroupDurationMetric = false # Switch group timings from cumulated response time to group duration. + indicators { + #lowerBound = 800 # Lower bound for the requests' response time to track in the reports and the console summary + #higherBound = 1200 # Higher bound for the requests' response time to track in the reports and the console summary + #percentile1 = 50 # Value for the 1st percentile to track in the reports, the console summary and Graphite + #percentile2 = 75 # Value for the 2nd percentile to track in the reports, the console summary and Graphite + #percentile3 = 95 # Value for the 3rd percentile to track in the reports, the console summary and Graphite + #percentile4 = 99 # Value for the 4th percentile to track in the reports, the console summary and Graphite + } + } + http { + #fetchedCssCacheMaxCapacity = 200 # Cache size for CSS parsed content, set to 0 to disable + #fetchedHtmlCacheMaxCapacity = 200 # Cache size for HTML parsed content, set to 0 to disable + #perUserCacheMaxCapacity = 200 # Per virtual user cache size, set to 0 to disable + #warmUpUrl = "https://gatling.io" # The URL to use to warm-up the HTTP stack (blank means disabled) + #enableGA = true # Very light Google Analytics (Gatling and Java version), please support + #pooledConnectionIdleTimeout = 60000 # Timeout in millis for a connection to stay idle in the pool + #requestTimeout = 60000 # Timeout in millis for performing an HTTP request + #enableHostnameVerification = false # When set to true, enable hostname verification: SSLEngine.setHttpsEndpointIdentificationAlgorithm("HTTPS") + dns { + #queryTimeout = 5000 # Timeout in millis of each DNS query in millis + #maxQueriesPerResolve = 6 # Maximum allowed number of DNS queries for a given name resolution + } + } + jms { + #replyTimeoutScanPeriod = 1000 # scan period for timedout reply messages + } + data { + #writers = [console, file] # The list of DataWriters to which Gatling write simulation data (currently supported : console, file, graphite) + console { + #light = false # When set to true, displays a light version without detailed request stats + #writePeriod = 5 # Write interval, in seconds + } + file { + #bufferSize = 8192 # FileDataWriter's internal data buffer size, in bytes + } + leak { + #noActivityTimeout = 30 # Period, in seconds, for which Gatling may have no activity before considering a leak may be happening + } + graphite { + #light = false # only send the all* stats + #host = "localhost" # The host where the Carbon server is located + #port = 2003 # The port to which the Carbon server listens to (2003 is default for plaintext, 2004 is default for pickle) + #protocol = "tcp" # The protocol used to send data to Carbon (currently supported : "tcp", "udp") + #rootPathPrefix = "gatling" # The common prefix of all metrics sent to Graphite + #bufferSize = 8192 # Internal data buffer size, in bytes + #writePeriod = 1 # Write period, in seconds + } + } +} diff --git a/gatling/conf/logback.xml b/gatling/conf/logback.xml new file mode 100644 index 0000000..df6084e --- /dev/null +++ b/gatling/conf/logback.xml @@ -0,0 +1,19 @@ + + + + + + %d{HH:mm:ss.SSS} [%-5level] %logger{15} - %msg%n%rEx + + false + + + + + + + + + + + diff --git a/gatling/user-files/resources/.gitkeep b/gatling/user-files/resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/gatling/user-files/simulations/.gitkeep b/gatling/user-files/simulations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/gatling/user-files/simulations/SampleSimulation.scala b/gatling/user-files/simulations/SampleSimulation.scala new file mode 100644 index 0000000..6c43082 --- /dev/null +++ b/gatling/user-files/simulations/SampleSimulation.scala @@ -0,0 +1,29 @@ +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ +import scala.concurrent.duration._ + +class SampleSimulation extends Simulation { + + val env = sys.env.getOrElse("ENV", "stg") + val endpoint = env match { + case "dev" => "https://sample-api/example.com/" + } + val users_per_sec = sys.env.getOrElse("CONCURRENCY", "2").toInt + val duration_sec = sys.env.getOrElse("DURATION", "10").toInt + + val httpProtocol = http + .baseUrl(endpoint) + .userAgentHeader("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:16.0) Gecko/20100101 Firefox/16.0") + + val headers = Map("Content-Type" -> "accept: application/json") + val request = exec(http("request sample API") + .get("/test") + .headers(headers) + .check(status.is(200))) + + val sample_request = scenario("Request (" + env + ") sample").exec(request) + + setUp( + sample_request.inject(constantUsersPerSec(users_per_sec) during(duration_sec seconds)).protocols(httpProtocol), + ) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dcbe2f4 --- /dev/null +++ b/go.mod @@ -0,0 +1,102 @@ +module github.com/st-tech/gatling-commander + +go 1.20 + +require ( + cloud.google.com/go/storage v1.28.1 + github.com/google/go-cmp v0.5.9 + github.com/jinzhu/copier v0.3.5 + github.com/spf13/cobra v1.7.0 + github.com/spf13/viper v1.16.0 + github.com/st-tech/gatling-operator v0.9.1 + github.com/stretchr/testify v1.8.3 + google.golang.org/api v0.122.0 + gopkg.in/inf.v0 v0.9.1 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.28.1 + k8s.io/apimachinery v0.28.1 + k8s.io/client-go v0.28.1 + k8s.io/metrics v0.28.1 + sigs.k8s.io/controller-runtime v0.15.0 +) + +require ( + cloud.google.com/go v0.110.0 // indirect + cloud.google.com/go/compute v1.19.0 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + cloud.google.com/go/iam v0.13.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.9.0 // indirect + github.com/evanphx/json-patch v5.6.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.6.0 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/go-logr/logr v1.2.4 // indirect + github.com/go-logr/zapr v1.2.4 // 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/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/s2a-go v0.1.3 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect + github.com/googleapis/gax-go/v2 v2.8.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/mapstructure v1.5.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/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.15.1 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.42.0 // indirect + github.com/prometheus/procfs v0.9.0 // indirect + github.com/spf13/afero v1.9.5 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.4.2 // indirect + go.opencensus.io v0.24.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.8.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/oauth2 v0.8.0 // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.16.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + gomodules.xyz/jsonpatch/v2 v2.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect + google.golang.org/grpc v1.55.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/apiextensions-apiserver v0.27.2 // indirect + k8s.io/component-base v0.27.2 // 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/go.sum b/go.sum new file mode 100644 index 0000000..3f4ec90 --- /dev/null +++ b/go.sum @@ -0,0 +1,702 @@ +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.44.3/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.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys= +cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= +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/compute v1.19.0 h1:+9zda3WGgW1ZSTlVppLCYFIr48Pa35q1uG2N1itbCEQ= +cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +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/iam v0.13.0 h1:+CmB+K0J/33d0zSQ9SlFWUeCCEn5XJA0ZMZ3pHE9u8k= +cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0= +cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM= +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/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.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI= +cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= +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/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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-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-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= +github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +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.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= +github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA= +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-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +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/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.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +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.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/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.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= +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-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.3 h1:FAgZmpLl/SXurPEZyCMPBIiiYeTbqfjlbdnCNTAkbGE= +github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= +github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= +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.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc= +github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +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 v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +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.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= +github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= +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/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +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/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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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 v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= +github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +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/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI= +github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= +github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +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/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= +github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= +github.com/st-tech/gatling-operator v0.9.1 h1:QXD6yoqUJPBCMSeNNj4JEoNrCRZ97aDw4wIGVKEw6Sw= +github.com/st-tech/gatling-operator v0.9.1/go.mod h1:QcoqPNlYPFkO9iWPfGXI2FvII584loSRL0f6uWAyuGI= +github.com/stretchr/objx v0.1.0/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.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.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +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= +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.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= +go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +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-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +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/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-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-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +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.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= +golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= +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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.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-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-20190502145724-3ef323f4f1fd/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-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-20210225134936-a50acf3fe073/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-20210423185535-09eb48e85fd7/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-20210615035016-665e8c7367d1/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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.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.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +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.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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +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.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +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.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +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-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gomodules.xyz/jsonpatch/v2 v2.3.0 h1:8NFhfS6gzxNqjLIYnZxg319wZ5Qjnx4m/CcX+Klzazc= +gomodules.xyz/jsonpatch/v2 v2.3.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +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.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es= +google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= +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 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +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-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= +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.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag= +google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= +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.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +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/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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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.0-20210107192922-496545a6307b/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.1 h1:i+0O8k2NPBCPYaMB+uCkseEbawEt/eFaiRqUx8aB108= +k8s.io/api v0.28.1/go.mod h1:uBYwID+66wiL28Kn2tBjBYQdEU0Xk0z5qF8bIBqk/Dg= +k8s.io/apiextensions-apiserver v0.27.2 h1:iwhyoeS4xj9Y7v8YExhUwbVuBhMr3Q4bd/laClBV6Bo= +k8s.io/apiextensions-apiserver v0.27.2/go.mod h1:Oz9UdvGguL3ULgRdY9QMUzL2RZImotgxvGjdWRq6ZXQ= +k8s.io/apimachinery v0.28.1 h1:EJD40og3GizBSV3mkIoXQBsws32okPOy+MkRyzh6nPY= +k8s.io/apimachinery v0.28.1/go.mod h1:X0xh/chESs2hP9koe+SdIAcXWcQ+RM5hy0ZynB+yEvw= +k8s.io/client-go v0.28.1 h1:pRhMzB8HyLfVwpngWKE8hDcXRqifh1ga2Z/PU9SXVK8= +k8s.io/client-go v0.28.1/go.mod h1:pEZA3FqOsVkCc07pFVzK076R+P/eXqsgx5zuuRWukNE= +k8s.io/component-base v0.27.2 h1:neju+7s/r5O4x4/txeUONNTS9r1HsPbyoPBAtHsDCpo= +k8s.io/component-base v0.27.2/go.mod h1:5UPk7EjfgrfgRIuDBFtsEFAe4DAvP3U+M8RTzoSJkpo= +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/metrics v0.28.1 h1:Q0AsAEZKlAzhqrvfoGyHjz2qAFlef0SqfGJ1YWJ+ITU= +k8s.io/metrics v0.28.1/go.mod h1:8lKkAajigcZWu0o9XCEBr++YVCzT48q1ck+f9CEBhZY= +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/controller-runtime v0.15.0 h1:ML+5Adt3qZnMSYxZ7gAverBLNPSMQEibtzAgp0UPojU= +sigs.k8s.io/controller-runtime v0.15.0/go.mod h1:7ngYvp1MLT+9GeZ+6lH3LOlcHkp/+tzA/fmHa4iq9kk= +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/main.go b/main.go new file mode 100644 index 0000000..7fe1247 --- /dev/null +++ b/main.go @@ -0,0 +1,37 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +package main + +import ( + "fmt" + "os" + + "github.com/st-tech/gatling-commander/pkg/cmd" +) + +func main() { + loadTestCmd := cmd.NewDefaultGatlingCommanderCommand() + if err := loadTestCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: command run failed %v\n", err) + os.Exit(1) + } +} diff --git a/pkg/cmd/exec/exec.go b/pkg/cmd/exec/exec.go new file mode 100644 index 0000000..d6b732c --- /dev/null +++ b/pkg/cmd/exec/exec.go @@ -0,0 +1,628 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package exec implements command which exec loadtest specified in config.yaml. +package exec + +import ( + "context" + "errors" + "fmt" + "os" + osExec "os/exec" + "os/signal" + "sync" + "time" + + cfg "github.com/st-tech/gatling-commander/pkg/config" + "github.com/st-tech/gatling-commander/pkg/external/cloudstorages" + slackTools "github.com/st-tech/gatling-commander/pkg/external/slack" + sheetTools "github.com/st-tech/gatling-commander/pkg/external/spreadsheet" + gatlingTools "github.com/st-tech/gatling-commander/pkg/internal/gatling" + kubeapiTools "github.com/st-tech/gatling-commander/pkg/internal/kubeapi" + + "github.com/spf13/cobra" + gatlingv1alpha1 "github.com/st-tech/gatling-operator/api/v1alpha1" + ctrl "sigs.k8s.io/controller-runtime" + ctrlClient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +type execFlags struct { + skipBuild bool +} + +type loadtestExecError struct { + serviceName string + scenarioName string + err error +} + +type metricsUsageRatio struct { + cpu float64 // ex: 0.1 (10%) + memory float64 +} + +type cloudStorageOperator interface { + Fetch(ctx context.Context, path string) ([]byte, error) +} + +type notifyOperator interface { + Notify(msg string) error +} + +type serviceConfig struct { + name string + spreadsheetId string + failFast bool + targetLatency float64 + targetPercentile uint32 +} + +type checkContinueToExecResult struct { + shouldContinue bool + message string +} + +func newExecFlags() *execFlags { + f := &execFlags{} + return f +} + +func (f *execFlags) addFlags(cmd *cobra.Command) { + cmd.Flags().BoolVar(&f.skipBuild, "skip-build", false, "skip build flag") +} + +func (f *execFlags) validateFlags(config *cfg.Config) error { + if f.skipBuild && config.ImageURL == "" { + return fmt.Errorf("skip-build flag specified, but there is no imageURL value") + } + return nil +} + +// NewCmdExec creates the `exec` command. +func NewCmdExec(baseName string, config *cfg.Config) *cobra.Command { + flags := newExecFlags() + + // Need setting logger to avoid controller-runtime error + // error: ([controller-runtime] log.SetLogger(...) was never called, logs will not be displayed:). + ctrl.SetLogger(zap.New()) + + cmd := &cobra.Command{ + Use: "exec", + Short: "Load configuration, execute load test, and record result", + Long: `The exec command load configuration file which has specified path with config arguments. + And execute load test by creating Gatling Resource in the cluster. + This command load Gatling Report and get load test target container metrics, and record it in specified Google Sheets. + Complete documentation is available at https://github.com/st-tech/gatling-commander/docs`, + RunE: func(cmd *cobra.Command, args []string) error { + err := runExec(cmd, config, flags) + if config.SlackConfig.WebhookURL != "" { + isSuccess := false + if err == nil { + isSuccess = true + } + err := runNotify(config.SlackConfig, isSuccess) + if err != nil { + fmt.Fprintf(os.Stderr, "Error failed to notify slack %v\n", err) + } else { + fmt.Printf("notify to slack succeeded\n") + } + } + return err + }, + } + + flags.addFlags(cmd) + return cmd +} + +/* +runExec execute all loadtests written in config/config.yaml. + +Per-service loadtests are run in parallel. +Each loadtest of service run in order. +If failFast or targetLatency value is set and loadtest finished with this condition, next loadtest of same service is +not executed. (checkContinueToExec) +The error occured in each loadtest will be output after all loadtest finished. +*/ +func runExec(cmd *cobra.Command, config *cfg.Config, flags *execFlags) error { + ctx, cancel := context.WithCancel(context.Background()) + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, os.Interrupt) + defer func() { + signal.Stop(signalCh) + cancel() + }() + go func() { + <-signalCh + cancel() + }() + + /* + Build and push image. + https://go.dev/src/time/format.go + Format is YYYYMMDDhhmm. + */ + execDate := time.Now().Format("200601021504") + imgTag := config.ImagePrefix + "-" + execDate + var imgURL string + if err := flags.validateFlags(config); err != nil { + return fmt.Errorf("config param or argument invalid %v", err) + } + if !flags.skipBuild { + genImageURL, err := buildPushImage(config.ImageRepository, imgTag, config.GatlingDockerfileDir) + if err != nil { + return fmt.Errorf("gatling image build error %v", err) + } + imgURL = genImageURL + } else { + imgURL = config.ImageURL + } + + /* + Create channel for receive error in each loadtest run (runLoadtestAndRecord). + Set number of loadtests of service as max error buffer length. + Each loadtest run returns at most one error, so loadtestErrorCh buffer is less than or equal to service num. + */ + loadtestErrorCh := make(chan loadtestExecError, len(config.Services)) + + wg := new(sync.WaitGroup) + for _, service := range config.Services { + wg.Add(1) + go func(ctx context.Context, s cfg.Service) { + defer wg.Done() + serviceConfig := extractServiceConfig(s) + for _, scenarioSpec := range s.ScenarioSpecs { + serviceName := serviceConfig.name + scenarioName := scenarioSpec.Name + scenarioSubName := scenarioSpec.SubName + /* + loadtestExecError struct type has err field. + when error occured, write it to this field and log at parent function too. + */ + occuredErr := loadtestExecError{ + serviceName: serviceName, + scenarioName: fmt.Sprintf("%v %v", scenarioName, scenarioSubName), + } + gatlingReport, err := runLoadtestAndRecord( + ctx, + config.GatlingContextName, + imgURL, + config.BaseManifest, + config.StartupTimeoutSec, + config.ExecTimeoutSec, + serviceConfig, + s.TargetPodConfig, + scenarioSpec, + ) + if err != nil { + occuredErr.err = err + loadtestErrorCh <- occuredErr + return + } + checkContinue, err := checkContinueToExec(serviceConfig, *gatlingReport) + if err != nil { + occuredErr.err = err + loadtestErrorCh <- occuredErr + return + } + // This cancel condition is not caused by the loadtest error, so only log and finish goroutine. + if !checkContinue.shouldContinue { + fmt.Printf( + "service %v loadtest %v execution canceled, %v\n", + occuredErr.serviceName, + occuredErr.scenarioName, + checkContinue.message, + ) + return + } + } + }(ctx, service) + } + wg.Wait() + close(loadtestErrorCh) + if len(loadtestErrorCh) > 0 { + for result := range loadtestErrorCh { + fmt.Fprintf( + os.Stderr, + "Error: failed to run loadtest service %v scenario %v, error %v\n", + result.serviceName, + result.scenarioName, + result.err, + ) + } + return fmt.Errorf("more than one loadtest scenario failed") + } + return nil +} + +/* +runLoadtestAndRecord is main logic in exec command. + +runLoadtestAndRecord Create gatling object and run loadtest, fetch loadtest target container metrics. +Wait loadtest running and get gatling report, write report to spreadsheet. +*/ +func runLoadtestAndRecord( + ctx context.Context, + k8sCtxName string, + imgURL, manifestPath string, + waitStartupTimeout int32, + waitExecTimeout int32, + serviceConfig serviceConfig, + targetPodConfig cfg.TargetPodConfig, + scenarioSpec cfg.ScenarioSpec, +) (*gatlingTools.GatlingReport, error) { + scenarioName := scenarioSpec.Name + serviceName := serviceConfig.name + + fmt.Printf("Start service %v loadtest %v\n", serviceName, scenarioName) + + gatling, err := loadAndPatchBaseGatling(serviceName, imgURL, scenarioSpec, manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to patch gatling struct field, %v", err) + } + + k8sTargetPodClint, err := kubeapiTools.InitClient(targetPodConfig.ContextName) + fmt.Printf("service %v loadtest %v, k8s target pod client initialized\n", serviceName, scenarioName) + if err != nil { + return nil, fmt.Errorf("failed to init target pod k8s cluster client, %v", err) + } + + // Fetch resources limit value before run loadtest. + containerResourcesLimit, err := kubeapiTools.FetchContainerResourcesLimit(ctx, k8sTargetPodClint, targetPodConfig) + fmt.Printf("service %v loadtest %v, target pod resources limit fetched\n", serviceName, scenarioName) + if err != nil { + return nil, fmt.Errorf("failed to get pod spec resources %v", err) + } + + k8sGatlingClient, err := kubeapiTools.InitClient(k8sCtxName) + fmt.Printf("service %v loadtest %v, k8s gatling client initialized\n", serviceName, scenarioName) + if err != nil { + return nil, fmt.Errorf("failed to init k8s cluster client, %v", err) + } + + err = gatlingTools.CreateGatling(ctx, k8sGatlingClient, gatling) + fmt.Printf("service %v loadtest %v, Gatling Object created\n", serviceName, scenarioName) + if err != nil { + return nil, fmt.Errorf("failed to create gatling object, %v", err) + } + + err = gatlingTools.WaitGatlingJobStartup(ctx, k8sGatlingClient, gatling, waitStartupTimeout) + fmt.Printf("service %v loadtest %v, Gatling Job Started\n", serviceName, scenarioName) + if err != nil { + return nil, fmt.Errorf("failed to wait gatling job start, %v", err) + } + + metricsCl, err := kubeapiTools.InitMetricsClient(targetPodConfig.ContextName) + fmt.Printf("service %v loadtest %v, k8s target pod metrics client initialized\n", serviceName, scenarioName) + if err != nil { + return nil, fmt.Errorf("failed to init k8s client for fetch metrics") + } + + wg := new(sync.WaitGroup) + wg.Add(1) + informJobFinishCh := make(chan bool, 1) + metricsUsageCh := make(chan kubeapiTools.MetricsField, 1) + // Fetch target container metrics in background during loadtest running. + go kubeapiTools.FetchContainerMetricsMean(ctx, wg, metricsCl, metricsUsageCh, informJobFinishCh, targetPodConfig) + + // Wait until Gatling job completed. + fmt.Printf("service %v loadtest %v, waiting Gatling Job Running\n", serviceName, scenarioName) + err = gatlingTools.WaitGatlingJobRunning(ctx, k8sGatlingClient, gatling, waitExecTimeout, informJobFinishCh) + fmt.Printf("service %v loadtest %v, waiting Gatling Job completed\n", serviceName, scenarioName) + if err != nil { + return nil, fmt.Errorf("failed to wait gatling job running, %v", err) + } + close(informJobFinishCh) + + wg.Wait() // Wait FetchContainerMetricsMean execution finish. + close(metricsUsageCh) + metricsUsageMean, ok := <-metricsUsageCh + if !ok { + fmt.Fprintf(os.Stderr, "metricsUsageCh value is empty, so each metricsUsage field value is 0") + } + + // Calculate ratio from usage mean and resource limit. + metricsUsageRatio := metricsUsageRatio{ + cpu: kubeapiTools.CalcAndRoundMetricsRatio(metricsUsageMean.Cpu, containerResourcesLimit.Cpu), + memory: kubeapiTools.CalcAndRoundMetricsRatio(metricsUsageMean.Memory, containerResourcesLimit.Memory), + } + + storageOp, err := cloudstorages.NewGoogleCloudStorageOperator(ctx) + if err != nil { + return nil, fmt.Errorf("failed to init cloud storage operator client, %v", err) + } + + // Fetch storage path from Gatling object and fetch gatlingReport from storage. And parse jsonBytes of gatlingReport + // to GatlingReport object. + gatlingReport, err := loadGatlingReportFromCloudStorage(ctx, storageOp, k8sGatlingClient, gatling) + if err != nil { + return nil, fmt.Errorf("failed to load gatling report from cloud storage, %v", err) + } + + // Write loadtest report to spreadsheet. + fmt.Printf("service %v loadtest %v, start to write Gatling Report to Spreadsheets\n", serviceName, scenarioName) + err = writeReportToSpreadsheets(ctx, imgURL, serviceConfig, scenarioSpec, gatlingReport, metricsUsageRatio) + if err != nil { + return nil, fmt.Errorf("failed to write gatling report to spreadsheets, %v", err) + } + + fmt.Printf("service %v loadtest %v succeeded\n", serviceName, scenarioName) + return gatlingReport, nil +} + +// runNotify check config.yaml webhookURL parameter and notify loadtest finished to slack. +func runNotify(slackConfig cfg.SlackConfig, isSuccess bool) error { + webhookURL := slackConfig.WebhookURL + mention := slackConfig.MentionText + + // skip notify to slack + if webhookURL == "" { + fmt.Printf("slack webhook url not found, skip notify to slack\n") + return nil + } + + slackOp := slackTools.NewSlackOperator(webhookURL) + data := slackTools.GenerateSlackPayloadData(mention, isSuccess) + if err := notifyLoadtestResult(slackOp, data); err != nil { + return err + } + return nil +} + +func buildPushImage(imgRepo string, imgTag string, gatlingDockerfileDir string) (string, error) { + imgURL := fmt.Sprintf("%s:%s", imgRepo, imgTag) + buildArgs := []string{ + "build", + "--platform", + "linux/x86_64", + "-t", + imgURL, + "-f", + fmt.Sprintf("%s/Dockerfile", gatlingDockerfileDir), + fmt.Sprintf("./%s", gatlingDockerfileDir), + } + + buildCmd := osExec.Command("docker", buildArgs...) + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + if err := buildCmd.Run(); err != nil { + return "", err + } + + authCmd := osExec.Command("gcloud", "auth", "configure-docker") + authCmd.Stdout = os.Stdout + authCmd.Stderr = os.Stderr + if err := authCmd.Run(); err != nil { + return "", err + } + + pushCmd := osExec.Command("docker", "push", imgURL) + pushCmd.Stdout = os.Stdout + pushCmd.Stderr = os.Stderr + if err := pushCmd.Run(); err != nil { + return "", err + } + return imgURL, nil +} + +/* +loadAndPatchBaseGatling load k8s gatling manifest to gatling object and set config.yaml value to replace target field +in base_manifest.yaml. +*/ +func loadAndPatchBaseGatling( + serviceName string, + imgURL string, + scenarioSpec cfg.ScenarioSpec, + baseManifest string, +) (*gatlingv1alpha1.Gatling, error) { + gatling, err := gatlingTools.LoadGatlingManifest(baseManifest) + if err != nil { + return nil, err + } + gatling.ObjectMeta.Name = serviceName + gatling.Spec.PodSpec.GatlingImage = imgURL + gatling.Spec.TestScenarioSpec = scenarioSpec.TestScenarioSpec + return gatling, nil +} + +/* +loadGatlingReportFromCloudStorage fetch gatling report and parse to gatling report object. + +Fetch gatling report path in cloud storage from gatling object. And fetch gatling report bytes from cloud storage. +And parse gatling report bytes to gatling report object. +*/ +func loadGatlingReportFromCloudStorage( + ctx context.Context, + op cloudStorageOperator, + cl ctrlClient.Client, + gatling *gatlingv1alpha1.Gatling, +) (*gatlingTools.GatlingReport, error) { + reportStorageFolderPath, err := gatlingTools.GetGatlingReportStoragePath(ctx, cl, gatling) + reportStorageObjectPath := reportStorageFolderPath + "/js/global_stats.json" + if err != nil { + return nil, fmt.Errorf("failed to get gatling report storage path, %w\n", err) + } + fetchedReportBytes, err := op.Fetch(ctx, reportStorageObjectPath) + if err != nil { + return nil, fmt.Errorf("failed to fetch report, %w\n", err) + } + gatlingReport, err := gatlingTools.BytesToGatlingReport(fetchedReportBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse gatling report, %w\n", err) + } + return gatlingReport, nil +} + +/* +writeReportToSpreadsheets write loadtest report to spreadsheet. + +Set column header name and add each row which has loadtest report value. +The sheet create by date by service. Set column header is called only once when sheet created. +*/ +func writeReportToSpreadsheets( + ctx context.Context, + imageURL string, + serviceConfig serviceConfig, + scenarioSpec cfg.ScenarioSpec, + gatlingReport *gatlingTools.GatlingReport, + mRatio metricsUsageRatio, +) error { + targetLatency := serviceConfig.targetLatency + targetPercentile := serviceConfig.targetPercentile + serviceName := serviceConfig.name + + op, err := sheetTools.NewSpreadsheetOperator(ctx, serviceConfig.spreadsheetId) + if err != nil { + return fmt.Errorf("failed to init spreadsheet operator, %w", err) + } + sheetTitle := fmt.Sprintf("%v-%v", scenarioSpec.Name, time.Now().Format("20060102")) + targetSheet, err := op.FindSheet(sheetTitle) + if err != nil && !errors.Is(err, &sheetTools.SheetNotFoundError{}) { + return fmt.Errorf("unexpected error occured when FindSheet, %w", err) + } + if errors.Is(err, &sheetTools.SheetNotFoundError{}) { + targetSheet, err = op.AddSheet(sheetTitle) + if err != nil { + return fmt.Errorf("failed to create new sheet, %w", err) + } + targetSheet, err = op.SetColumnHeader(targetSheet) + if err != nil { + return fmt.Errorf("failed to set cell name, %w", err) + } + } + var targetLatencyFieldValue string + if targetPercentile == 0 && targetLatency == 0 { + targetLatencyFieldValue = "target latency not specified" + } else { + targetLatencyFieldValue = fmt.Sprintf("percentile %v, latency %vms", targetPercentile, targetLatency) + } + commonSettingValue := sheetTools.NewLoadtestCommonSettingRow(imageURL, serviceName, targetLatencyFieldValue) + targetSheet, err = op.SetLoadtestCommonSettingValue(commonSettingValue, targetSheet) + if err != nil { + return fmt.Errorf("failed to set loadtest common setting value %w", err) + } + + concurrency, duration, condition, err := gatlingTools.ExtractLoadtestConditionToReport( + scenarioSpec.TestScenarioSpec, + ) + if err != nil { + return fmt.Errorf("failed to parse loadtest condition %w", err) + } + + row := sheetTools.NewLoadtestReportRow( + scenarioSpec.SubName, + condition, + duration, + concurrency, + gatlingReport.MaxResponseTime.Ok, + gatlingReport.MeanResponseTime.Ok, + gatlingReport.FiftiethPercentiles.Ok, + gatlingReport.SeventyFifthPercentiles.Ok, + gatlingReport.NintyFifthPercentiles.Ok, + gatlingReport.NintyNinthPercentiles.Ok, + gatlingReport.Failed.Percentage, + gatlingReport.UnderEightHundredMilliSec.Percentage, + gatlingReport.BetweenFromEightHundredToOneThousandTwoHundredMilliSec.Percentage, + gatlingReport.OverOneThousandTwoHundredMilliSec.Percentage, + mRatio.cpu*100, // conv ratio to percentage + mRatio.memory*100, // conv ratio to percentage + ) + _, err = op.AppendLoadtestReportRow(row, targetSheet) + if err != nil { + return err + } + return nil +} + +// notifyLoadtestResult call notifyOperator Notify method. +func notifyLoadtestResult(op notifyOperator, data string) error { + err := op.Notify(data) + if err != nil { + return err + } + return nil +} + +/* +checkContinueToExec returns checkContinueToExecResult which has shouldContinue boolean flag. + +If shouldContinue is false, loadtest per service will be interrupted. + +Some cases of shouldContinue is false are below. +- config.yaml parameter failFast is true and gatlingReport.Failed.Percentage is more than 0 +- config.yaml parameter targetLatency and targetPercentile specified, +and gatlingReport target Percentile latency value is more than targetLatency. +*/ +func checkContinueToExec( + serviceConfig serviceConfig, + gatlingReport gatlingTools.GatlingReport, +) (*checkContinueToExecResult, error) { + failFast := serviceConfig.failFast + targetLatency := serviceConfig.targetLatency + targetPercentile := serviceConfig.targetPercentile + + // Check failed percentage + if failFast { + if gatlingReport.Failed.Percentage > 0 { + return &checkContinueToExecResult{ + shouldContinue: false, + message: "failed percentage greater than 0", + }, nil + } + } + // nolint:lll // If targetLatency and targetPercentile field value exist, check whether latency in gatling report is larger than target latency or not. + // these field are already validated when cli loaded config.yaml. + if targetLatency != 0 && targetPercentile != 0 { + resultLatency, err := gatlingReport.GetPercentileLatency(targetPercentile) + if err != nil { + return nil, fmt.Errorf("failed to get specified percentile latency %v", err) + } + if resultLatency > targetLatency { + return &checkContinueToExecResult{ + shouldContinue: false, + message: fmt.Sprintf( + "latency below specified target, target: %v, result: %v", + targetLatency, + resultLatency, + ), + }, nil + } + } + return &checkContinueToExecResult{ + shouldContinue: true, + message: "", + }, nil +} + +// extractServiceConfig extract cfg.Service struct field use for logging service metadata and so on. +func extractServiceConfig(s cfg.Service) serviceConfig { + return serviceConfig{ + name: s.Name, + spreadsheetId: s.SpreadsheetId, + failFast: s.FailFast, + targetLatency: s.TargetLatency, + targetPercentile: s.TargetPercentile, + } +} diff --git a/pkg/cmd/exec/exec_test.go b/pkg/cmd/exec/exec_test.go new file mode 100644 index 0000000..20ff593 --- /dev/null +++ b/pkg/cmd/exec/exec_test.go @@ -0,0 +1,439 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package exec + +import ( + "context" + "fmt" + "os" + "testing" + + cfg "github.com/st-tech/gatling-commander/pkg/config" + "github.com/st-tech/gatling-commander/pkg/internal/gatling" + gatlingTools "github.com/st-tech/gatling-commander/pkg/internal/gatling" + kubeutil "github.com/st-tech/gatling-commander/pkg/internal/kubeutil" + + "github.com/google/go-cmp/cmp" + "github.com/jinzhu/copier" + gatlingv1alpha1 "github.com/st-tech/gatling-operator/api/v1alpha1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +const ( + ServiceName = "sample-service" + BaseManifest = "testdata/base_manifest.yaml" + Root = "../../.." + ImgURL = "example/gatling-scenario/sample-202308021850" + SampleGatlingManifestPath = "testdata/sample_gatling_manifest.yaml" +) + +type mockCloudStorageOperator struct{} + +func (op *mockCloudStorageOperator) Fetch(ctx context.Context, path string) ([]byte, error) { + if path == "" { + return nil, fmt.Errorf("path parameter value is empty") + } + data, _ := os.ReadFile(path) + return data, nil +} + +func TestLoadAndPatchBaseGatling(t *testing.T) { + sampleGatling, err := gatlingTools.LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + tests := []struct { + name string + scenarioSpec cfg.ScenarioSpec + hasDiff bool + }{ + { + name: "expected no diff", + scenarioSpec: cfg.ScenarioSpec{ + Name: "sample-scenario", + TestScenarioSpec: gatlingv1alpha1.TestScenarioSpec{ + SimulationClass: "SampleScenario", + Parallelism: 1, + Env: []corev1.EnvVar{ + { + Name: "ENV", + Value: "stg", + }, + { + Name: "CONCURRENCY", + Value: "25", + }, + { + Name: "DURATION", + Value: "10", + }, + }, + }, + }, + hasDiff: false, + }, + { + name: "expected diff (no env field in generated manifest)", + scenarioSpec: cfg.ScenarioSpec{ + Name: "sample-scenario", + TestScenarioSpec: gatlingv1alpha1.TestScenarioSpec{ + SimulationClass: "SampleScenario", + Parallelism: 1, + }, + }, + hasDiff: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gatling, err := loadAndPatchBaseGatling(ServiceName, ImgURL, tt.scenarioSpec, BaseManifest) + assert.NoError(t, err) + if diff := cmp.Diff(*sampleGatling, *gatling); (diff == "") == tt.hasDiff { + t.Errorf("%v: unexpected diff found %v, diff %v", tt.hasDiff, diff != "", diff) + } + }) + } +} + +func TestValidateFlags(t *testing.T) { + // assign to var to refer to it as a pointer + skipBuildTrue := true + skipBuildFalse := false + + tests := []struct { + name string + flags execFlags + config cfg.Config + expected error + }{ + { + name: "valid flag value (skipBuild true and there is imageURL value)", + flags: execFlags{ + skipBuild: skipBuildTrue, + }, + config: cfg.Config{ + ImageURL: "example", + }, + expected: nil, + }, + { + name: "invalid flag value (skipBuild true but there is no imageURL value)", + flags: execFlags{ + skipBuild: skipBuildTrue, + }, + config: cfg.Config{ + ImageURL: "", + }, + expected: fmt.Errorf("skip-build flag specified, but there is no imageURL value"), + }, + { + name: "valid flag value (skipBuild false and there is imageURL value)", + flags: execFlags{ + skipBuild: skipBuildFalse, + }, + config: cfg.Config{ + ImageURL: "example", + }, + expected: nil, + }, + { + name: "valid flag value (skipBuild false and there is no imageURL value)", + flags: execFlags{ + skipBuild: skipBuildFalse, + }, + config: cfg.Config{ + ImageURL: "", + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.flags.validateFlags(&tt.config) + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestLoadGatlingReportFromCloudStorage(t *testing.T) { + cl := kubeutil.InitFakeClient() + reportCompletedGatling, err := gatlingTools.LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + reportCompletedGatling.Status = gatlingv1alpha1.GatlingStatus{ + ReportCompleted: true, + ReportStoragePath: "testdata/gatling_report_sample", + } + // create Status.ReportCompleted field value false gatling object + err = cl.Create(context.TODO(), reportCompletedGatling) + assert.NoError(t, err) + op := &mockCloudStorageOperator{} + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + }{ + { + name: "load gatling report from cloud storage success", + gatling: reportCompletedGatling, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := loadGatlingReportFromCloudStorage(context.TODO(), op, cl, tt.gatling) + assert.NoError(t, err) + }) + } +} + +func TestLoadGatlingReportFromCloudStorage_Fail(t *testing.T) { + cl := kubeutil.InitFakeClient() + reportCompletedFalseGatling, err := gatlingTools.LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + noReportStoragePathGatling, err := gatlingTools.LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + reportCompletedFalseGatling.ObjectMeta.Name = "report completed" + reportCompletedFalseGatling.Status = gatlingv1alpha1.GatlingStatus{ + ReportCompleted: false, + ReportStoragePath: "", + } + noReportStoragePathGatling.ObjectMeta.Name = "no report storage path" + noReportStoragePathGatling.Status = gatlingv1alpha1.GatlingStatus{ + ReportCompleted: true, + ReportStoragePath: "", + } + // create gatling object preparation + err = cl.Create(context.TODO(), reportCompletedFalseGatling) + assert.NoError(t, err) + err = cl.Create(context.TODO(), noReportStoragePathGatling) + assert.NoError(t, err) + op := &mockCloudStorageOperator{} + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + }{ + { + name: "gatling object not exists", + gatling: &gatlingv1alpha1.Gatling{}, + }, + { + name: "gatling report completed false", + gatling: reportCompletedFalseGatling, + }, + { + name: "gatling report storage path field is invalid", + gatling: noReportStoragePathGatling, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := loadGatlingReportFromCloudStorage(context.TODO(), op, cl, tt.gatling) + assert.Error(t, err) + }) + } +} + +func TestCheckContinueToExec_FailFast(t *testing.T) { + var failedExistsReport gatling.GatlingReport + sampleReport := gatling.GatlingReport{ + FiftiethPercentiles: gatling.GatlingReportStats{ + Ok: 70, + }, + SeventyFifthPercentiles: gatling.GatlingReportStats{ + Ok: 80, + }, + NintyFifthPercentiles: gatling.GatlingReportStats{ + Ok: 90, + }, + NintyNinthPercentiles: gatling.GatlingReportStats{ + Ok: 100, + }, + } + err := copier.CopyWithOption(&failedExistsReport, sampleReport, copier.Option{ + IgnoreEmpty: false, + DeepCopy: true, + }) + assert.NoError(t, err) + failedExistsReport.Failed.Percentage = 10 + + tests := []struct { + name string + serviceConfig serviceConfig + gatlingReport gatling.GatlingReport + expected *checkContinueToExecResult + }{ + { + name: "failFast false, should continue", + serviceConfig: serviceConfig{ + failFast: false, + }, + gatlingReport: sampleReport, + expected: &checkContinueToExecResult{ + shouldContinue: true, + message: "", + }, + }, + { + name: "failFast true, should not continue", + serviceConfig: serviceConfig{ + failFast: true, + }, + gatlingReport: failedExistsReport, + expected: &checkContinueToExecResult{ + shouldContinue: false, + message: "failed percentage greater than 0", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkContinue, err := checkContinueToExec(tt.serviceConfig, tt.gatlingReport) + assert.NoError(t, err) + assert.Equal(t, tt.expected, checkContinue) + }) + } +} + +func TestCheckContinueToExec_TargetLatency(t *testing.T) { + sampleReport := gatling.GatlingReport{ + FiftiethPercentiles: gatling.GatlingReportStats{ + Ok: 70, + }, + SeventyFifthPercentiles: gatling.GatlingReportStats{ + Ok: 80, + }, + NintyFifthPercentiles: gatling.GatlingReportStats{ + Ok: 90, + }, + NintyNinthPercentiles: gatling.GatlingReportStats{ + Ok: 100, + }, + Failed: gatling.GatlingReportGroup{ + Percentage: 0, + }, + } + + latencyBelowServiceConfig := serviceConfig{ + failFast: true, + targetLatency: 10, + targetPercentile: 99, + } + + tests := []struct { + name string + serviceConfig serviceConfig + gatlingReport gatling.GatlingReport + expected *checkContinueToExecResult + }{ + { + name: "check latency, should continue", + serviceConfig: serviceConfig{ + failFast: true, + targetLatency: 800, + targetPercentile: 99, + }, + gatlingReport: sampleReport, + expected: &checkContinueToExecResult{ + shouldContinue: true, + message: "", + }, + }, + { + name: "not check latency, should continue", + serviceConfig: serviceConfig{ + failFast: true, + targetLatency: 0, + targetPercentile: 0, + }, + gatlingReport: sampleReport, + expected: &checkContinueToExecResult{ + shouldContinue: true, + message: "", + }, + }, + { + name: "check latency, should not continue", + serviceConfig: latencyBelowServiceConfig, + gatlingReport: sampleReport, + expected: &checkContinueToExecResult{ + shouldContinue: false, + message: fmt.Sprintf( + "latency below specified target, target: %v, result: %v", + latencyBelowServiceConfig.targetLatency, + sampleReport.NintyNinthPercentiles.Ok, + ), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkContinue, err := checkContinueToExec(tt.serviceConfig, sampleReport) + assert.NoError(t, err) + assert.Equal(t, tt.expected, checkContinue) + }) + } +} + +func TestCheckContinueToExec_Failed(t *testing.T) { + sampleReport := gatling.GatlingReport{ + FiftiethPercentiles: gatling.GatlingReportStats{ + Ok: 70, + }, + SeventyFifthPercentiles: gatling.GatlingReportStats{ + Ok: 80, + }, + NintyFifthPercentiles: gatling.GatlingReportStats{ + Ok: 90, + }, + NintyNinthPercentiles: gatling.GatlingReportStats{ + Ok: 100, + }, + Failed: gatling.GatlingReportGroup{ + Percentage: 0, + }, + } + + tests := []struct { + name string + serviceConfig serviceConfig + gatlingReport gatling.GatlingReport + expected error + }{ + { + name: "error invalid percentile specified", + serviceConfig: serviceConfig{ + failFast: true, + targetLatency: 800, + targetPercentile: 80, + }, + gatlingReport: sampleReport, + expected: fmt.Errorf( + "failed to get specified percentile latency %v", + fmt.Errorf("specified percentile value is not matched to GatlingReport field"), + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := checkContinueToExec(tt.serviceConfig, sampleReport) + assert.Equal(t, tt.expected, err) + }) + } +} diff --git a/pkg/cmd/exec/testdata/base_manifest.yaml b/pkg/cmd/exec/testdata/base_manifest.yaml new file mode 100644 index 0000000..5d371c6 --- /dev/null +++ b/pkg/cmd/exec/testdata/base_manifest.yaml @@ -0,0 +1,47 @@ +apiVersion: gatling-operator.tech.zozo.com/v1alpha1 +kind: Gatling +metadata: + name: + namespace: gatling-system # specify namespace which has service account for gatling worker pod +spec: + generateReport: true + generateLocalReport: true + notifyReport: false + cleanupAfterJobDone: false + podSpec: + gatlingImage: + rcloneImage: rclone/rclone + resources: + requests: + cpu: "7000m" + memory: "4G" + limits: + cpu: "7000m" + memory: "4G" + serviceAccountName: "gatling-operator-worker" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cloud.google.com/gke-nodepool + operator: In + values: + - "gatling-operator-worker-v1" + tolerations: + - key: "dedicated" + operator: "Equal" + value: "gatling-operator-worker-v1" + effect: "NoSchedule" + cloudStorageSpec: + provider: "gcp" + bucket: "gatling-operator-reports" + notificationServiceSpec: + provider: "slack" # Notification provider name. Supported provider: "slack" + secretName: "gatling-notification-slack-secrets" # The name of secret in which all key/value sets needed for the notification are stored + testScenarioSpec: + parallelism: 1 # Optional. Default: 1. Number of pods running at any instan + simulationClass: # Gatling simulation class name + env: # Optional. Environment variables to be used in Gatling Simulation Scala + - name: + value: diff --git a/pkg/cmd/exec/testdata/gatling_report_sample/js/global_stats.json b/pkg/cmd/exec/testdata/gatling_report_sample/js/global_stats.json new file mode 100644 index 0000000..a87a8c1 --- /dev/null +++ b/pkg/cmd/exec/testdata/gatling_report_sample/js/global_stats.json @@ -0,0 +1,73 @@ +{ + "name": "Global Information", + "numberOfRequests": { + "total": 10, + "ok": 10, + "ko": 0 + }, + "minResponseTime": { + "total": 69, + "ok": 69, + "ko": 0 + }, + "maxResponseTime": { + "total": 595, + "ok": 595, + "ko": 0 + }, + "meanResponseTime": { + "total": 257, + "ok": 257, + "ko": 0 + }, + "standardDeviation": { + "total": 214, + "ok": 214, + "ko": 0 + }, + "percentiles1": { + "total": 113, + "ok": 113, + "ko": 0 + }, + "percentiles2": { + "total": 472, + "ok": 472, + "ko": 0 + }, + "percentiles3": { + "total": 564, + "ok": 564, + "ko": 0 + }, + "percentiles4": { + "total": 589, + "ok": 589, + "ko": 0 + }, + "group1": { + "name": "t < 800 ms", + "count": 10, + "percentage": 100 + }, + "group2": { + "name": "800 ms < t < 1200 ms", + "count": 0, + "percentage": 0 + }, + "group3": { + "name": "t > 1200 ms", + "count": 0, + "percentage": 0 + }, + "group4": { + "name": "failed", + "count": 0, + "percentage": 0 + }, + "meanNumberOfRequestsPerSecond": { + "total": 1.1111111111111112, + "ok": 1.1111111111111112, + "ko": 0 + } +} diff --git a/pkg/cmd/exec/testdata/sample_gatling_manifest.yaml b/pkg/cmd/exec/testdata/sample_gatling_manifest.yaml new file mode 100644 index 0000000..5c270d3 --- /dev/null +++ b/pkg/cmd/exec/testdata/sample_gatling_manifest.yaml @@ -0,0 +1,51 @@ +apiVersion: gatling-operator.tech.zozo.com/v1alpha1 +kind: Gatling +metadata: + name: sample-service + namespace: gatling-system # specify namespace which has service account for gatling worker pod +spec: + generateReport: true + generateLocalReport: true + notifyReport: false + cleanupAfterJobDone: false + podSpec: + gatlingImage: example/gatling-scenario/sample-202308021850 + rcloneImage: rclone/rclone + resources: + requests: + cpu: "7000m" + memory: "4G" + limits: + cpu: "7000m" + memory: "4G" + serviceAccountName: "gatling-operator-worker" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cloud.google.com/gke-nodepool + operator: In + values: + - "gatling-operator-worker-v1" + tolerations: + - key: "dedicated" + operator: "Equal" + value: "gatling-operator-worker-v1" + effect: "NoSchedule" + cloudStorageSpec: + provider: "gcp" + bucket: "gatling-operator-reports" + notificationServiceSpec: + provider: "slack" # Notification provider name. Supported provider: "slack" + secretName: "gatling-notification-slack-secrets" # The name of secret in which all key/value sets needed for the notification are stored + testScenarioSpec: + parallelism: 1 # Optional. Default: 1. Number of pods running at any instan + simulationClass: SampleScenario # Gatling simulation class name + env: # Optional. Environment variables to be used in Gatling Simulation Scala + - name: ENV + value: "stg" + - name: CONCURRENCY + value: "25" + - name: DURATION + value: "10" diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go new file mode 100644 index 0000000..52b4467 --- /dev/null +++ b/pkg/cmd/root.go @@ -0,0 +1,129 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package cmd implements root command of gatling-commander. +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/st-tech/gatling-commander/pkg/cmd/exec" + cfg "github.com/st-tech/gatling-commander/pkg/config" +) + +type gatlingCommanderOptions struct { + Arguments []string +} + +var configFile string +var config cfg.Config + +const rootCmdName = "gatling-commander" + +// NewDefaultGatlingCommanderCommand creates the 'gatling-commander' command with default arguments. +func NewDefaultGatlingCommanderCommand() *cobra.Command { + return NewDefaultGatlingCommanderCommandWithArgs(gatlingCommanderOptions{ + Arguments: os.Args, + }) +} + +// NewDefaultGatlingCommanderCommandWithArgs creates the 'gatling-commander' command with arguments. +func NewDefaultGatlingCommanderCommandWithArgs(o gatlingCommanderOptions) *cobra.Command { + cmd := NewGatlingCommanderCommand(o) + + if len(o.Arguments) > 1 { + cmdPathPieces := o.Arguments[1:] + var cmdName string + if _, _, err := cmd.Find(cmdPathPieces); err != nil { + for _, arg := range cmdPathPieces { + if !strings.HasPrefix(arg, "-") { + cmdName = arg + break + } + } + switch cmdName { + case "help": + // Avoid unsupported command error. + // The help command display default help message generated by cobra. + default: + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + } + + return cmd +} + +// NewGatlingCommanderCommand creates the 'gatling-commander' command and its nested children. +func NewGatlingCommanderCommand(o gatlingCommanderOptions) *cobra.Command { + // Parent Command to which all subcommands are added. + cmds := &cobra.Command{ + Use: rootCmdName, + Short: "gatling-commander automates the execution of load test using Gatling Operator", + Long: `gatling-commander is a CLI tool that automates a series of tasks + in the execution of load test using Gatling Operator. + Complete documentation is available at https://github.com/st-tech/gatling-commander/docs`, + } + + cmds.PersistentFlags().StringVarP(&configFile, "config", "c", "", "config file name") + + cobra.OnInitialize(func() { + // avoid to return error when run help command without config flag + if configFile == "" { + if len(o.Arguments) > 1 { + cmdName := o.Arguments[1] + if cmdName == "help" { + _ = cmds.Help() + os.Exit(0) + } + } + fmt.Fprintf(os.Stderr, "Error: config file not provided\n") + os.Exit(1) + } + + viper.SetConfigFile(configFile) + + if err := viper.ReadInConfig(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if err := viper.Unmarshal(&config); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if err := config.ValidateFieldValue(); err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid config param %v\n", err) + os.Exit(1) + } + }) + cmds.CompletionOptions.DisableDefaultCmd = true + cmds.AddCommand(exec.NewCmdExec(rootCmdName, &config)) + return cmds +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..9c3a0a0 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,162 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/* +Package config map config.yaml to config.Config object and other nested struct type object. +*/ +package config + +import ( + "fmt" + + "github.com/st-tech/gatling-commander/pkg/internal/gatling" + "github.com/st-tech/gatling-commander/pkg/util" +) + +// Config map config/config.yaml field value. +type Config struct { + GatlingContextName string `yaml:"gatlingContextName"` + ImageRepository string `yaml:"imageRepository"` + ImagePrefix string `yaml:"imagePrefix"` + ImageURL string `yaml:"imageURL"` + GatlingDockerfileDir string `yaml:"gatlingDockerfileDir"` + BaseManifest string `yaml:"baseManifest"` + StartupTimeoutSec int32 `yaml:"startupTimeoutSec"` + ExecTimeoutSec int32 `yaml:"execTimeoutSec"` + SlackConfig SlackConfig `yaml:"slackConfig"` + Services []Service `yaml:"services"` +} + +/* +ValidateFieldValue validate config/config.yaml field value. + +Check items are below. + - each of Config object field value is set + - each of Service object field required value is set + - each of TargetPodConfig object is valid + - Service objects TargetPercentile and TargetLatency fields value are valid +*/ +func (c *Config) ValidateFieldValue() error { + if c.GatlingContextName == "" { + return fmt.Errorf("config param gatlingContextName is required") + } + if c.ImageRepository == "" { + return fmt.Errorf("config param imageRepostory is required") + } + if c.ImagePrefix == "" { + return fmt.Errorf("config param imagePrefix is required") + } + if c.GatlingDockerfileDir == "" { + return fmt.Errorf("config param gatlingDockerfileDir is required") + } + if c.BaseManifest == "" { + return fmt.Errorf("config param baseManifest is required") + } + if c.StartupTimeoutSec == 0 { + return fmt.Errorf("config param startupTimeout is required") + } + if c.ExecTimeoutSec == 0 { + return fmt.Errorf("config param execTimeout is required") + } + serviceNames := make([]string, 0, len(c.Services)) + for _, service := range c.Services { + if service.Name == "" { + return fmt.Errorf("config param service[].name is required") + } + if service.SpreadsheetId == "" { + return fmt.Errorf("config param service[].spreadsheetID is required") + } + err := validateGetTargetPodRequiredField(service.TargetPodConfig) + if err != nil { + return fmt.Errorf("config param filter target pod param is invalid %v", err) + } + err = validateTargetLatencyField(service.TargetPercentile, service.TargetLatency) + if err != nil { + return fmt.Errorf("config param check latency field value is invalid %v", err) + } + serviceNames = append(serviceNames, service.Name) + scenarioSpecNames := make([]string, 0, len(service.ScenarioSpecs)) + for _, scenarioSpec := range service.ScenarioSpecs { + scenarioSpecNames = append(scenarioSpecNames, scenarioSpec.Name+scenarioSpec.SubName) + } + if err := util.CheckDuplicate(scenarioSpecNames); err != nil { + return fmt.Errorf("%v, config.yaml scenarioSpec name duplicated in service %v", err, service.Name) + } + } + if err := util.CheckDuplicate(serviceNames); err != nil { + return fmt.Errorf("%v config.yaml service name duplicated", err) + } + return nil +} + +// validateTargetLatencyField validate config.yaml target latency field value. +func validateTargetLatencyField(percentile uint32, latency float64) error { + if latency < float64(0) { + return fmt.Errorf("invalid latency value specified, it must be more than 0") + } + err := checkTargetLatencyRequiredField(percentile, latency) + if err != nil { + return err + } + return nil +} + +// checkTargetLatencyRequiredField check required field for check target latency. +func checkTargetLatencyRequiredField(percentile uint32, latency float64) error { + if percentile == 0 && latency == 0 { // case: config.yaml targetPercentile & targetLatency value is empty + return nil + } + + if percentile == 0 || latency == 0 { + return fmt.Errorf("percentile must be set with latency, one of these is empty") + } + gatlingReport := &gatling.GatlingReport{} + if _, err := gatlingReport.GetPercentileLatency(percentile); err != nil { // validate specified percentile value + return err + } + return nil +} + +/* +validateGetTargetPodRequiredField validate config.yaml targetPodConfig field value. + +Check items are below. + - each of podConfig field value is set +*/ +func validateGetTargetPodRequiredField(podConfig TargetPodConfig) error { + if podConfig.ContextName == "" { + return fmt.Errorf("targetPod field contextName is required") + } + if podConfig.Namespace == "" { + return fmt.Errorf("targetPod field namespace is required") + } + if podConfig.LabelKey == "" { + return fmt.Errorf("targetPod field podLabelKey is required") + } + if podConfig.LabelValue == "" { + return fmt.Errorf("targetPod field podLabelValue is required") + } + if podConfig.ContainerName == "" { + return fmt.Errorf("targetPod field containerName is required") + } + return nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..a793f2e --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,389 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package config + +import ( + "fmt" + "os" + "testing" + + "github.com/jinzhu/copier" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +type targetLatencyField struct { + percentile uint32 + latency float64 +} + +var validConfig Config + +func init() { + testing.Init() + validConfigYaml, _ := os.ReadFile("testdata/valid_config.yaml") + if err := yaml.Unmarshal(validConfigYaml, &validConfig); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func TestValidateFieldValue(t *testing.T) { + noContextNameField, noImgRepoField, noImgPrefixField := validConfig, validConfig, validConfig + noGatlingDockerfileDirField, noBaseManifestField, noStartupTimeoutSecField := validConfig, validConfig, validConfig + noExecTimeoutSecField, serviceNameDuplicate := validConfig, validConfig + var ( + noServiceNameField Config + noSpreadsheetIdField Config + scenarioSpecNameDuplicate Config + ) + err := copier.CopyWithOption(&noServiceNameField, validConfig, copier.Option{ + IgnoreEmpty: false, + DeepCopy: true, + }) + assert.NoError(t, err) + err = copier.CopyWithOption(&noSpreadsheetIdField, validConfig, copier.Option{ + IgnoreEmpty: false, + DeepCopy: true, + }) + assert.NoError(t, err) + err = copier.CopyWithOption(&scenarioSpecNameDuplicate, validConfig, copier.Option{ + IgnoreEmpty: false, + DeepCopy: true, + }) + assert.NoError(t, err) + + noContextNameField.GatlingContextName = "" + noImgRepoField.ImageRepository = "" + noImgPrefixField.ImagePrefix = "" + noGatlingDockerfileDirField.GatlingDockerfileDir = "" + noBaseManifestField.BaseManifest = "" + noStartupTimeoutSecField.StartupTimeoutSec = 0 + noExecTimeoutSecField.ExecTimeoutSec = 0 + noServiceNameField.Services[0].Name = "" + noSpreadsheetIdField.Services[0].SpreadsheetId = "" + serviceNameDuplicate.Services = append(serviceNameDuplicate.Services, serviceNameDuplicate.Services[0]) + duplicateServiceName := serviceNameDuplicate.Services[0].Name + serviceNameDuplicateErr := fmt.Errorf("duplicated value found %v\n", []string{duplicateServiceName}) + scenarioSpecNameDuplicate.Services[0].ScenarioSpecs = append( + scenarioSpecNameDuplicate.Services[0].ScenarioSpecs, + scenarioSpecNameDuplicate.Services[0].ScenarioSpecs[0], + ) + scenarioSpecNameDuplicate.Services[0].ScenarioSpecs[1].Name = scenarioSpecNameDuplicate.Services[0].ScenarioSpecs[0].Name // nolint:lll + scenarioSpecNameDuplicate.Services[0].ScenarioSpecs[1].SubName = scenarioSpecNameDuplicate.Services[0].ScenarioSpecs[0].SubName // nolint:lll + scenarioSpecNameDuplicateErr := fmt.Errorf( + "duplicated value found %v\n", + []string{ + scenarioSpecNameDuplicate.Services[0].ScenarioSpecs[0].Name + scenarioSpecNameDuplicate.Services[0].ScenarioSpecs[0].SubName, // nolint:lll + }, + ) + + tests := []struct { + name string + config Config + expected error + }{ + { + name: "valid config field value", + config: validConfig, + expected: nil, + }, + { + name: "lack of config gatlingContextName field value", + config: noContextNameField, + expected: fmt.Errorf("config param gatlingContextName is required"), + }, + { + name: "lack of config imageRepository field value", + config: noImgRepoField, + expected: fmt.Errorf("config param imageRepostory is required"), + }, + { + name: "lack of config imagePrefix field value", + config: noImgPrefixField, + expected: fmt.Errorf("config param imagePrefix is required"), + }, + { + name: "lack of config gatlingDockerfileDir field value", + config: noGatlingDockerfileDirField, + expected: fmt.Errorf("config param gatlingDockerfileDir is required"), + }, + { + name: "lack of config baseManifest field value", + config: noBaseManifestField, + expected: fmt.Errorf("config param baseManifest is required"), + }, + { + name: "lack of config startupTimeoutSec field value", + config: noStartupTimeoutSecField, + expected: fmt.Errorf("config param startupTimeout is required"), + }, + { + name: "lack of config execTimeoutSec field value", + config: noExecTimeoutSecField, + expected: fmt.Errorf("config param execTimeout is required"), + }, + { + name: "lack of config service name field value", + config: noServiceNameField, + expected: fmt.Errorf("config param service[].name is required"), + }, + { + name: "lack of config service spreadsheetId field value", + config: noSpreadsheetIdField, + expected: fmt.Errorf("config param service[].spreadsheetID is required"), + }, + { + name: "config services[].name field value duplicate", + config: serviceNameDuplicate, + expected: fmt.Errorf("%v config.yaml service name duplicated", serviceNameDuplicateErr), + }, + { + name: "config services[].scenarioSpecs[].name field value duplicate", + config: scenarioSpecNameDuplicate, + expected: fmt.Errorf( + "%v, config.yaml scenarioSpec name duplicated in service %v", + scenarioSpecNameDuplicateErr, + duplicateServiceName, + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.ValidateFieldValue() + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestValidateTargetLatencyField_Success(t *testing.T) { + tests := []struct { + name string + input targetLatencyField + }{ + { + name: "percentile and latency is not specified (0)", + input: targetLatencyField{ + percentile: 0, + latency: 0, + }, + }, + { + name: "percentile and latency is specified", + input: targetLatencyField{ + percentile: 99, + latency: 100, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTargetLatencyField(tt.input.percentile, tt.input.latency) + assert.NoError(t, err) + }) + } +} + +func TestValidateTargetLatencyField_Failed(t *testing.T) { + tests := []struct { + name string + input targetLatencyField + expected error + }{ + { + name: "percentile and latency is negative value", + input: targetLatencyField{ + percentile: 99, + latency: -100, + }, + expected: fmt.Errorf("invalid latency value specified, it must be more than 0"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTargetLatencyField(tt.input.percentile, tt.input.latency) + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestCheckTargetLatencyRequiredField_Success(t *testing.T) { + tests := []struct { + name string + input targetLatencyField + }{ + { + name: "percentile and latency is not specified (0)", + input: targetLatencyField{ + percentile: 0, + latency: 0, + }, + }, + { + name: "percentile and latency is specified", + input: targetLatencyField{ + percentile: 99, + latency: 100, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTargetLatencyField(tt.input.percentile, tt.input.latency) + assert.NoError(t, err) + }) + } +} + +func TestCheckTargetLatencyRequiredField_Failed(t *testing.T) { + tests := []struct { + name string + input targetLatencyField + expected error + }{ + { + name: "percentile and latency is negative value", + input: targetLatencyField{ + percentile: 99, + latency: -100, + }, + expected: fmt.Errorf("invalid latency value specified, it must be more than 0"), + }, + { + name: "target percentile or target latency is not specified", + input: targetLatencyField{ + percentile: 0, + latency: 100, + }, + expected: fmt.Errorf("percentile must be set with latency, one of these is empty"), + }, + { + name: "invalid percentile specified", + input: targetLatencyField{ + percentile: 80, + latency: 100, + }, + expected: fmt.Errorf("specified percentile value is not matched to GatlingReport field"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTargetLatencyField(tt.input.percentile, tt.input.latency) + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestValidateGetTargetPodRequiredField(t *testing.T) { + tests := []struct { + name string + input TargetPodConfig + }{ + { + name: "validate get target pod required field success", + input: TargetPodConfig{ + ContextName: "gke_sample_asia-east1_gke_sample_asia", + Namespace: "sample", + LabelKey: "run", + LabelValue: "sample-api", + ContainerName: "sample-api", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGetTargetPodRequiredField(tt.input) + assert.NoError(t, err) + }) + } +} + +func TestValidateGetTargetPodRequiredField_Failed(t *testing.T) { + tests := []struct { + name string + input TargetPodConfig + expected error + }{ + { + name: "empty ContextName field value", + input: TargetPodConfig{ + ContextName: "", + Namespace: "sample", + LabelKey: "run", + LabelValue: "sample-api", + ContainerName: "sample-api", + }, + expected: fmt.Errorf("targetPod field contextName is required"), + }, + { + name: "empty Namespace field value", + input: TargetPodConfig{ + ContextName: "gke_sample_asia-east1_gke_sample_asia", + Namespace: "", + LabelKey: "run", + LabelValue: "sample-api", + ContainerName: "sample-api", + }, + expected: fmt.Errorf("targetPod field namespace is required"), + }, + { + name: "empty LabelKey field value", + input: TargetPodConfig{ + ContextName: "gke_sample_asia-east1_gke_sample_asia", + Namespace: "sample", + LabelKey: "", + LabelValue: "sample-api", + ContainerName: "sample-api", + }, + expected: fmt.Errorf("targetPod field podLabelKey is required"), + }, + { + name: "empty LabelValue filed value", + input: TargetPodConfig{ + ContextName: "gke_sample_asia-east1_gke_sample_asia", + Namespace: "sample", + LabelKey: "run", + LabelValue: "", + ContainerName: "sample-api", + }, + expected: fmt.Errorf("targetPod field podLabelValue is required"), + }, + { + name: "empty ContainerName filed value", + input: TargetPodConfig{ + ContextName: "gke_sample_asia-east1_gke_sample_asia", + Namespace: "sample", + LabelKey: "run", + LabelValue: "sample-api", + ContainerName: "", + }, + expected: fmt.Errorf("targetPod field containerName is required"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGetTargetPodRequiredField(tt.input) + assert.Equal(t, tt.expected, err) + }) + } +} diff --git a/pkg/config/testdata/valid_config.yaml b/pkg/config/testdata/valid_config.yaml new file mode 100644 index 0000000..729ed1e --- /dev/null +++ b/pkg/config/testdata/valid_config.yaml @@ -0,0 +1,44 @@ +gatlingContextName: gke_sample_asia-northeast1_sample +imageRepository: example/gatling-scenario +imagePrefix: sample +gatlingDockerfileDir: gatling_operator_scripts/gatling_buildkit +baseManifest: config/base_manifest.yaml +startupTimeoutSec: 1800 # 30min +execTimeoutSec: 10800 # 3h +services: + - name: sample-service + spreadsheetID: sample-id + failFast: false + targetPodConfig: + contextName: gke_sample_asia-east1_gke_sample_asia + namespace: default + labelKey: run + labelValue: sample-api + containerName: sample-api + targetPercentile: + targetLatency: + scenarioSpecs: + - name: sample-test-scenario + subName: -5rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "stg" + - name: CONCURRENCY + value: "5" + - name: DURATION + value: "10" + - name: sample-test-scenario + subName: 10rps + testScenarioSpec: + simulationClass: SampleSimulation + parallelism: 1 + env: + - name: ENV + value: "stg" + - name: CONCURRENCY + value: "10" + - name: DURATION + value: "10" diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 0000000..2077f7e --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,58 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package config + +import gatlingv1alpha1 "github.com/st-tech/gatling-operator/api/v1alpha1" + +// Service has common field among each loadtests per target service, and has several its ScenarioSpecs. +type Service struct { + Name string `yaml:"name"` + SpreadsheetId string `yaml:"spreadsheetID"` + FailFast bool `yaml:"failFast"` + TargetPodConfig TargetPodConfig `yaml:"targetPodConfig"` + TargetPercentile uint32 `yaml:"targetPercentile"` + TargetLatency float64 `yaml:"targetLatency"` + ScenarioSpecs []ScenarioSpec `yaml:"scenarioSpecs"` +} + +// SlackConfig has field which used for slack alert. +type SlackConfig struct { + WebhookURL string `yaml:"webhookURL"` + MentionText string `yaml:"mentionText"` +} + +// ScenarioSpec has each loadtest setting field. +type ScenarioSpec struct { + Name string `yaml:"name"` + SubName string `yaml:"subName"` + TestScenarioSpec gatlingv1alpha1.TestScenarioSpec `yaml:"testScenarioSpec"` +} + +// TargetPodConfig field value is used to fetch target container metrics value. +type TargetPodConfig struct { + ContextName string `yaml:"contextName"` + Namespace string `yaml:"namespace"` + LabelKey string `yaml:"labelKey"` + LabelValue string `yaml:"labelValue"` + ContainerName string `yaml:"containerName"` +} diff --git a/pkg/external/cloudstorages/doc.go b/pkg/external/cloudstorages/doc.go new file mode 100644 index 0000000..7ea410e --- /dev/null +++ b/pkg/external/cloudstorages/doc.go @@ -0,0 +1,2 @@ +// Package cloudstoarges implements operator of each cloud storage vendor. +package cloudstorages diff --git a/pkg/external/cloudstorages/gcp.go b/pkg/external/cloudstorages/gcp.go new file mode 100644 index 0000000..204e931 --- /dev/null +++ b/pkg/external/cloudstorages/gcp.go @@ -0,0 +1,67 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package cloudstorages + +import ( + "context" + "fmt" + "io" + "time" + + "cloud.google.com/go/storage" +) + +// GoogleCloudStorageOperator implements exec.cloudStorageOperator interface. +type GoogleCloudStorageOperator struct { + client *storage.Client +} + +// NewGoogleCloudStorageOperator returns initialized GoogleCloudStorageOperator value. +func NewGoogleCloudStorageOperator(ctx context.Context) (*GoogleCloudStorageOperator, error) { + client, err := storage.NewClient(ctx) + if err != nil { + return &GoogleCloudStorageOperator{}, fmt.Errorf("storage.NewClient: %w", err) + } + return &GoogleCloudStorageOperator{ + client: client, + }, nil +} + +// Fetch returns bytes of object in GCS. +func (op *GoogleCloudStorageOperator) Fetch(ctx context.Context, path string) ([]byte, error) { + client := op.client + defer client.Close() + ctx, cancel := context.WithTimeout(ctx, time.Second*50) + defer cancel() + bucket, object := parsePath(path) + rc, err := client.Bucket(bucket).Object(object).NewReader(ctx) + if err != nil { + return nil, fmt.Errorf("Object(%q).NewReader: %w", object, err) + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + return nil, fmt.Errorf("ioutil.ReadAll from gcs object: %w", err) + } + return data, nil +} diff --git a/pkg/external/cloudstorages/util.go b/pkg/external/cloudstorages/util.go new file mode 100644 index 0000000..3fb2f9c --- /dev/null +++ b/pkg/external/cloudstorages/util.go @@ -0,0 +1,32 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package cloudstorages + +import "strings" + +// parsePath returns splitted url value of cloud storage to bucket and object path. +func parsePath(path string) (bucket string, object string) { + host := strings.SplitN(path, "://", 2)[1] + bucket, object = strings.SplitN(host, "/", 2)[0], strings.SplitN(host, "/", 2)[1] + return bucket, object +} diff --git a/pkg/external/cloudstorages/util_test.go b/pkg/external/cloudstorages/util_test.go new file mode 100644 index 0000000..b4f53a0 --- /dev/null +++ b/pkg/external/cloudstorages/util_test.go @@ -0,0 +1,52 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package cloudstorages + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParsePath(t *testing.T) { + tests := []struct { + name string + path string + expectedBucket string + expectedObject string + }{ + { + name: "got expected bucket and object", + path: "gs://report-bucket/loadtest-name/999999/js/global_stats.json", + expectedBucket: "report-bucket", + expectedObject: "loadtest-name/999999/js/global_stats.json", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bucket, object := parsePath(tt.path) + assert.Equal(t, tt.expectedBucket, bucket) + assert.Equal(t, tt.expectedObject, object) + }) + } +} diff --git a/pkg/external/slack/slack.go b/pkg/external/slack/slack.go new file mode 100644 index 0000000..bd8efd9 --- /dev/null +++ b/pkg/external/slack/slack.go @@ -0,0 +1,89 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package slack implements operator to notify message to slack. +package slack + +import ( + "bytes" + "fmt" + "net/http" +) + +type slackOperator struct { + webhookURL string +} + +// NewSlackOperator creates slackOperator with arguments webhookURL. +func NewSlackOperator(webhookURL string) *slackOperator { + return &slackOperator{ + webhookURL: webhookURL, + } +} + +// Notify post http request to specified webhookURL. +func (op *slackOperator) Notify(msg string) error { + payload := []byte(msg) + res, err := http.Post(op.webhookURL, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to post message to slack webhook url\n") + } + defer res.Body.Close() + return nil +} + +/* +GenerateSlackPayloadData generates slack payload data with arguments mention and isSuccess. + +The argument mention specifies the target of the mentions. The format of string is <@memberID>. +The argument isSuccess is condition which decide message color and its fields value. +*/ +func GenerateSlackPayloadData(mention string, isSuccess bool) string { + var color string + var msg string + + if isSuccess { + color = "good" + msg = "loadtest execution succeeded" + } else { + color = "danger" + msg = "loadtest execution failed, please check cli log" + } + + data := fmt.Sprintf(`{ + "text": "%v", + "attachments": [ + { + "color": "%v", + "text": "monitor loadtest status", + "fields": [ + { + "title": "loadtest result", + "value": "%v", + "short": false, + } + ] + } + ] + }`, mention, color, msg) + return data +} diff --git a/pkg/external/slack/slack_test.go b/pkg/external/slack/slack_test.go new file mode 100644 index 0000000..f98cd93 --- /dev/null +++ b/pkg/external/slack/slack_test.go @@ -0,0 +1,142 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package slack + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateSlackPayloadDataColor(t *testing.T) { + cleaner := regexp.MustCompile(`[\n\t]`) // for remove tab and new line + tests := []struct { + name string + isSuccess bool + expected string + }{ + { + name: "success", + isSuccess: true, + expected: `{ + "text": "", + "attachments": [ + { + "color": "good", + "text": "monitor loadtest status", + "fields": [ + { + "title": "loadtest result", + "value": "loadtest execution succeeded", + "short": false, + } + ] + } + ] + }`, + }, + { + name: "failed", + isSuccess: false, + expected: `{ + "text": "", + "attachments": [ + { + "color": "danger", + "text": "monitor loadtest status", + "fields": [ + { + "title": "loadtest result", + "value": "loadtest execution failed, please check cli log", + "short": false, + } + ] + } + ] + }`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := GenerateSlackPayloadData("", tt.isSuccess) + assert.Equal(t, cleaner.ReplaceAllString(tt.expected, ""), cleaner.ReplaceAllString(data, "")) + }) + } +} + +func TestGenerateSlackPayloadDataMention(t *testing.T) { + cleaner := regexp.MustCompile(`[\n\t]`) + tests := []struct { + name string + mention string + expected string + }{ + { + name: "specify mention", + mention: "test", + expected: `{ + "text": "test", + "attachments": [ + { + "color": "good", + "text": "monitor loadtest status", + "fields": [ + { + "title": "loadtest result", + "value": "loadtest execution succeeded", + "short": false, + } + ] + } + ] + }`, + }, + { + name: "not specify mention", + mention: "", + expected: `{ + "text": "", + "attachments": [ + { + "color": "good", + "text": "monitor loadtest status", + "fields": [ + { + "title": "loadtest result", + "value": "loadtest execution succeeded", + "short": false, + } + ] + } + ] + }`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := GenerateSlackPayloadData(tt.mention, true) + assert.Equal(t, cleaner.ReplaceAllString(tt.expected, ""), cleaner.ReplaceAllString(data, "")) + }) + } +} diff --git a/pkg/external/spreadsheet/spreadsheet.go b/pkg/external/spreadsheet/spreadsheet.go new file mode 100644 index 0000000..fa0f19c --- /dev/null +++ b/pkg/external/spreadsheet/spreadsheet.go @@ -0,0 +1,621 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package spreadsheeet implements operator to write loadtest result to spreadsheets. +package spreadsheet + +import ( + "context" + "fmt" + "reflect" + + "google.golang.org/api/sheets/v4" +) + +/* +SheetNotFoundError implements Error and Is methods. + +This error is returned when got sheets is nothing. +*/ +type SheetNotFoundError struct { + sheetName string +} + +/* +spreadsheetOperator hold updated spreadsheet. + +Operating spreadsheet by this operators method, hold latest spreadsheet. +Call spreadsheet api need to prepare BatchUpdateSpreadsheetRequest +and given it as doBatchUpdate method argument. +And run doBatchUpdate method. +*/ +type spreadsheetOperator struct { + ctx context.Context + service *sheets.Service + spreadsheet *sheets.Spreadsheet +} + +// loadtestCommonSettingRow hold row value which is common setting of loadtest. +type loadtestCommonSettingRow struct { + imageURL string + serviceName string + targetLatency string +} + +/* +gatlingResultRow hold row value which has gatling report value and calcurated container metrics. + +The report example table is below. + +| subName | condition | duration (s) | concurrency (req/s) | + +| 4rps | ENV=dev, ... ,FEATURE_CACHE_HIT_RATIO=0.38, | 180 | 40 | + +| max (ms) | mean (ms) | 50%ile latency (ms) | 75%ile latency (ms) | 95%ile latency (ms) | + +| 1107 | 51 | 31 | 55 | 64 | + +| 99%ile latency (ms) | failed | t < 800 | 800 < t <= 1200 | 1200 < t | cpu usage mean (%) | memory usage mean (%) | + +| 674 | 0 | 100 | 0 | 0 | 15.5 | 22.3 | +*/ +type loadtestReportRow struct { + subName string + condition string + duration string + concurrency string + maxLatency float64 + meanLatency float64 + fiftiethPercentilesLatency float64 + seventyFifthPercentilesLatency float64 + nintyFifthPercentilesLatency float64 + nintyNinthPercentilesLatency float64 + failedPercentage float64 + underEightHundredMilliSecPercentage float64 + eightHundredToOneThousandTwoHundredMilliSecPercentage float64 + overOneThousandTwoHundredMilliSecPercentage float64 + cpuUsePercentage float64 + memoryUsePercentage float64 +} + +// NewLoadtestCommonSettingRow creates loadtestCommonSettingRow objects. +func NewLoadtestCommonSettingRow(imageURL, serviceName, targetLatency string) loadtestCommonSettingRow { + return loadtestCommonSettingRow{ + imageURL: imageURL, + serviceName: serviceName, + targetLatency: targetLatency, + } +} + +// NewLoadtestReportRow creates loadtestReportRow objects. +func NewLoadtestReportRow( + subName, condition, duration, concurrency string, + maxLatency, meanLatency, fiftiethPercentilesLatency, seventyFifthPercentilesLatency, + nintyFifthPercentilesLatency, nintyNinthPercentilesLatency, failedPercentage, + underEightHundredMilliSecPercentage, eightHundredToOneThousandTwoHundredMilliSecPercentage, + overOneThousandTwoHundredMilliSecPercentage, cpuUsePercentage, memoryUsePercentage float64, +) loadtestReportRow { + return loadtestReportRow{ + subName: subName, + condition: condition, + concurrency: concurrency, + duration: duration, + maxLatency: maxLatency, + meanLatency: meanLatency, + fiftiethPercentilesLatency: fiftiethPercentilesLatency, + seventyFifthPercentilesLatency: seventyFifthPercentilesLatency, + nintyFifthPercentilesLatency: nintyFifthPercentilesLatency, + nintyNinthPercentilesLatency: nintyNinthPercentilesLatency, + failedPercentage: failedPercentage, + underEightHundredMilliSecPercentage: underEightHundredMilliSecPercentage, + eightHundredToOneThousandTwoHundredMilliSecPercentage: eightHundredToOneThousandTwoHundredMilliSecPercentage, + overOneThousandTwoHundredMilliSecPercentage: overOneThousandTwoHundredMilliSecPercentage, + cpuUsePercentage: cpuUsePercentage, + memoryUsePercentage: memoryUsePercentage, + } +} + +// NewSpreadsheetOperator returns initialized spreadsheetOperator. +func NewSpreadsheetOperator(ctx context.Context, spreadsheetId string) (*spreadsheetOperator, error) { + var op spreadsheetOperator + srv, err := sheets.NewService(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create new sheets service, %v", err) + } + op.ctx = ctx + op.service = srv + targetSpreadsheet, err := op.service.Spreadsheets.Get(spreadsheetId).Do() + if err != nil { + return nil, fmt.Errorf("failed to get target spreadsheet, %v", err) + } + op.spreadsheet = targetSpreadsheet + return &op, nil +} + +// AddSheet method add new sheet to spreadsheet and returns added sheet. +func (op *spreadsheetOperator) AddSheet(sheetTitle string) (*sheets.Sheet, error) { + createNewSheetReq := &sheets.BatchUpdateSpreadsheetRequest{ + IncludeSpreadsheetInResponse: true, + Requests: []*sheets.Request{ + &sheets.Request{ + AddSheet: &sheets.AddSheetRequest{ + Properties: &sheets.SheetProperties{ + Title: sheetTitle, + }, + }, + }, + }, + } + err := op.doBatchUpdate(createNewSheetReq) + if err != nil { + return &sheets.Sheet{}, err + } + foundSheet, err := op.FindSheet(sheetTitle) + if err != nil { + return &sheets.Sheet{}, err + } + return foundSheet, nil +} + +// FindSheet method find target sheet and returns found sheet. +func (op *spreadsheetOperator) FindSheet(sheetTitle string) (*sheets.Sheet, error) { + sheetsMap := make(map[string]*sheets.Sheet) + // Google Spreadsheet always have more than 1 sheet. + for _, sheet := range op.spreadsheet.Sheets { + sheetsMap[sheet.Properties.Title] = sheet + } + + foundSheet, exist := sheetsMap[sheetTitle] + if !exist { + return &sheets.Sheet{}, &SheetNotFoundError{sheetName: sheetTitle} + } + return foundSheet, nil +} + +// SetCellName method set column header to sheet and returns updated sheet. +func (op *spreadsheetOperator) SetColumnHeader(targetSheet *sheets.Sheet) (*sheets.Sheet, error) { + // sheets.ExtendedValue StringValue field need pointer, + // so assign string value to var and give its pointer to map value. + commonSettingHeader := struct { + imageURLColumnName string + serviceNameColumnName string + targetLatencyColumnName string + }{ + imageURLColumnName: "imageURL", + serviceNameColumnName: "serviceName", + targetLatencyColumnName: "targetLatency", + } + commonSettingHeaderColumnNum := reflect.TypeOf(commonSettingHeader).NumField() + + gatlingReportHeader := struct { + subNameColumnName string + conditionColumnName string + durationColumnName string + concurrencyColumnName string + maxLatencyColumnName string + meanLatencyColumnName string + fiftiethPercentileColumnName string + seventyfifthPercentileColumnName string + ninetyfifthPercentileColumnName string + ninetyninthPercentileColumnName string + failedPercentageColumnName string + underEightHundredMilliSecCountColumnName string + betweenFromEightHundredToOneThousandTwoHundredMilliSecCountColumnName string + overOneThousandTwoHundredMilliSecCountColumnName string + cpuUsePercentageColumnName string + memoryUsePercentageColumnName string + }{ + subNameColumnName: "subName", + conditionColumnName: "condition", + durationColumnName: "duration (s)", + concurrencyColumnName: "concurrency (req/s)", + maxLatencyColumnName: "max (ms)", + meanLatencyColumnName: "mean (ms)", + fiftiethPercentileColumnName: "50%ile latency (ms)", + seventyfifthPercentileColumnName: "75%ile latency (ms)", + ninetyfifthPercentileColumnName: "95%ile latency (ms)", + ninetyninthPercentileColumnName: "99%ile latency (ms)", + failedPercentageColumnName: "failed", + underEightHundredMilliSecCountColumnName: "t < 800", + betweenFromEightHundredToOneThousandTwoHundredMilliSecCountColumnName: "800 < t <= 1200", + overOneThousandTwoHundredMilliSecCountColumnName: "1200 < t", + cpuUsePercentageColumnName: "cpu usage mean (%)", + memoryUsePercentageColumnName: "memory usage mean (%)", + } + gatlingReportHeaderColumnNum := reflect.TypeOf(gatlingReportHeader).NumField() + + setCellNameReq := &sheets.BatchUpdateSpreadsheetRequest{ + IncludeSpreadsheetInResponse: true, + Requests: []*sheets.Request{ + &sheets.Request{ + UpdateCells: &sheets.UpdateCellsRequest{ + Fields: "userEnteredValue", + Range: &sheets.GridRange{ + SheetId: targetSheet.Properties.SheetId, + StartRowIndex: 0, + EndRowIndex: 1, + StartColumnIndex: 0, + // (caution) Index count from 0 and `EndIndex` should be specified + // as the index value of the last column plus 1. + EndColumnIndex: int64( + commonSettingHeaderColumnNum, + ), + }, + Rows: []*sheets.RowData{ + &sheets.RowData{ + Values: []*sheets.CellData{ + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &commonSettingHeader.imageURLColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &commonSettingHeader.serviceNameColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &commonSettingHeader.targetLatencyColumnName, + }, + }, + }, + }, + }, + }, + }, + &sheets.Request{ + UpdateCells: &sheets.UpdateCellsRequest{ + Fields: "userEnteredValue", + Range: &sheets.GridRange{ + SheetId: targetSheet.Properties.SheetId, + StartRowIndex: 3, + EndRowIndex: 5, + StartColumnIndex: 0, + EndColumnIndex: int64( + // (caution) Index count from 0 and `EndIndex` should be specified + // as the index value of the last column plus 1. + gatlingReportHeaderColumnNum, + ), + }, + Rows: []*sheets.RowData{ + &sheets.RowData{ + Values: []*sheets.CellData{ + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.subNameColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.conditionColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.durationColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.concurrencyColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.maxLatencyColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.meanLatencyColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.fiftiethPercentileColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.seventyfifthPercentileColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.ninetyfifthPercentileColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.ninetyninthPercentileColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.failedPercentageColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.underEightHundredMilliSecCountColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.betweenFromEightHundredToOneThousandTwoHundredMilliSecCountColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.overOneThousandTwoHundredMilliSecCountColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.cpuUsePercentageColumnName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &gatlingReportHeader.memoryUsePercentageColumnName, + }, + }, + }, + }, + }, + }, + }, + }, + } + err := op.doBatchUpdate(setCellNameReq) + if err != nil { + return &sheets.Sheet{}, err + } + foundSheet, err := op.FindSheet(targetSheet.Properties.Title) + if err != nil { + return &sheets.Sheet{}, fmt.Errorf("set cell name but sheet not found") + } + return foundSheet, nil +} + +// SetLoadtestCommonSettingValue method set common setting value to column. +func (op *spreadsheetOperator) SetLoadtestCommonSettingValue( + row loadtestCommonSettingRow, + targetSheet *sheets.Sheet, +) (*sheets.Sheet, error) { + loadtestCommonSettingColumnNum := reflect.TypeOf(row).NumField() + setLoadtestCommonSettingCellValueReq := &sheets.BatchUpdateSpreadsheetRequest{ + IncludeSpreadsheetInResponse: true, + Requests: []*sheets.Request{ + &sheets.Request{ + UpdateCells: &sheets.UpdateCellsRequest{ + Fields: "userEnteredValue", + Range: &sheets.GridRange{ + SheetId: targetSheet.Properties.SheetId, + StartRowIndex: 1, + EndRowIndex: 3, + StartColumnIndex: 0, + EndColumnIndex: int64(loadtestCommonSettingColumnNum), + }, + Rows: []*sheets.RowData{ + &sheets.RowData{ + Values: []*sheets.CellData{ + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &row.imageURL, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &row.serviceName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &row.targetLatency, + }, + }, + }, + }, + }, + }, + }, + }, + } + err := op.doBatchUpdate(setLoadtestCommonSettingCellValueReq) + if err != nil { + return &sheets.Sheet{}, err + } + foundSheet, err := op.FindSheet(targetSheet.Properties.Title) + if err != nil { + return &sheets.Sheet{}, fmt.Errorf("loadtest common setting was added but sheet not found") + } + return foundSheet, nil +} + +// AppendLoadtestReportRow append report of each loadtest to the end of row in target sheet. +func (op *spreadsheetOperator) AppendLoadtestReportRow( + row loadtestReportRow, + targetSheet *sheets.Sheet, +) (*sheets.Sheet, error) { + loadtestReportColumnNum := reflect.TypeOf(row).NumField() + readRange := fmt.Sprintf("%v!A:M", targetSheet.Properties.Title) + existingRowCount, err := op.getRowCount(readRange) + if err != nil { + return &sheets.Sheet{}, err + } + addLoadtestReportRowReq := &sheets.BatchUpdateSpreadsheetRequest{ + IncludeSpreadsheetInResponse: true, + Requests: []*sheets.Request{ + &sheets.Request{ + UpdateCells: &sheets.UpdateCellsRequest{ + Fields: "userEnteredValue", + Range: &sheets.GridRange{ + SheetId: targetSheet.Properties.SheetId, + StartRowIndex: existingRowCount, + EndRowIndex: existingRowCount + 2, + StartColumnIndex: 0, + EndColumnIndex: int64(loadtestReportColumnNum), + }, + Rows: []*sheets.RowData{ + &sheets.RowData{ + Values: []*sheets.CellData{ + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &row.subName, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &row.condition, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &row.duration, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + StringValue: &row.concurrency, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.maxLatency, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.meanLatency, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.fiftiethPercentilesLatency, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.seventyFifthPercentilesLatency, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.nintyFifthPercentilesLatency, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.nintyNinthPercentilesLatency, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.failedPercentage, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.underEightHundredMilliSecPercentage, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.eightHundredToOneThousandTwoHundredMilliSecPercentage, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.overOneThousandTwoHundredMilliSecPercentage, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.cpuUsePercentage, + }, + }, + { + UserEnteredValue: &sheets.ExtendedValue{ + NumberValue: &row.memoryUsePercentage, + }, + }, + }, + }, + }, + }, + }, + }, + } + err = op.doBatchUpdate(addLoadtestReportRowReq) + if err != nil { + return &sheets.Sheet{}, err + } + foundSheet, err := op.FindSheet(targetSheet.Properties.Title) + if err != nil { + return &sheets.Sheet{}, fmt.Errorf("loadtest report was added but sheet not found") + } + return foundSheet, nil +} + +// doBatchUpdate update spreadsheets by given requests. And update spreadsheetOperator's spreadsheet field value by +// updated spreadsheet. +func (op *spreadsheetOperator) doBatchUpdate(req *sheets.BatchUpdateSpreadsheetRequest) error { + if !req.IncludeSpreadsheetInResponse { + return fmt.Errorf( + "invalid input IncludeSpreadsheetInResponse is false", + ) + // doBatchUpdate always update SpreadsheetOperator field so need to include updated spreadsheet in response. + // nolint:lll // ref: https://github.com/googleapis/google-api-go-client/blob/113082d14d54f188d1b6c34c652e416592fc51b5/sheets/v4/sheets-gen.go#L1921 + } + res, err := op.service.Spreadsheets.BatchUpdate(op.spreadsheet.SpreadsheetId, req).Do() + if err != nil { + return err + } + op.spreadsheet = res.UpdatedSpreadsheet + return nil +} + +/* +getRowCount returns specified targetRange row count. + +Specify targetRange in the form of "sheet title!column start:column end". example: "YOUR_SHEET_NAME!A:M" +*/ +func (op *spreadsheetOperator) getRowCount(targetRange string) (int64, error) { + res, err := op.service.Spreadsheets.Values.Get(op.spreadsheet.SpreadsheetId, targetRange).Do() + if err != nil { + return 0, err + } + return int64(len(res.Values)), nil +} + +// Error implements error interface. +func (e *SheetNotFoundError) Error() string { + return fmt.Sprintf("%s: sheet not found", e.sheetName) +} + +// Is method is needed to compare by errors.Is method. +func (e *SheetNotFoundError) Is(target error) bool { + _, ok := target.(*SheetNotFoundError) + return ok +} diff --git a/pkg/external/spreadsheet/spreadsheet_test.go b/pkg/external/spreadsheet/spreadsheet_test.go new file mode 100644 index 0000000..e4ea815 --- /dev/null +++ b/pkg/external/spreadsheet/spreadsheet_test.go @@ -0,0 +1,141 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package spreadsheet + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/api/sheets/v4" +) + +func TestFindSheet(t *testing.T) { + existTitle := "exists" + op := &spreadsheetOperator{ + spreadsheet: &sheets.Spreadsheet{ + Sheets: []*sheets.Sheet{ + &sheets.Sheet{ + Properties: &sheets.SheetProperties{ + Title: existTitle, + }, + }, + }, + }, + } + tests := []struct { + name string + sheetTitle string + expected *sheets.Sheet + }{ + { + name: "sheet title found", + sheetTitle: existTitle, + expected: op.spreadsheet.Sheets[0], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + foundSheet, err := op.FindSheet(tt.sheetTitle) + assert.NoError(t, err) + assert.Equal(t, tt.expected, foundSheet) + }) + } +} + +func TestFindSheet_SheetNotExist(t *testing.T) { + notExistTitle := "not exists" + existTitle := "exists" + op := &spreadsheetOperator{ + spreadsheet: &sheets.Spreadsheet{ + Sheets: []*sheets.Sheet{ + &sheets.Sheet{ + Properties: &sheets.SheetProperties{ + Title: existTitle, + }, + }, + }, + }, + } + tests := []struct { + name string + sheetTitle string + expected error + }{ + { + name: "sheet title not found", + sheetTitle: notExistTitle, + expected: &SheetNotFoundError{ + sheetName: notExistTitle, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := op.FindSheet(tt.sheetTitle) + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestDoBatchUpdate_IncludeSpreadsheetInResponseFalse(t *testing.T) { + op := &spreadsheetOperator{} + tests := []struct { + name string + req *sheets.BatchUpdateSpreadsheetRequest + expected error + }{ + { + name: "IncludeSpreadsheetInResponse field value is false", + req: &sheets.BatchUpdateSpreadsheetRequest{}, + expected: fmt.Errorf("invalid input IncludeSpreadsheetInResponse is false"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := op.doBatchUpdate(tt.req) + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestDoBatchUpdateFail(t *testing.T) { + op := &spreadsheetOperator{} + tests := []struct { + name string + req *sheets.BatchUpdateSpreadsheetRequest + expected error + }{ + { + name: "IncludeSpreadsheetInResponse field value is false", + req: &sheets.BatchUpdateSpreadsheetRequest{}, + expected: fmt.Errorf("invalid input IncludeSpreadsheetInResponse is false"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := op.doBatchUpdate(tt.req) + assert.Equal(t, tt.expected, err) + }) + } +} diff --git a/pkg/internal/gatling/gatling.go b/pkg/internal/gatling/gatling.go new file mode 100644 index 0000000..6e91a2c --- /dev/null +++ b/pkg/internal/gatling/gatling.go @@ -0,0 +1,197 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package gatling implements function and type to operate gatling custom resource. +package gatling + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/st-tech/gatling-commander/pkg/util" + + gatlingv1alpha1 "github.com/st-tech/gatling-operator/api/v1alpha1" + "gopkg.in/yaml.v3" + kubeapiErrors "k8s.io/apimachinery/pkg/api/errors" + ctrlClient "sigs.k8s.io/controller-runtime/pkg/client" +) + +/* +LoadGatlingManifest returns gatling object. + +Load gatling resource manifest file and parse Bytes to Gatling object. +*/ +func LoadGatlingManifest(path string) (*gatlingv1alpha1.Gatling, error) { + gatlingYaml, _ := os.ReadFile(path) + + var ( + gatling gatlingv1alpha1.Gatling + gatlingManifest interface{} + ) + if err := yaml.Unmarshal(gatlingYaml, &gatlingManifest); err != nil { + return nil, err + } + // Gatling struct has json tag. so encode loaded manifest to json bytes and map field to Gatling struct object. + jsonEncodedGatlingManifestBytes, err := json.Marshal(&gatlingManifest) + if err != nil { + return nil, err + } + if err := json.Unmarshal(jsonEncodedGatlingManifestBytes, &gatling); err != nil { + return nil, err + } + return &gatling, nil +} + +/* +CreateGatling returns error object when success to create gatling object. + +If gatling object which has same name already exists in same namespace, delete old one and create new one. +*/ +func CreateGatling(ctx context.Context, cl ctrlClient.Client, gatling *gatlingv1alpha1.Gatling) error { + // check gatling object exists or not + var foundGatling gatlingv1alpha1.Gatling + err := cl.Get(ctx, ctrlClient.ObjectKey{ + Namespace: gatling.ObjectMeta.Namespace, + Name: gatling.ObjectMeta.Name, + }, &foundGatling) + if err != nil && !kubeapiErrors.IsNotFound(err) { + return err + } + + // skip delete when gatling object is not found + if err == nil { + if err := cl.Delete(ctx, &foundGatling); err != nil { + return err + } + } + + if err := cl.Create(ctx, gatling); err != nil { + return err + } + return nil +} + +/* +WaitGatlingJobStartup wait until gatling job started. + +Check every 5 seconds until Status.RunnerStartTime field value is set. +Except for above case, context Done or over timeout threshold, or something error occured will finish loop. +Before finish loop, cleanupGatlingJob is called and delete existing gatling object. +*/ +func WaitGatlingJobStartup( + ctx context.Context, + cl ctrlClient.Client, + gatling *gatlingv1alpha1.Gatling, + timeout int32, +) error { + var foundGatling gatlingv1alpha1.Gatling + startTime := int32(time.Now().Unix()) + for { + select { + case <-ctx.Done(): + cleanupGatlingJob(cl, &foundGatling) + return nil + default: + if err := cl.Get( + ctx, ctrlClient.ObjectKey{ + Name: gatling.ObjectMeta.Name, + Namespace: gatling.ObjectMeta.Namespace, + }, &foundGatling, + ); err != nil { + return err + } + duration := int32(time.Now().Unix()) - startTime + if err := util.CheckTimeout(timeout, duration); err != nil { + cleanupGatlingJob(cl, &foundGatling) + return err + } + // if RunnerStartTime to be set, finish wait loop. + if foundGatling.Status.RunnerStartTime > 0 { + return nil + } + time.Sleep(5 * time.Second) + } + } +} + +/* +WaitGatlingJobRunning wait until gatling Job completed. + +Check every 10 seconds until Status.RunnerCompleted and Status.ReportCompleted field value is set. +Check Status.RunnerStartTime field value and if its value not set (0) return error. +Except for above case, context Done or over timeout threshold, or something error occured will finish loop. +Before finish loop except for succeeded case, cleanupGatlingJob is called and delete existing gatling object. +*/ +func WaitGatlingJobRunning( + ctx context.Context, + cl ctrlClient.Client, + gatling *gatlingv1alpha1.Gatling, + timeout int32, + jobFinishCh chan bool, +) error { + defer func() { jobFinishCh <- true }() + var foundGatling gatlingv1alpha1.Gatling + for { + select { + case <-ctx.Done(): // when interrupt + cleanupGatlingJob(cl, &foundGatling) + return nil + default: + if err := cl.Get( + ctx, ctrlClient.ObjectKey{ + Name: gatling.ObjectMeta.Name, + Namespace: gatling.ObjectMeta.Namespace, + }, &foundGatling, + ); err != nil { + return err + } + if foundGatling.Status.RunnerStartTime == 0 { + return fmt.Errorf("waitGatlingJobRunning called, but Gatling Job not started yet") + } + duration := int32(time.Now().Unix()) - foundGatling.Status.RunnerStartTime + if err := util.CheckTimeout(timeout, duration); err != nil { + cleanupGatlingJob(cl, &foundGatling) + return err + } + if foundGatling.Status.RunnerCompleted && foundGatling.Status.ReportCompleted { + fmt.Printf("Gatling Job %v completed\n", foundGatling.ObjectMeta.Name) + return nil + } + time.Sleep(10 * time.Second) + } + } +} + +/* +cleanupGatlingJob Delete specified gatling object. + +Called for cleanup existing gatling object when gatling loadtest interrupted. +*/ +func cleanupGatlingJob(cl ctrlClient.Client, foundGatling *gatlingv1alpha1.Gatling) { + cleanupCtx := context.Background() + if err := cl.Delete(cleanupCtx, foundGatling); err != nil { + fmt.Fprintf(os.Stderr, "failed to delete found Gatling Job for cleanup, %v\n", err) + } +} diff --git a/pkg/internal/gatling/gatling_test.go b/pkg/internal/gatling/gatling_test.go new file mode 100644 index 0000000..dc508a1 --- /dev/null +++ b/pkg/internal/gatling/gatling_test.go @@ -0,0 +1,290 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package gatling + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/st-tech/gatling-commander/pkg/internal/kubeutil" + + gatlingv1alpha1 "github.com/st-tech/gatling-operator/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrlClient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/stretchr/testify/assert" +) + +const ( + SampleGatlingManifestPath = "testdata/sample_gatling_manifest.yaml" +) + +func TestCreateGatling(t *testing.T) { + cl := kubeutil.InitFakeClient() + + // prepare gatling data for test + sampleGatling, err := LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + + existsGatling, notExistsGatling := *sampleGatling, *sampleGatling + notExistsGatling.ObjectMeta.Name = "new-gatling" + + // create sample gatling object for existsGatling test + err = cl.Create(context.TODO(), sampleGatling) + assert.NoError(t, err) + + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + }{ + { + name: "create new gatling object", + gatling: ¬ExistsGatling, + }, + { + name: "delete existing and create new gatling object", + gatling: &existsGatling, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := testCreateGatling(cl, tt.gatling) + assert.NoError(t, err) + }) + } +} + +func TestWaitGatlingJobStartup(t *testing.T) { + cl := kubeutil.InitFakeClient() + startedGatling, err := LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + + startedGatling.Status = gatlingv1alpha1.GatlingStatus{ + RunnerStartTime: int32(time.Now().Unix()), + } + err = cl.Create(context.TODO(), startedGatling) + assert.NoError(t, err) + + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + }{ + { + name: "wait found gatling startup", + gatling: startedGatling, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := WaitGatlingJobStartup(context.TODO(), cl, tt.gatling, 2) + assert.NoError(t, err) + }) + } +} + +func TestWaitGatlingJobStartup_ExpectedFail(t *testing.T) { + cl := kubeutil.InitFakeClient() + + noRunnerStartTimeGatling, err := LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + noRunnerStartTimeGatling.Status = gatlingv1alpha1.GatlingStatus{ + RunnerStartTime: 0, + } + + err = cl.Create(context.TODO(), noRunnerStartTimeGatling) + assert.NoError(t, err) + + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + timeout int32 + expected error + }{ + { + name: "wait startup failed with timeout", + gatling: noRunnerStartTimeGatling, + timeout: 1, + expected: fmt.Errorf("timeout %v execeeded", 1), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := WaitGatlingJobStartup(context.TODO(), cl, tt.gatling, tt.timeout) + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestWaitGatlingJobRunning(t *testing.T) { + cl := kubeutil.InitFakeClient() + + completedGatling, err := LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + + completedGatling.Status = gatlingv1alpha1.GatlingStatus{ + RunnerStartTime: int32(time.Now().Unix()), + RunnerCompleted: true, + ReportCompleted: true, + } + err = cl.Create(context.TODO(), completedGatling) + assert.NoError(t, err) + + informJobFinishCh := make(chan bool, 1) + + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + }{ + { + name: "wait found gatling completed", + gatling: completedGatling, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := WaitGatlingJobRunning(context.TODO(), cl, tt.gatling, 2, informJobFinishCh) + assert.NoError(t, err) + }) + } +} + +func TestWaitGatlingJobRunning_ExpectedFail(t *testing.T) { + cl := kubeutil.InitFakeClient() + + sampleGatling, err := LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + + sampleGatling.Status = gatlingv1alpha1.GatlingStatus{ + RunnerStartTime: int32(time.Now().Unix()), + } + existsGatling, notExistsGatling, noRunnerStartTimeGatling := *sampleGatling, *sampleGatling, *sampleGatling + notExistsGatling.ObjectMeta.Name = "notexists" + noRunnerStartTimeGatling.Status.RunnerStartTime = 0 + + err = cl.Create(context.TODO(), &existsGatling) + assert.NoError(t, err) + + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + timeout int32 + informerCh chan bool + }{ + { + name: "handle not found error", + gatling: ¬ExistsGatling, + timeout: 1, + informerCh: make(chan bool, 1), + }, + { + name: "handle timeout error", + gatling: &existsGatling, + timeout: 0, + informerCh: make(chan bool, 1), + }, + { + name: "Gatling Job not started", + gatling: &noRunnerStartTimeGatling, + timeout: 1, + informerCh: make(chan bool, 1), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := WaitGatlingJobRunning(context.TODO(), cl, tt.gatling, tt.timeout, tt.informerCh) + assert.Error(t, err) + }) + } +} + +func TestWaitGatlingJobRunning_CleanupGatlingJobWhenInterrupted(t *testing.T) { + cl := kubeutil.InitFakeClient() + + notCompletedGatling, err := LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + + notCompletedGatling.Status = gatlingv1alpha1.GatlingStatus{ + RunnerStartTime: int32(time.Now().Unix()), + RunnerCompleted: false, + ReportCompleted: false, + } + err = cl.Create(context.TODO(), notCompletedGatling) + assert.NoError(t, err) + + informJobFinishCh := make(chan bool, 1) + + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + }{ + { + name: "cleanup gatling job succeeded", + gatling: notCompletedGatling, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(1 * time.Second) + cancel() + }() + err := WaitGatlingJobRunning(ctx, cl, tt.gatling, 2, informJobFinishCh) + assert.NoError(t, err) + var foundGatling gatlingv1alpha1.Gatling + err = cl.Get( + context.TODO(), + ctrlClient.ObjectKey{ + Namespace: notCompletedGatling.ObjectMeta.Namespace, + Name: notCompletedGatling.ObjectMeta.Name, + }, + &foundGatling, + ) + fmt.Printf("%v\n", err) + }) + } +} + +func testCreateGatling(cl client.Client, newGatling *gatlingv1alpha1.Gatling) error { + err := CreateGatling(context.TODO(), cl, newGatling) + if err != nil { + return err + } + + var foundGatling gatlingv1alpha1.Gatling + err = cl.Get( + context.TODO(), + ctrlClient.ObjectKey{ + Namespace: newGatling.ObjectMeta.Namespace, + Name: newGatling.ObjectMeta.Name, + }, + &foundGatling, + ) + if err != nil { + return fmt.Errorf("unexpected error occured when get gatling object %v", err) + } + return nil +} diff --git a/pkg/internal/gatling/report.go b/pkg/internal/gatling/report.go new file mode 100644 index 0000000..d00bc06 --- /dev/null +++ b/pkg/internal/gatling/report.go @@ -0,0 +1,134 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package gatling + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + gatlingv1alpha1 "github.com/st-tech/gatling-operator/api/v1alpha1" + ctrlClient "sigs.k8s.io/controller-runtime/pkg/client" +) + +/* +GatlingReport has field of gatling report. + +refer to gatling report json key "/js/global_stats.json". +*/ +type GatlingReport struct { + Name string `json:"name"` + NumberOfRequests GatlingReportStats `json:"numberOfRequests"` + MinResponseTime GatlingReportStats `json:"minResponseTime"` + MaxResponseTime GatlingReportStats `json:"maxResponseTime"` + MeanResponseTime GatlingReportStats `json:"meanResponseTime"` + StandardDeviation GatlingReportStats `json:"standardDeviation"` + FiftiethPercentiles GatlingReportStats `json:"percentiles1"` + SeventyFifthPercentiles GatlingReportStats `json:"percentiles2"` + NintyFifthPercentiles GatlingReportStats `json:"percentiles3"` + NintyNinthPercentiles GatlingReportStats `json:"percentiles4"` + MeanNumberOfRequestsPerSecond GatlingReportStats `json:"meanNumberOfRequestsPerSecond"` + UnderEightHundredMilliSec GatlingReportGroup `json:"group1"` + BetweenFromEightHundredToOneThousandTwoHundredMilliSec GatlingReportGroup `json:"group2"` + OverOneThousandTwoHundredMilliSec GatlingReportGroup `json:"group3"` + Failed GatlingReportGroup `json:"group4"` +} + +// GetPercentileLatency get latency match to specified percentile. +func (r *GatlingReport) GetPercentileLatency(percentile uint32) (float64, error) { + var latency float64 + switch percentile { + case 99: + latency = r.NintyNinthPercentiles.Ok + case 95: + latency = r.NintyFifthPercentiles.Ok + case 75: + latency = r.SeventyFifthPercentiles.Ok + case 50: + latency = r.FiftiethPercentiles.Ok + default: + return 0, fmt.Errorf("specified percentile value is not matched to GatlingReport field") + } + return latency, nil +} + +// BytesToGatlingReport parse jsonBytes to GatlingReport object. +func BytesToGatlingReport(jsonBytes []byte) (*GatlingReport, error) { + var gatlingReport GatlingReport + if err := json.Unmarshal(jsonBytes, &gatlingReport); err != nil { + return &GatlingReport{}, err + } + return &gatlingReport, nil +} + +/* +ExtractLoadtestConditionToReport extract gatling object TestScenarioSpec field value and returns them in a format +matching the spreadsheet columns. + +Extract DURATION and CONCURRENCY value, and the values of the other fields are summarized in the same column. +*/ +func ExtractLoadtestConditionToReport( + testScenarioSpec gatlingv1alpha1.TestScenarioSpec, +) (concurrency string, duration string, condition string, err error) { + for _, field := range testScenarioSpec.Env { + switch field.Name { + case "DURATION": + duration = field.Value + case "CONCURRENCY": + singleConcurrency, err := strconv.Atoi(field.Value) + if err != nil { + return "", "", "", err + } + concurrency = fmt.Sprintf("%v", testScenarioSpec.Parallelism*int32(singleConcurrency)) + default: + condition += fmt.Sprintf("%v=%v,", field.Name, field.Value) + } + } + return concurrency, duration, condition, nil +} + +// GetGatlingReportStoragePath fetch gatling object and get ReportStoragePath value. +func GetGatlingReportStoragePath( + ctx context.Context, + cl ctrlClient.Client, + gatling *gatlingv1alpha1.Gatling, +) (string, error) { + var foundGatling gatlingv1alpha1.Gatling + if err := cl.Get( + ctx, ctrlClient.ObjectKey{ + Name: gatling.ObjectMeta.Name, + Namespace: gatling.ObjectMeta.Namespace, + }, &foundGatling, + ); err != nil { + return "", err + } + + if !foundGatling.Status.ReportCompleted { + return "", fmt.Errorf( + "found gatling object status.ReportCompleted field value is not true %v\n", + foundGatling.Status.ReportCompleted, + ) + } + return foundGatling.Status.ReportStoragePath, nil +} diff --git a/pkg/internal/gatling/report_test.go b/pkg/internal/gatling/report_test.go new file mode 100644 index 0000000..5b2e6f5 --- /dev/null +++ b/pkg/internal/gatling/report_test.go @@ -0,0 +1,178 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package gatling + +import ( + "context" + "fmt" + "testing" + + "github.com/st-tech/gatling-commander/pkg/internal/kubeutil" + + gatlingv1alpha1 "github.com/st-tech/gatling-operator/api/v1alpha1" + "github.com/stretchr/testify/assert" +) + +func TestGetPercentileLatency_Success(t *testing.T) { + sampleReport := &GatlingReport{ + FiftiethPercentiles: GatlingReportStats{ + Ok: 70, + }, + SeventyFifthPercentiles: GatlingReportStats{ + Ok: 80, + }, + NintyFifthPercentiles: GatlingReportStats{ + Ok: 90, + }, + NintyNinthPercentiles: GatlingReportStats{ + Ok: 100, + }, + } + tests := []struct { + name string + input uint32 + expected float64 + }{ + { + name: "specify 99 percentile", + input: 99, + expected: sampleReport.NintyNinthPercentiles.Ok, + }, + { + name: "specify 95 percentile", + input: 95, + expected: sampleReport.NintyFifthPercentiles.Ok, + }, + { + name: "specify 75 percentile", + input: 75, + expected: sampleReport.SeventyFifthPercentiles.Ok, + }, + { + name: "specify 50 percentile", + input: 50, + expected: sampleReport.FiftiethPercentiles.Ok, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + latency, err := sampleReport.GetPercentileLatency(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.expected, latency) + }) + } +} + +func TestGetPercentileLatency_Fail(t *testing.T) { + SampleReport := &GatlingReport{ + FiftiethPercentiles: GatlingReportStats{ + Ok: 70, + }, + SeventyFifthPercentiles: GatlingReportStats{ + Ok: 80, + }, + NintyFifthPercentiles: GatlingReportStats{ + Ok: 90, + }, + NintyNinthPercentiles: GatlingReportStats{ + Ok: 100, + }, + } + tests := []struct { + name string + input uint32 + expected error + }{ + { + name: "invalid percentile value", + input: 80, + expected: fmt.Errorf("specified percentile value is not matched to GatlingReport field"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + latency, err := SampleReport.GetPercentileLatency(tt.input) + assert.Equal(t, tt.expected, err) + assert.Equal(t, float64(0), latency) + }) + } +} + +func TestGetGatlingReportStoragePath(t *testing.T) { + cl := kubeutil.InitFakeClient() + reportCompletedGatling, err := LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + reportCompletedGatling.Status = gatlingv1alpha1.GatlingStatus{ + ReportCompleted: true, + ReportStoragePath: "gs://test-bucket/sample-reports/99999999", + } + // create Status.ReportCompleted field value false gatling object + err = cl.Create(context.TODO(), reportCompletedGatling) + assert.NoError(t, err) + + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + }{ + { + name: "report completed and have report storage path field value", + gatling: reportCompletedGatling, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reportStoragePath, err := GetGatlingReportStoragePath(context.TODO(), cl, tt.gatling) + assert.Equal(t, reportStoragePath, tt.gatling.Status.ReportStoragePath) + assert.NoError(t, err) + }) + } +} + +func TestGetGatlingReportStoragePath_ReportCompletedNotTrue(t *testing.T) { + cl := kubeutil.InitFakeClient() + reportCompletedFalseGatling, err := LoadGatlingManifest(SampleGatlingManifestPath) + assert.NoError(t, err) + reportCompletedFalseGatling.Status = gatlingv1alpha1.GatlingStatus{ + ReportCompleted: false, + ReportStoragePath: "gs://test-bucket/sample-reports/99999999", + } + // create Status.ReportCompleted field value false gatling object + err = cl.Create(context.TODO(), reportCompletedFalseGatling) + assert.NoError(t, err) + + tests := []struct { + name string + gatling *gatlingv1alpha1.Gatling + }{ + { + name: "report completed false", + gatling: reportCompletedFalseGatling, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := GetGatlingReportStoragePath(context.TODO(), cl, tt.gatling) + assert.Error(t, err) + }) + } +} diff --git a/pkg/internal/gatling/testdata/base_manifest.yaml b/pkg/internal/gatling/testdata/base_manifest.yaml new file mode 100644 index 0000000..5d371c6 --- /dev/null +++ b/pkg/internal/gatling/testdata/base_manifest.yaml @@ -0,0 +1,47 @@ +apiVersion: gatling-operator.tech.zozo.com/v1alpha1 +kind: Gatling +metadata: + name: + namespace: gatling-system # specify namespace which has service account for gatling worker pod +spec: + generateReport: true + generateLocalReport: true + notifyReport: false + cleanupAfterJobDone: false + podSpec: + gatlingImage: + rcloneImage: rclone/rclone + resources: + requests: + cpu: "7000m" + memory: "4G" + limits: + cpu: "7000m" + memory: "4G" + serviceAccountName: "gatling-operator-worker" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cloud.google.com/gke-nodepool + operator: In + values: + - "gatling-operator-worker-v1" + tolerations: + - key: "dedicated" + operator: "Equal" + value: "gatling-operator-worker-v1" + effect: "NoSchedule" + cloudStorageSpec: + provider: "gcp" + bucket: "gatling-operator-reports" + notificationServiceSpec: + provider: "slack" # Notification provider name. Supported provider: "slack" + secretName: "gatling-notification-slack-secrets" # The name of secret in which all key/value sets needed for the notification are stored + testScenarioSpec: + parallelism: 1 # Optional. Default: 1. Number of pods running at any instan + simulationClass: # Gatling simulation class name + env: # Optional. Environment variables to be used in Gatling Simulation Scala + - name: + value: diff --git a/pkg/internal/gatling/testdata/gatling_report_sample/js/global_stats.json b/pkg/internal/gatling/testdata/gatling_report_sample/js/global_stats.json new file mode 100644 index 0000000..a87a8c1 --- /dev/null +++ b/pkg/internal/gatling/testdata/gatling_report_sample/js/global_stats.json @@ -0,0 +1,73 @@ +{ + "name": "Global Information", + "numberOfRequests": { + "total": 10, + "ok": 10, + "ko": 0 + }, + "minResponseTime": { + "total": 69, + "ok": 69, + "ko": 0 + }, + "maxResponseTime": { + "total": 595, + "ok": 595, + "ko": 0 + }, + "meanResponseTime": { + "total": 257, + "ok": 257, + "ko": 0 + }, + "standardDeviation": { + "total": 214, + "ok": 214, + "ko": 0 + }, + "percentiles1": { + "total": 113, + "ok": 113, + "ko": 0 + }, + "percentiles2": { + "total": 472, + "ok": 472, + "ko": 0 + }, + "percentiles3": { + "total": 564, + "ok": 564, + "ko": 0 + }, + "percentiles4": { + "total": 589, + "ok": 589, + "ko": 0 + }, + "group1": { + "name": "t < 800 ms", + "count": 10, + "percentage": 100 + }, + "group2": { + "name": "800 ms < t < 1200 ms", + "count": 0, + "percentage": 0 + }, + "group3": { + "name": "t > 1200 ms", + "count": 0, + "percentage": 0 + }, + "group4": { + "name": "failed", + "count": 0, + "percentage": 0 + }, + "meanNumberOfRequestsPerSecond": { + "total": 1.1111111111111112, + "ok": 1.1111111111111112, + "ko": 0 + } +} diff --git a/pkg/internal/gatling/testdata/sample_gatling_manifest.yaml b/pkg/internal/gatling/testdata/sample_gatling_manifest.yaml new file mode 100644 index 0000000..5c270d3 --- /dev/null +++ b/pkg/internal/gatling/testdata/sample_gatling_manifest.yaml @@ -0,0 +1,51 @@ +apiVersion: gatling-operator.tech.zozo.com/v1alpha1 +kind: Gatling +metadata: + name: sample-service + namespace: gatling-system # specify namespace which has service account for gatling worker pod +spec: + generateReport: true + generateLocalReport: true + notifyReport: false + cleanupAfterJobDone: false + podSpec: + gatlingImage: example/gatling-scenario/sample-202308021850 + rcloneImage: rclone/rclone + resources: + requests: + cpu: "7000m" + memory: "4G" + limits: + cpu: "7000m" + memory: "4G" + serviceAccountName: "gatling-operator-worker" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: cloud.google.com/gke-nodepool + operator: In + values: + - "gatling-operator-worker-v1" + tolerations: + - key: "dedicated" + operator: "Equal" + value: "gatling-operator-worker-v1" + effect: "NoSchedule" + cloudStorageSpec: + provider: "gcp" + bucket: "gatling-operator-reports" + notificationServiceSpec: + provider: "slack" # Notification provider name. Supported provider: "slack" + secretName: "gatling-notification-slack-secrets" # The name of secret in which all key/value sets needed for the notification are stored + testScenarioSpec: + parallelism: 1 # Optional. Default: 1. Number of pods running at any instan + simulationClass: SampleScenario # Gatling simulation class name + env: # Optional. Environment variables to be used in Gatling Simulation Scala + - name: ENV + value: "stg" + - name: CONCURRENCY + value: "25" + - name: DURATION + value: "10" diff --git a/pkg/internal/gatling/types.go b/pkg/internal/gatling/types.go new file mode 100644 index 0000000..5bcb8f8 --- /dev/null +++ b/pkg/internal/gatling/types.go @@ -0,0 +1,37 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package gatling + +// GatlingReportStats has Gatling Report Stats field. +type GatlingReportStats struct { + Total float64 `json:"total"` + Ok float64 `json:"ok"` + Ko float64 `json:"ko"` +} + +// GatlingReportGroup has Gatling Report Group field. +type GatlingReportGroup struct { + Name string `json:"name"` + Count float64 `json:"count"` + Percentage float64 `json:"percentage"` +} diff --git a/pkg/internal/kubeapi/client.go b/pkg/internal/kubeapi/client.go new file mode 100644 index 0000000..326e8e7 --- /dev/null +++ b/pkg/internal/kubeapi/client.go @@ -0,0 +1,70 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package kubeapi + +import ( + gatlingv1alpha1 "github.com/st-tech/gatling-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + metricsClientset "k8s.io/metrics/pkg/client/clientset/versioned" + ctrlClient "sigs.k8s.io/controller-runtime/pkg/client" + ctrlConfig "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +/* +InitClient returns client of kubeapi. + +use for operate gatling object. +*/ +func InitClient(k8sCtxName string) (ctrlClient.Client, error) { + // add custom resource gatling to scheme + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(gatlingv1alpha1.AddToScheme(scheme)) + + k8sConfig, err := ctrlConfig.GetConfigWithContext(k8sCtxName) + if err != nil { + return nil, err + } + cl, err := ctrlClient.New(k8sConfig, ctrlClient.Options{ + Scheme: scheme, + }) + if err != nil { + return nil, err + } + return cl, nil +} + +// InitMetricsClient returns clientset of metrics. +func InitMetricsClient(k8sCtxName string) (*metricsClientset.Clientset, error) { + k8sConfig, err := ctrlConfig.GetConfigWithContext(k8sCtxName) + if err != nil { + return nil, err + } + cl, err := metricsClientset.NewForConfig(k8sConfig) + if err != nil { + return nil, err + } + return cl, nil +} diff --git a/pkg/internal/kubeapi/doc.go b/pkg/internal/kubeapi/doc.go new file mode 100644 index 0000000..9dcfe12 --- /dev/null +++ b/pkg/internal/kubeapi/doc.go @@ -0,0 +1,2 @@ +// Package kubeapi implements function and type to operate kubernetes common resource. +package kubeapi diff --git a/pkg/internal/kubeapi/metrics.go b/pkg/internal/kubeapi/metrics.go new file mode 100644 index 0000000..eea466c --- /dev/null +++ b/pkg/internal/kubeapi/metrics.go @@ -0,0 +1,291 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package kubeapi + +import ( + "context" + "fmt" + "math" + "os" + "sync" + "time" + + "gopkg.in/inf.v0" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + metricsClientset "k8s.io/metrics/pkg/client/clientset/versioned" + ctrlClient "sigs.k8s.io/controller-runtime/pkg/client" + + cfg "github.com/st-tech/gatling-commander/pkg/config" +) + +type metricsPool struct { + pool []MetricsField +} + +func newMetricsPool() *metricsPool { + return &metricsPool{} +} + +// calcEachMetricsMean calcurate mean of each MetricsField value. +func (metricsPool *metricsPool) calcEachMetricsMean() (*MetricsField, error) { + // fieldExtractor extract field value by given argument function returns value. + mean := func(fieldExtractor func(metrics MetricsField) int64) (int64, error) { + if len(metricsPool.pool) <= 0 { + return 0, fmt.Errorf("input value length is 0") + } + sum := int64(0) + for _, metrics := range metricsPool.pool { + sum += fieldExtractor(metrics) + } + return sum / int64(len(metricsPool.pool)), nil + } + + cpu, err := mean(func(metrics MetricsField) int64 { + return metrics.Cpu + }) + if err != nil { + return nil, fmt.Errorf("calculate cpu mean error %v", err) + } + + memory, err := mean(func(metrics MetricsField) int64 { + return metrics.Memory + }) + if err != nil { + return nil, fmt.Errorf("calculate memory mean error %v", err) + } + + return &MetricsField{ + Cpu: cpu, + Memory: memory, + }, nil +} + +func (metricsPool *metricsPool) append(metrics MetricsField) { + metricsPool.pool = append(metricsPool.pool, metrics) +} + +// FetchContainerResourcesLimit fetch specified container and get resources limits or requests field value. +func FetchContainerResourcesLimit( + ctx context.Context, + cl ctrlClient.Client, + podConfig cfg.TargetPodConfig, +) (*MetricsField, error) { + namespace := podConfig.Namespace + labelKey := podConfig.LabelKey + labelValue := podConfig.LabelValue + targetContainerName := podConfig.ContainerName + + var foundPods corev1.PodList + labelSelector := labels.SelectorFromSet(labels.Set{labelKey: labelValue}) + listOptions := &ctrlClient.ListOptions{ + Namespace: namespace, + LabelSelector: labelSelector, + } + + err := cl.List(ctx, &foundPods, listOptions) + if err != nil { + return nil, fmt.Errorf("failed to list target pod, %v", err) + } + + if len(foundPods.Items) == 0 { + return nil, fmt.Errorf("no match pods to specified label") + } + + var representPod *corev1.Pod + for _, pod := range foundPods.Items { + if pod.Status.Phase == "Running" { + /* + pod resources value is assumed to be equal among pods which has same label. + so use one of these as represent. + */ + representPod = &pod + break + } + } + if representPod == nil { + return nil, fmt.Errorf("founds pods status is not Running") + } + + for _, c := range representPod.Spec.Containers { + if c.Name == targetContainerName { + return getContainerResourcesLimits(c.Resources) + } + } + return nil, fmt.Errorf("target container not found") +} + +/* +FetchContainerMetricsMean returns container resources value mean. + +Fetch metrics value every 5 seconds until informerCh get value or context done. +Cpu and Memory value is rounded and cast from *inf.Dec to int64. +If error occured this error only log error and continue to run. (not returns error object) +*/ +func FetchContainerMetricsMean( + ctx context.Context, + wg *sync.WaitGroup, + cl metricsClientset.Interface, + resultCh chan MetricsField, + receiveGatlingFinishedCh chan bool, + podConfig cfg.TargetPodConfig, +) { + defer wg.Done() + + namespace := podConfig.Namespace + labelKey := podConfig.LabelKey + labelValue := podConfig.LabelValue + targetContainerName := podConfig.ContainerName + + log := func(msg string, isErr bool) { + logCommonPodInfo := fmt.Sprintf( + "Namespace: %v, Label: %v", + namespace, + fmt.Sprintf("%v=%v", labelKey, labelValue), + ) + if isErr { + fmt.Fprintf(os.Stderr, "Error: %v, %v\n", msg, logCommonPodInfo) + } else { + fmt.Printf("%v, %v\n", msg, logCommonPodInfo) + } + } + + metricsPool := newMetricsPool() + for { + select { + case <-ctx.Done(): + return + case <-receiveGatlingFinishedCh: + log("receive gatling job finished", false) + meanMetrics, err := metricsPool.calcEachMetricsMean() + if err != nil { + log(err.Error(), true) + return + } + resultCh <- *meanMetrics + return + default: + listOptions := metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%v=%v", labelKey, labelValue), + } + metricses, err := cl.MetricsV1beta1().PodMetricses(namespace).List(ctx, listOptions) + if err != nil { + log(fmt.Sprintf("failed to get pod metricses list %v", err), true) + continue + } + for _, podMetrics := range metricses.Items { + for _, c := range podMetrics.Containers { + if c.Name == targetContainerName { + // The unit of CPU value that the container object has is cores. + // ref: https://github.com/kubernetes/api/blob/5e9982075c8d828d9501e306ed0a2e133f1ebd88/core/v1/types.go#L5704 + // CPU, in cores. (500 mCPU = .5 vCPU) + cpuUsageDec := c.Usage.Cpu().AsDec() + // change unit to mCPU and convert *inf.Dec to int64 + // The Round method returns a 3-digit reounded up value which has type inf.Dec. + // The inf.Dec object Unscaled method returns unscaled property value which has int64 type. + // nolint:lll // ex: cpuUsageDec: inf.Dec{unscale: 5005, scale: 4} (equal to float64 0.5005), rounded up with scale 3 result: inf.Dec{unscale: 501, scale: 3}, Unscaled result: int64 501 + // So if get 0.5005 vCPU it convert 501 mCPU + cpuUsage, ok := cpuUsageDec.Round(cpuUsageDec, 3, inf.RoundUp).Unscaled() + if !ok { + log("failed to convert value of container metrics cpu usage *inf.Dec to int64", true) + } + // The unit of Memory value that the container object has is bytes. + // ref: https://github.com/kubernetes/api/blob/5e9982075c8d828d9501e306ed0a2e133f1ebd88/core/v1/types.go#L5704 + memUsage, _ := c.Usage.Memory().AsInt64() + metricsPool.append( + MetricsField{ + Cpu: cpuUsage, + Memory: memUsage, + }, + ) + } + } + } + time.Sleep(5 * time.Second) // NOTE: wait few seconds to avoid excessive cpu usage. + } + } +} + +// CalcAndRoundMetricsRatio calculate ratio by resource usage and limit. +func CalcAndRoundMetricsRatio(usage, limit int64) float64 { + ratio := float64(usage) / float64(limit) + roundedRatio := math.Round(ratio*1000) / 1000 // round 4th place to closest + return roundedRatio +} + +/* +getContainerResourcesLimits returns container resources limits field value. +If the limits field is not present, get requests field value. +If both of these are not present, an error is returned +*/ +func getContainerResourcesLimits(resources corev1.ResourceRequirements) (*MetricsField, error) { + var ( + gotCpu *inf.Dec + gotMem int64 + ) + // if Limits field value is not set, Quantity AsDec() method return inf.Dec{unscale: 0, scale: 0} + // nolint:lll // ref: https://github.com/kubernetes/apimachinery/blob/fd8daa85285e31da9771dbe372a66dfa20e78489/pkg/api/resource/quantity.go#L500 + // nolint:lll // ref: https://github.com/kubernetes/apimachinery/blob/fd8daa85285e31da9771dbe372a66dfa20e78489/pkg/api/resource/amount.go#L104 + if lim := resources.Limits.Cpu().AsDec(); lim.Cmp(inf.NewDec(0, 0)) == 0 { + req := resources.Requests.Cpu().AsDec() + if req.Cmp(inf.NewDec(0, 0)) == 0 { + return nil, fmt.Errorf("failed to get container metrics cpu both limits and requests") + } + gotCpu = req // use requests value as limits value if limits field value is not set. + } else { + gotCpu = lim + } + + if lim, _ := resources.Limits.Memory().AsInt64(); lim == 0 { + req, _ := resources.Requests.Memory().AsInt64() + if req == 0 { + return nil, fmt.Errorf("failed to get container metrics memory both limits and requests") + } + gotMem = req // use requests value as limits value if limits field value is not set. + } else { + gotMem = lim + } + + /* + got cpu unit is cores. + ref: https://github.com/kubernetes/api/blob/5e9982075c8d828d9501e306ed0a2e133f1ebd88/core/v1/types.go#L5704 + CPU, in cores. (500m = .5 cores) + change unit to m vCPU and convert *inf.Dec to int64 + */ + cpuLimits, ok := gotCpu.Round(gotCpu, 3, inf.RoundUp).Unscaled() + if !ok { + return nil, fmt.Errorf("failed to round and unscaled cpu value") + } + + /* + got memory unit is bytes. + ref: https://github.com/kubernetes/api/blob/5e9982075c8d828d9501e306ed0a2e133f1ebd88/core/v1/types.go#L5704 + */ + memLimits := gotMem + + return &MetricsField{ + Cpu: cpuLimits, + Memory: memLimits, + }, nil +} diff --git a/pkg/internal/kubeapi/metrics_test.go b/pkg/internal/kubeapi/metrics_test.go new file mode 100644 index 0000000..001db08 --- /dev/null +++ b/pkg/internal/kubeapi/metrics_test.go @@ -0,0 +1,746 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package kubeapi + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + cfg "github.com/st-tech/gatling-commander/pkg/config" + "github.com/st-tech/gatling-commander/pkg/internal/kubeutil" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/metrics/pkg/apis/metrics/v1beta1" + metricsFake "k8s.io/metrics/pkg/client/clientset/versioned/fake" +) + +func TestFetchContainerResourcesLimit(t *testing.T) { + cl := kubeutil.InitFakeClient() + const ( + labelKey = "app" + podCpu = int64(500) // m vCPU + podMem = int64(10 * 1024 * 1024 * 1024) // bytes + ) + + samplePod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-pod", + Namespace: "sample-namespace", + Labels: map[string]string{ + labelKey: "sample-app", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + corev1.Container{ + Name: "sample-container", + Resources: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + + samplePodOnlyResourcesRequests := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-pod-only-resources-requests", + Namespace: "sample-namespace", + Labels: map[string]string{ + labelKey: "sample-app-only-resources-requests", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + corev1.Container{ + Name: "sample-container-only-resources-requests", + Resources: corev1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + + err := cl.Create(context.TODO(), &samplePod) + assert.NoError(t, err) + err = cl.Create(context.TODO(), &samplePodOnlyResourcesRequests) + assert.NoError(t, err) + + samplePodConfig := cfg.TargetPodConfig{ + Namespace: samplePod.ObjectMeta.Namespace, + LabelKey: labelKey, + LabelValue: samplePod.ObjectMeta.Labels[labelKey], + ContainerName: samplePod.Spec.Containers[0].Name, + } + samplePodOnlyResourcesRequestsConfig := cfg.TargetPodConfig{ + Namespace: samplePodOnlyResourcesRequests.ObjectMeta.Namespace, + LabelKey: labelKey, + LabelValue: samplePodOnlyResourcesRequests.ObjectMeta.Labels[labelKey], + ContainerName: samplePodOnlyResourcesRequests.Spec.Containers[0].Name, + } + + expectedResourcesLimit := &MetricsField{ + Cpu: podCpu, + Memory: podMem, + } + + tests := []struct { + name string + podConfig cfg.TargetPodConfig + }{ + { + name: "success to fetched resources limits value", + podConfig: samplePodConfig, + }, + { + name: "success to fetched resources requests value", + podConfig: samplePodOnlyResourcesRequestsConfig, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + containerResourcesLimit, err := FetchContainerResourcesLimit(context.TODO(), cl, tt.podConfig) + assert.NoError(t, err) + assert.Equal(t, expectedResourcesLimit, containerResourcesLimit) + }) + } +} + +func TestFetchContainerResourcesLimit_MultiMemUnit(t *testing.T) { + cl := kubeutil.InitFakeClient() + + const ( + podCpu = int64(500) // m vCPU + podMem = int64(10 * 1024 * 1024 * 1024) // bytes sample value 10Gi + podMemMi = int64(podMem / (1024 * 1024)) // Mi + podMemGi = int64(podMem / (1024 * 1024 * 1024)) // Gi + labelKey = "app" + ) + + samplePodMemBytes := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-pod-mem-bytes", + Namespace: "sample-namespace", + Labels: map[string]string{ + labelKey: "sample-app-mem-bytes", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + corev1.Container{ + Name: "sample-container-mem-bytes", + Resources: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + + samplePodMemMi := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-pod-mem-mi", + Namespace: "sample-namespace", + Labels: map[string]string{ + labelKey: "sample-app-mem-mi", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + corev1.Container{ + Name: "sample-container-mem-mi", + Resources: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%vMi", podMemMi)), + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + + samplePodMemGi := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-pod-mem-gi", + Namespace: "sample-namespace", + Labels: map[string]string{ + labelKey: "sample-app-mem-gi", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + corev1.Container{ + Name: "sample-container-mem-gi", + Resources: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%vGi", podMemGi)), + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + + err := cl.Create(context.TODO(), &samplePodMemBytes) + assert.NoError(t, err) + err = cl.Create(context.TODO(), &samplePodMemMi) + assert.NoError(t, err) + err = cl.Create(context.TODO(), &samplePodMemGi) + assert.NoError(t, err) + + memBytesPodConfig := cfg.TargetPodConfig{ + Namespace: samplePodMemBytes.ObjectMeta.Namespace, + LabelKey: labelKey, + LabelValue: samplePodMemBytes.ObjectMeta.Labels[labelKey], + ContainerName: samplePodMemBytes.Spec.Containers[0].Name, + } + memMiPodConfig := cfg.TargetPodConfig{ + Namespace: samplePodMemMi.ObjectMeta.Namespace, + LabelKey: labelKey, + LabelValue: samplePodMemMi.ObjectMeta.Labels[labelKey], + ContainerName: samplePodMemMi.Spec.Containers[0].Name, + } + memGiPodConfig := cfg.TargetPodConfig{ + Namespace: samplePodMemGi.ObjectMeta.Namespace, + LabelKey: labelKey, + LabelValue: samplePodMemGi.ObjectMeta.Labels[labelKey], + ContainerName: samplePodMemGi.Spec.Containers[0].Name, + } + + expectedResourcesLimit := &MetricsField{ + Cpu: podCpu, + Memory: podMem, + } + + tests := []struct { + name string + podConfig cfg.TargetPodConfig + }{ + { + name: "memory limit bytes", + podConfig: memBytesPodConfig, + }, + { + name: "memory limit Mi", + podConfig: memMiPodConfig, + }, + { + name: "memory limit Gi", + podConfig: memGiPodConfig, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + containerResourcesLimit, err := FetchContainerResourcesLimit(context.TODO(), cl, tt.podConfig) + assert.NoError(t, err) + assert.Equal(t, expectedResourcesLimit, containerResourcesLimit) + }) + } +} + +func TestFetchContainerResourcesLimit_Failed(t *testing.T) { + cl := kubeutil.InitFakeClient() + const ( + podCpu = int64(500) // m vCPU + podMem = int64(10 * 1024 * 1024 * 1024) // bytes sample value 10Gi + ) + + labelKey := "app" + samplePod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-pod", + Namespace: "sample-namespace", + Labels: map[string]string{ + labelKey: "sample-app", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + corev1.Container{ + Name: "sample-container", + Resources: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + statusNotRunningPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "status-not-running-sample-pod", + Namespace: "sample-namespace", + Labels: map[string]string{ + labelKey: "status-not-running-sample-app", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + corev1.Container{ + Name: "status-not-running-sample-container", + Resources: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%vGi", podMem)), + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Pending", + }, + } + noResourcesMemFieldPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-resources-mem-field-sample-pod", + Namespace: "sample-namespace", + Labels: map[string]string{ + labelKey: "no-resources-mem-field-sample-app", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + corev1.Container{ + Name: "no-resources-mem-field-sample-container", + Resources: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + noResourcesCpuFieldPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-resources-cpu-field-sample-pod", + Namespace: "sample-namespace", + Labels: map[string]string{ + labelKey: "no-resources-cpu-field-sample-app", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + corev1.Container{ + Name: "no-resources-cpu-field-sample-container", + Resources: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%vGi", podMem)), + }, + }, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + + err := cl.Create(context.TODO(), &samplePod) + assert.NoError(t, err) + err = cl.Create(context.TODO(), &statusNotRunningPod) + assert.NoError(t, err) + err = cl.Create(context.TODO(), &noResourcesMemFieldPod) + assert.NoError(t, err) + err = cl.Create(context.TODO(), &noResourcesCpuFieldPod) + assert.NoError(t, err) + + notExistsPodConfig := cfg.TargetPodConfig{ + Namespace: "not exists namespace", + LabelKey: labelKey, + LabelValue: samplePod.ObjectMeta.Labels[labelKey], + ContainerName: samplePod.Spec.Containers[0].Name, + } + statusNotRunningPodConfig := cfg.TargetPodConfig{ + Namespace: statusNotRunningPod.ObjectMeta.Namespace, + LabelKey: labelKey, + LabelValue: statusNotRunningPod.ObjectMeta.Labels[labelKey], + ContainerName: statusNotRunningPod.Spec.Containers[0].Name, + } + noResourcesMemFieldPodConfig := cfg.TargetPodConfig{ + Namespace: noResourcesMemFieldPod.ObjectMeta.Namespace, + LabelKey: labelKey, + LabelValue: noResourcesMemFieldPod.ObjectMeta.Labels[labelKey], + ContainerName: noResourcesMemFieldPod.Spec.Containers[0].Name, + } + noResourcesCpuFieldPodConfig := cfg.TargetPodConfig{ + Namespace: noResourcesCpuFieldPod.ObjectMeta.Namespace, + LabelKey: labelKey, + LabelValue: noResourcesCpuFieldPod.ObjectMeta.Labels[labelKey], + ContainerName: noResourcesCpuFieldPod.Spec.Containers[0].Name, + } + + tests := []struct { + name string + podConfig cfg.TargetPodConfig + expected error + }{ + { + name: "failed because target pod not exists", + podConfig: notExistsPodConfig, + expected: fmt.Errorf("no match pods to specified label"), + }, + { + name: "failed because target pod with status Running is not exists", + podConfig: statusNotRunningPodConfig, + expected: fmt.Errorf("founds pods status is not Running"), + }, + { + name: "container has no resources memory field", + podConfig: noResourcesMemFieldPodConfig, + expected: fmt.Errorf("failed to get container metrics memory both limits and requests"), + }, + { + name: "container has no resources cpu field", + podConfig: noResourcesCpuFieldPodConfig, + expected: fmt.Errorf("failed to get container metrics cpu both limits and requests"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := FetchContainerResourcesLimit(context.TODO(), cl, tt.podConfig) + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestGetContainerResourcesLimits(t *testing.T) { + const ( + podCpu = int64(500) // m vCPU + podMem = int64(10 * 1024 * 1024 * 1024) // bytes sample value 10Gi + ) + + tests := []struct { + name string + input corev1.ResourceRequirements + }{ + { + name: "success to get cpu requests value", + input: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + Requests: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + }, + }, + }, + { + name: "success to get cpu limits value", + input: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + }, + { + name: "success to get memory requests value", + input: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + }, + Requests: v1.ResourceList{ + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + }, + { + name: "success to get memory limits value", + input: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + containerResourcesLimit, err := getContainerResourcesLimits(tt.input) + assert.NoError(t, err) + assert.Equal(t, &MetricsField{Cpu: podCpu, Memory: podMem}, containerResourcesLimit) + }) + } +} + +func TestGetContainerResourcesLimits_Failed(t *testing.T) { + const ( + podCpu = int64(500) // m vCPU + podMem = int64(10 * 1024 * 1024 * 1024) // bytes sample value 10Gi + ) + + tests := []struct { + name string + input corev1.ResourceRequirements + expected error + }{ + { + name: "failed to get cpu value", + input: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + expected: fmt.Errorf("failed to get container metrics cpu both limits and requests"), + }, + { + name: "failed to get memory value", + input: corev1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + }, + }, + expected: fmt.Errorf("failed to get container metrics memory both limits and requests"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := getContainerResourcesLimits(tt.input) + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestFetchTargetPodMetricsMean(t *testing.T) { + ctx := context.TODO() + + const ( + podCpu = int64(500) // m vCPU + podMem = int64(10 * 1024 * 1024 * 1024) // bytes + ) + podLabel := map[string]string{ + "app": "sample-app", + } + podMetrics := &v1beta1.PodMetrics{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-pod", + Namespace: "sample-namespace", + Labels: podLabel, + }, + Containers: []v1beta1.ContainerMetrics{ + { + Name: "sample-container", + Usage: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + }, + } + + gvr := schema.GroupVersionResource{Group: "metrics.k8s.io", Version: "v1beta1", Resource: "pods"} + cl := metricsFake.NewSimpleClientset(podMetrics) + _ = cl.Tracker().Create(gvr, podMetrics, podMetrics.ObjectMeta.Namespace) + + var targetLabelKey, targetLabelVal string + for key, val := range podMetrics.ObjectMeta.Labels { + targetLabelKey = key + targetLabelVal = val + } + + podConfig := cfg.TargetPodConfig{ + Namespace: podMetrics.ObjectMeta.Namespace, + LabelKey: targetLabelKey, + LabelValue: targetLabelVal, + ContainerName: podMetrics.Containers[0].Name, + } + + tests := []struct { + name string + podConfig cfg.TargetPodConfig + expected MetricsField + }{ + { + name: "success to fetch target pod metrics", + podConfig: podConfig, + expected: MetricsField{ + Cpu: podCpu, + Memory: podMem, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + informJobFinishCh := make(chan bool) + resultCh := make(chan MetricsField, 1) + go FetchContainerMetricsMean(ctx, &wg, cl, resultCh, informJobFinishCh, tt.podConfig) + + mockWaitGatlingJobRunning := func(jobFinishCh chan bool) { + defer func() { jobFinishCh <- true }() + time.Sleep(2 * time.Second) + } + + mockWaitGatlingJobRunning(informJobFinishCh) + + close(informJobFinishCh) + wg.Wait() + close(resultCh) + + metricsResult := <-resultCh + assert.Equal(t, tt.expected.Cpu, metricsResult.Cpu) + assert.Equal(t, tt.expected.Memory, metricsResult.Memory) + }) + } +} + +func TestFetchTargetPodMetricsMean_Failed(t *testing.T) { + ctx := context.TODO() + + const ( + podCpu = int64(500) // m vCPU + podMem = int64(10 * 1024 * 1024 * 1024) // bytes + ) + podLabel := map[string]string{ + "app": "sample-app", + } + podMetrics := &v1beta1.PodMetrics{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sample-pod", + Namespace: "sample-namespace", + Labels: podLabel, + }, + Containers: []v1beta1.ContainerMetrics{ + { + Name: "sample-container", + Usage: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse(fmt.Sprintf("%vm", podCpu)), + v1.ResourceMemory: resource.MustParse(fmt.Sprintf("%v", podMem)), + }, + }, + }, + } + + gvr := schema.GroupVersionResource{Group: "metrics.k8s.io", Version: "v1beta1", Resource: "pods"} + cl := metricsFake.NewSimpleClientset(podMetrics) + _ = cl.Tracker().Create(gvr, podMetrics, podMetrics.ObjectMeta.Namespace) + + resultCh := make(chan MetricsField, 1) + informerCh := make(chan bool) + + var targetLabelKey, targetLabelVal string + for key, val := range podMetrics.ObjectMeta.Labels { + targetLabelKey = key + targetLabelVal = val + } + targetPodConfig := cfg.TargetPodConfig{ + Namespace: "invalid namespace", + LabelKey: targetLabelKey, + LabelValue: targetLabelVal, + ContainerName: podMetrics.Containers[0].Name, + } + + var wg sync.WaitGroup + wg.Add(1) + go FetchContainerMetricsMean(ctx, &wg, cl, resultCh, informerCh, targetPodConfig) + + mockWaitGatlingJobRunning := func(ch chan bool) { + defer func() { ch <- true }() + time.Sleep(2 * time.Second) + } + + mockWaitGatlingJobRunning(informerCh) + + close(informerCh) + wg.Wait() + close(resultCh) + + metricsResult, ok := <-resultCh // resultCh has no metric value so return 0 value + assert.Equal(t, ok, false) + assert.Equal(t, int64(0), metricsResult.Cpu) + assert.Equal(t, int64(0), metricsResult.Memory) +} + +func TestCalcAndRoundMetricsRatio(t *testing.T) { + type input struct { + usage int64 + limit int64 + } + tests := []struct { + name string + input input + expected float64 + }{ + { + name: "receive expected value", + input: input{ + usage: int64(499), + limit: int64(3000), + }, + expected: 0.166, // rounded 4th place to closest + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + roundedRatio := CalcAndRoundMetricsRatio(tt.input.usage, tt.input.limit) + assert.Equal(t, tt.expected, roundedRatio) + }) + } +} diff --git a/pkg/internal/kubeapi/types.go b/pkg/internal/kubeapi/types.go new file mode 100644 index 0000000..68ca791 --- /dev/null +++ b/pkg/internal/kubeapi/types.go @@ -0,0 +1,29 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package kubeapi + +// MetricsField hold container metrics value. +type MetricsField struct { + Cpu int64 // milli vCPU + Memory int64 // bytes +} diff --git a/pkg/internal/kubeutil/kubeutil.go b/pkg/internal/kubeutil/kubeutil.go new file mode 100644 index 0000000..f9a0b03 --- /dev/null +++ b/pkg/internal/kubeutil/kubeutil.go @@ -0,0 +1,45 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package kubeutil implements utility for operating Kubernetes resource. +package kubeutil + +import ( + gatlingv1alpha1 "github.com/st-tech/gatling-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// InitFakeClient creates fake client for testing function which request to kubernetes api. +func InitFakeClient() client.WithWatch { + // add custom resource to scheme + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(gatlingv1alpha1.AddToScheme(scheme)) + + // prepare fake client + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + return cl +} diff --git a/pkg/util/check.go b/pkg/util/check.go new file mode 100644 index 0000000..42db6de --- /dev/null +++ b/pkg/util/check.go @@ -0,0 +1,53 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +// Package util implements utility in gatling-commander. +package util + +import ( + "fmt" +) + +// CheckDuplicate checks target string list items are unique. +func CheckDuplicate(target []string) error { + var duplicates []string + unique := make(map[string]interface{}) + for _, name := range target { + if _, exist := unique[name]; !exist { + unique[name] = nil + continue + } + duplicates = append(duplicates, name) + if len(duplicates) > 0 { + return fmt.Errorf("duplicated value found %v\n", duplicates) + } + } + return nil +} + +// CheckTimeout checks duration value is not exceeded timeout value. +func CheckTimeout(timeout int32, duration int32) error { + if duration > timeout { + return fmt.Errorf("timeout %v execeeded", timeout) + } + return nil +} diff --git a/pkg/util/check_test.go b/pkg/util/check_test.go new file mode 100644 index 0000000..1b94bca --- /dev/null +++ b/pkg/util/check_test.go @@ -0,0 +1,89 @@ +/* +Copyright © ZOZO, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package util + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCheckDuplicate(t *testing.T) { + tests := []struct { + name string + input []string + expected error + }{ + { + name: "not duplicated", + input: []string{"hoge", "fuga"}, + expected: nil, + }, + { + name: "duplicated", + input: []string{"hoge", "hoge"}, + expected: fmt.Errorf("duplicated value found %v\n", []string{"hoge"}), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CheckDuplicate(tt.input) + assert.Equal(t, tt.expected, err) + }) + } +} + +func TestCheckTimeout(t *testing.T) { + fromTime := int32(time.Now().Unix()) + time.Sleep(2 * time.Second) + duration := int32(time.Now().Unix()) - fromTime + tests := []struct { + name string + timeout int32 + expected error + }{ + { + name: "duration exceeded timeout", + timeout: 1, + expected: fmt.Errorf("timeout %v execeeded", 1), + }, + { + name: "duration equal timeout", + timeout: 2, + expected: nil, + }, + { + name: "duration not exceeded timeout", + timeout: 3, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CheckTimeout(tt.timeout, duration) + assert.Equal(t, tt.expected, err) + }) + } +}