├── .codespellignore ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── linter.yml ├── .gitignore ├── .golangci.yml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── examples ├── configmap_operations.js ├── deployment_operations.js ├── endpoints_operations.js ├── ingress_operations.js ├── job_operations.js ├── namespace_operations.js ├── node_operations.js ├── persistentvolume_operations.js ├── persistentvolumeclaim_operations.js ├── pod_operations.js ├── secret_operations.js ├── service_operations.js └── statefulset_operations.js ├── go.mod ├── go.sum ├── internal └── testutils │ └── fake.go ├── kubernetes.go ├── kubernetes_test.go └── pkg ├── api └── api.go ├── helpers ├── helpers.go ├── jobs.go ├── jobs_test.go ├── pods.go ├── pods_test.go ├── services.go └── services_test.go ├── resources ├── resources.go └── resources_test.go └── utils ├── retry.go ├── retry_test.go └── unstructured.go /.codespellignore: -------------------------------------------------------------------------------- 1 | AKS -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | vendor/* linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # Enable manually triggering this workflow via the API or web UI 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - main 9 | tags: 10 | - v* 11 | pull_request: 12 | 13 | defaults: 14 | run: 15 | shell: bash 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | build-with-xk6: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | with: 27 | persist-credentials: false 28 | - name: Install Go 29 | uses: actions/setup-go@v4 30 | with: 31 | go-version: 1.21.x 32 | cache: false 33 | - name: Check build 34 | run: | 35 | go version 36 | pwd && ls -l 37 | 38 | go install go.k6.io/xk6/cmd/xk6@latest 39 | MODULE_NAME=$(go list -m) 40 | 41 | xk6 build \ 42 | --output ./k6ext \ 43 | --with $MODULE_NAME="." 44 | ./k6ext version 45 | 46 | test-go-versions: 47 | strategy: 48 | fail-fast: false 49 | matrix: 50 | go-version: [1.20.x, 1.21.x, tip] 51 | platform: [ubuntu-latest, windows-latest] 52 | runs-on: ${{ matrix.platform }} 53 | steps: 54 | - name: Checkout code 55 | uses: actions/checkout@v4 56 | with: 57 | persist-credentials: false 58 | - name: Install Go ${{ matrix.go-version }} 59 | if: matrix.go-version != 'tip' 60 | uses: actions/setup-go@v4 61 | with: 62 | go-version: ${{ matrix.go-version }} 63 | cache: false 64 | - name: Install Go stable 65 | if: matrix.go-version == 'tip' 66 | uses: actions/setup-go@v4 67 | with: 68 | go-version: 1.x 69 | cache: false 70 | - name: Install Go tip 71 | shell: bash 72 | if: matrix.go-version == 'tip' 73 | run: | 74 | go install golang.org/dl/gotip@latest 75 | gotip download 76 | echo "GOROOT=$HOME/sdk/gotip" >> "$GITHUB_ENV" 77 | echo "GOPATH=$HOME/go" >> "$GITHUB_ENV" 78 | echo "$HOME/go/bin" >> "$GITHUB_PATH" 79 | echo "$HOME/sdk/gotip/bin" >> "$GITHUB_PATH" 80 | - name: Run tests 81 | run: | 82 | which go 83 | go version 84 | go test -race -timeout 60s ./... 85 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | check-modules: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | with: 21 | persist-credentials: false 22 | - name: Install Go 23 | uses: actions/setup-go@v4 24 | with: 25 | go-version: 1.21.x 26 | cache: false 27 | - name: Check module dependencies 28 | run: | 29 | go version 30 | test -z "$(go mod tidy && git status go.* --porcelain)" 31 | go mod verify 32 | 33 | lint: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v4 38 | with: 39 | fetch-depth: 0 40 | persist-credentials: false 41 | - name: Install Go 42 | uses: actions/setup-go@v4 43 | with: 44 | go-version: 1.21.x 45 | cache: false 46 | - name: Retrieve golangci-lint version 47 | run: | 48 | echo "Version=$(head -n 1 "${GITHUB_WORKSPACE}/.golangci.yml" | tr -d '# ')" >> $GITHUB_OUTPUT 49 | id: version 50 | - name: golangci-lint 51 | uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2 52 | with: 53 | version: ${{ steps.version.outputs.Version }} 54 | only-new-issues: true 55 | 56 | codespell: 57 | name: Codespell 58 | runs-on: ubuntu-latest 59 | timeout-minutes: 5 60 | steps: 61 | - uses: actions/checkout@v4 62 | with: 63 | persist-credentials: false 64 | - name: Codespell test 65 | uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2.1 66 | with: 67 | ignore_words_file: .codespellignore 68 | 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | k6 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # v1.55.2 2 | # Please don't remove the first line. It is used in CI to determine the golangci version 3 | run: 4 | deadline: 5m 5 | 6 | issues: 7 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 8 | max-issues-per-linter: 0 9 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 10 | max-same-issues: 0 11 | 12 | # We want to try and improve the comments in the k6 codebase, so individual 13 | # non-golint items from the default exclusion list will gradually be added 14 | # to the exclude-rules below 15 | exclude-use-default: false 16 | 17 | exclude-rules: 18 | # Exclude duplicate code and function length and complexity checking in test 19 | # files (due to common repeats and long functions in test code) 20 | - path: _(test|gen)\.go 21 | linters: 22 | - cyclop 23 | - dupl 24 | - gocognit 25 | - funlen 26 | - lll 27 | - linters: 28 | - staticcheck # Tracked in https://github.com/grafana/xk6-grpc/issues/14 29 | text: "The entire proto file grpc/reflection/v1alpha/reflection.proto is marked as deprecated." 30 | - linters: 31 | - forbidigo 32 | text: 'use of `os\.(SyscallError|Signal|Interrupt)` forbidden' 33 | 34 | linters-settings: 35 | nolintlint: 36 | # Disable to ensure that nolint directives don't have a leading space. Default is true. 37 | allow-leading-space: false 38 | exhaustive: 39 | default-signifies-exhaustive: true 40 | govet: 41 | check-shadowing: true 42 | cyclop: 43 | max-complexity: 25 44 | maligned: 45 | suggest-new: true 46 | dupl: 47 | threshold: 150 48 | goconst: 49 | min-len: 10 50 | min-occurrences: 4 51 | funlen: 52 | lines: 80 53 | statements: 60 54 | forbidigo: 55 | forbid: 56 | - '^(fmt\\.Print(|f|ln)|print|println)$' 57 | # Forbid everything in os, except os.Signal and os.SyscalError 58 | - '^os\.(.*)$(# Using anything except Signal and SyscallError from the os package is forbidden )?' 59 | # Forbid everything in syscall except the uppercase constants 60 | - '^syscall\.[^A-Z_]+$(# Using anything except constants from the syscall package is forbidden )?' 61 | - '^logrus\.Logger$' 62 | 63 | linters: 64 | disable-all: true 65 | enable: 66 | - asasalint 67 | - asciicheck 68 | - bidichk 69 | - bodyclose 70 | - contextcheck 71 | - cyclop 72 | - dogsled 73 | - dupl 74 | - durationcheck 75 | - errcheck 76 | - errchkjson 77 | - errname 78 | - errorlint 79 | - exhaustive 80 | - exportloopref 81 | - forbidigo 82 | - forcetypeassert 83 | - funlen 84 | - gocheckcompilerdirectives 85 | - gochecknoglobals 86 | - gocognit 87 | - goconst 88 | - gocritic 89 | - gofmt 90 | - gofumpt 91 | - goimports 92 | - gomoddirectives 93 | - goprintffuncname 94 | - gosec 95 | - gosimple 96 | - govet 97 | - importas 98 | - ineffassign 99 | - interfacebloat 100 | - lll 101 | - makezero 102 | - misspell 103 | - nakedret 104 | - nestif 105 | - nilerr 106 | - nilnil 107 | - noctx 108 | - nolintlint 109 | - nosprintfhostport 110 | - paralleltest 111 | - prealloc 112 | - predeclared 113 | - promlinter 114 | - revive 115 | - reassign 116 | - rowserrcheck 117 | - sqlclosecheck 118 | - staticcheck 119 | - stylecheck 120 | - tenv 121 | - tparallel 122 | - typecheck 123 | - unconvert 124 | - unparam 125 | - unused 126 | - usestdlibvars 127 | - wastedassign 128 | - whitespace 129 | fast: false 130 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @grafana/k6-extensions 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting our Developer Relations team, avocados@k6.io. 59 | 60 | All complaints will be reviewed and investigated and will result in a response that 61 | is deemed necessary and appropriate to the circumstances. The project team is 62 | obligated to maintain confidentiality with regard to the reporter of an incident. 63 | Further details of specific enforcement policies may be posted separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 72 | available at [http://contributor-covenant.org/version/1/4][version] 73 | 74 | [homepage]: http://contributor-covenant.org 75 | [version]: http://contributor-covenant.org/version/1/4/ 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += --silent 2 | 3 | all: clean format test build 4 | 5 | ## help: Prints a list of available build targets. 6 | help: 7 | echo "Usage: make ... " 8 | echo "" 9 | echo "Available targets are:" 10 | echo '' 11 | sed -n 's/^##//p' ${PWD}/Makefile | column -t -s ':' | sed -e 's/^/ /' 12 | echo 13 | echo "Targets run by default are: `sed -n 's/^all: //p' ./Makefile | sed -e 's/ /, /g' | sed -e 's/\(.*\), /\1, and /'`" 14 | 15 | ## clean: Removes any previously created build artifacts. 16 | clean: 17 | rm -f ./k6 18 | 19 | ## build: Builds a custom 'k6' with the local extension. 20 | build: 21 | go install go.k6.io/xk6/cmd/xk6@latest 22 | xk6 build --with $(shell go list -m)=. 23 | 24 | ## format: Applies Go formatting to code. 25 | format: 26 | go fmt ./... 27 | 28 | ## test: Executes any unit tests. 29 | test: 30 | go test -cover -race ./... 31 | 32 | .PHONY: build clean format help test 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Reference](https://pkg.go.dev/badge/github.com/grafana/xk6-kubernetes.svg)](https://pkg.go.dev/github.com/grafana/xk6-kubernetes) 2 | [![Version Badge](https://img.shields.io/github/v/release/grafana/xk6-kubernetes?style=flat-square)](https://github.com/grafana/xk6-kubernetes/releases) 3 | ![Build Status](https://img.shields.io/github/actions/workflow/status/grafana/xk6-kubernetes/ci.yml?style=flat-square) 4 | 5 | # xk6-kubernetes 6 | A k6 extension for interacting with Kubernetes clusters while testing. 7 | 8 | ## Build 9 | 10 | To build a custom `k6` binary with this extension, first ensure you have the prerequisites: 11 | 12 | - [Go toolchain](https://go101.org/article/go-toolchain.html) 13 | - Git 14 | 15 | 1. Download [xk6](https://github.com/grafana/xk6): 16 | 17 | ```bash 18 | go install go.k6.io/xk6/cmd/xk6@latest 19 | ``` 20 | 21 | 2. [Build the k6 binary](https://github.com/grafana/xk6#command-usage): 22 | 23 | ```bash 24 | xk6 build --with github.com/grafana/xk6-kubernetes 25 | ``` 26 | 27 | The `xk6 build` command creates a k6 binary that includes the xk6-kubernetes extension in your local folder. This k6 binary can now run a k6 test using [xk6-kubernetes APIs](#apis). 28 | 29 | 30 | ### Development 31 | To make development a little smoother, use the `Makefile` in the root folder. The default target will format your code, run tests, and create a `k6` binary with your local code rather than from GitHub. 32 | 33 | ```shell 34 | git clone git@github.com:grafana/xk6-kubernetes.git 35 | cd xk6-kubernetes 36 | make 37 | ``` 38 | 39 | Using the `k6` binary with `xk6-kubernetes`, run the k6 test as usual: 40 | 41 | ```bash 42 | ./k6 run k8s-test-script.js 43 | 44 | ``` 45 | # Usage 46 | 47 | By default, the API assumes a `kubeconfig` configuration is available at `$HOME/.kube`. 48 | 49 | Alternatively, you can pass in the following options as a javascript Object to the Kubernetes constructor to configure access to the Kubernetes API server: 50 | 51 | | Option | Value | Description | 52 | | -- | --| ---- | 53 | | config_path | /path/to/kubeconfig | Kubeconfig file location. You can also set this to __ENV.KUBECONFIG to use the location pointed by the `KUBECONFIG` environment variable | 54 | | server | | Kubernetes API server URL | 55 | | token | | Bearer Token for authenticating to the Kubernetes API server | 56 | 57 | ```javascript 58 | 59 | import { Kubernetes } from 'k6/x/kubernetes'; 60 | 61 | export default function () { 62 | const k = new Kubernetes({ 63 | config_map: '/path/to/kubeconfig', 64 | }); 65 | } 66 | ``` 67 | 68 | # APIs 69 | 70 | ## Generic API 71 | 72 | This API offers methods for creating, retrieving, listing and deleting resources of any of the supported kinds. 73 | 74 | | Method | Parameters| Description | 75 | | ------------ | ---| ------ | 76 | | apply | manifest string| creates a Kubernetes resource given a YAML manifest or updates it if already exists | 77 | | create | spec object | creates a Kubernetes resource given its specification | 78 | | delete | kind | removes the named resource | 79 | | | name | 80 | | | namespace| 81 | | get | kind| returns the named resource | 82 | | | name | 83 | | | namespace | 84 | | list | kind| returns a collection of resources of a given kind 85 | | | namespace | 86 | | update | spec object | updates an existing resource 87 | 88 | ### Examples 89 | 90 | #### Creating a pod using a specification 91 | ```javascript 92 | import { Kubernetes } from 'k6/x/kubernetes'; 93 | 94 | const podSpec = { 95 | apiVersion: "v1", 96 | kind: "Pod", 97 | metadata: { 98 | name: "busybox", 99 | namespace: "testns" 100 | }, 101 | spec: { 102 | containers: [ 103 | { 104 | name: "busybox", 105 | image: "busybox", 106 | command: ["sh", "-c", "sleep 30"] 107 | } 108 | ] 109 | } 110 | } 111 | 112 | export default function () { 113 | const kubernetes = new Kubernetes(); 114 | 115 | kubernetes.create(pod) 116 | 117 | const pods = kubernetes.list("Pod", "testns"); 118 | 119 | console.log(`${pods.length} Pods found:`); 120 | pods.map(function(pod) { 121 | console.log(` ${pod.metadata.name}`) 122 | }); 123 | } 124 | ``` 125 | 126 | #### Creating a job using a YAML manifest 127 | ```javascript 128 | import { Kubernetes } from 'k6/x/kubernetes'; 129 | 130 | const manifest = ` 131 | apiVersion: batch/v1 132 | kind: Job 133 | metadata: 134 | name: busybox 135 | namespace: testns 136 | spec: 137 | template: 138 | spec: 139 | containers: 140 | - name: busybox 141 | image: busybox 142 | command: ["sleep", "300"] 143 | restartPolicy: Never 144 | ` 145 | 146 | export default function () { 147 | const kubernetes = new Kubernetes(); 148 | 149 | kubernetes.apply(manifest) 150 | 151 | const jobs = kubernetes.list("Job", "testns"); 152 | 153 | console.log(`${jobs.length} Jobs found:`); 154 | pods.map(function(job) { 155 | console.log(` ${job.metadata.name}`) 156 | }); 157 | } 158 | ``` 159 | 160 | #### Interacting with objects created by CRDs 161 | 162 | For objects outside of the core API, use the fully-qualified resource name. 163 | 164 | ```javascript 165 | 166 | import { Kubernetes } from 'k6/x/kubernetes'; 167 | 168 | const manifest = ` 169 | apiVersion: networking.k8s.io/v1 170 | kind: Ingress 171 | metadata: 172 | name: yaml-ingress 173 | namespace: default 174 | spec: 175 | ingressClassName: nginx 176 | rules: 177 | - http: 178 | paths: 179 | - path: /my-service-path 180 | pathType: Prefix 181 | backend: 182 | service: 183 | name: my-service 184 | port: 185 | number: 80 186 | ` 187 | 188 | export default function () { 189 | const kubernetes = new Kubernetes(); 190 | 191 | kubernetes.apply(manifest); 192 | 193 | const ingresses = kubernetes.list("Ingress.networking.k8s.io", "default") 194 | 195 | console.log(`${ingresses.length} Ingress found:`); 196 | ingresses.map(function(ingress) { 197 | console.log(` ${ingress.metadata.name}`) 198 | }); 199 | } 200 | 201 | 202 | ``` 203 | 204 | ## Helpers 205 | 206 | The `xk6-kubernetes` extension offers helpers to facilitate common tasks when setting up a tests. All helper functions work in a namespace to facilitate the development of tests segregated by namespace. The helpers are accessed using the following method: 207 | 208 | | Method | Parameters| Description | 209 | | -------------| ---| ------ | 210 | | helpers | namespace | returns helpers that operate in the given namespace. If none is specified, "default" is used | 211 | 212 | The methods above return an object that implements the following helper functions: 213 | 214 | | Method | Parameters| Description | 215 | | ------------ | --------| ------ | 216 | | getExternalIP | service | returns the external IP of a service if any is assigned before timeout expires| 217 | | | timeout in seconds | | 218 | | waitPodRunning | pod name | waits until the pod is in 'Running' state or the timeout expires. Returns a boolean indicating of the pod was ready or not. Throws an error if the pod is Failed. | 219 | | | timeout in seconds | | 220 | | waitServiceReady | service name | waits until the given service has at least one endpoint ready or the timeout expires | 221 | | | timeout in seconds | | 222 | 223 | 224 | 225 | ### Examples 226 | 227 | ### Creating a pod and wait until it is running 228 | 229 | ```javascript 230 | import { Kubernetes } from 'k6/x/kubernetes'; 231 | 232 | let podSpec = { 233 | apiVersion: "v1", 234 | kind: "Pod", 235 | metadata: { 236 | name: "busybox", 237 | namespace: "default" 238 | }, 239 | spec: { 240 | containers: [ 241 | { 242 | name: "busybox", 243 | image: "busybox", 244 | command: ["sh", "-c", "sleep 30"] 245 | } 246 | ] 247 | } 248 | } 249 | 250 | export default function () { 251 | const kubernetes = new Kubernetes(); 252 | 253 | // create pod 254 | kubernetes.create(pod) 255 | 256 | // get helpers for test namespace 257 | const helpers = kubernetes.helpers() 258 | 259 | // wait for pod to be running 260 | const timeout = 10 261 | if (!helpers.waitPodRunning(pod.metadata.name, timeout)) { 262 | console.log(`"pod ${pod.metadata.name} not ready after ${timeout} seconds`) 263 | } 264 | } 265 | ``` 266 | -------------------------------------------------------------------------------- /examples/configmap_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from "k6/x/kubernetes"; 2 | import { describe, expect } from "https://jslib.k6.io/k6chaijs/4.3.4.3/index.js"; 3 | import { load, dump } from "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs"; 4 | 5 | let json = { 6 | apiVersion: "v1", 7 | kind: "ConfigMap", 8 | metadata: { 9 | name: "json-configmap", 10 | namespace: "default", 11 | }, 12 | data: { 13 | K6_API_TEST_URL: "https://test.k6.io", 14 | } 15 | } 16 | 17 | let yaml = ` 18 | apiVersion: v1 19 | kind: ConfigMap 20 | metadata: 21 | name: yaml-configmap 22 | namespace: default 23 | data: 24 | K6_API_TEST_URL: https://test.k6.io 25 | ` 26 | 27 | export default function () { 28 | const kubernetes = new Kubernetes(); 29 | 30 | describe('JSON-based resources', () => { 31 | const name = json.metadata.name 32 | const ns = json.metadata.namespace 33 | 34 | let configmap 35 | 36 | describe('Create our ConfigMap using the JSON definition', () => { 37 | configmap = kubernetes.create(json) 38 | expect(configmap.metadata, 'new configmap').to.have.property('uid') 39 | }) 40 | 41 | describe('Retrieve all available ConfigMap', () => { 42 | expect(kubernetes.list("ConfigMap", ns).length, 'total configmaps').to.be.at.least(1) 43 | }) 44 | 45 | describe('Retrieve our ConfigMap by name and namespace', () => { 46 | let fetched = kubernetes.get("ConfigMap", name, ns) 47 | expect(configmap.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 48 | }) 49 | 50 | describe('Update our ConfigMap with a modified JSON definition', () => { 51 | const newValue = 'https://test-api.k6.io/' 52 | json.data.K6_API_TEST_URL = newValue 53 | 54 | kubernetes.update(json) 55 | let updated = kubernetes.get("ConfigMap", name, ns) 56 | expect(updated.data.K6_API_TEST_URL, 'changed value').to.be.equal(newValue) 57 | }) 58 | 59 | describe('Remove our ConfigMap to cleanup', () => { 60 | kubernetes.delete("ConfigMap", name, ns) 61 | }) 62 | }) 63 | 64 | describe('YAML-based resources', () => { 65 | let yamlObject = load(yaml) 66 | const name = yamlObject.metadata.name 67 | const ns = yamlObject.metadata.namespace 68 | 69 | describe('Create our ConfigMap using the YAML definition', () => { 70 | kubernetes.apply(yaml) 71 | let created = kubernetes.get("ConfigMap", name, ns) 72 | expect(created.metadata, 'new configmap').to.have.property('uid') 73 | }) 74 | 75 | describe('Update our ConfigMap with a modified YAML definition', () => { 76 | const newValue = 'https://test-api.k6.io/' 77 | yamlObject.data.K6_API_TEST_URL = newValue 78 | let newYaml = dump(yamlObject) 79 | 80 | kubernetes.apply(newYaml) 81 | let updated = kubernetes.get("ConfigMap", name, ns) 82 | expect(updated.data.K6_API_TEST_URL, 'changed value').to.be.equal(newValue) 83 | }) 84 | 85 | describe('Remove our ConfigMap to cleanup', () => { 86 | kubernetes.delete("ConfigMap", name, ns) 87 | }) 88 | }) 89 | 90 | } 91 | -------------------------------------------------------------------------------- /examples/deployment_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from "k6/x/kubernetes"; 2 | import { describe, expect } from "https://jslib.k6.io/k6chaijs/4.3.4.3/index.js"; 3 | import { load, dump } from "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs"; 4 | 5 | let json = { 6 | apiVersion: "apps/v1", 7 | kind: "Deployment", 8 | metadata: { 9 | name: "json-deployment", 10 | namespace: "default", 11 | }, 12 | spec: { 13 | replicas: 1, 14 | selector: { 15 | matchLabels: { 16 | app: "json-intg-test" 17 | } 18 | }, 19 | template: { 20 | metadata: { 21 | labels: { 22 | app: "json-intg-test" 23 | } 24 | }, 25 | spec: { 26 | containers: [ 27 | { 28 | name: "nginx", 29 | image: "nginx:1.14.2", 30 | ports: [ 31 | {containerPort: 80} 32 | ] 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | } 39 | 40 | let yaml = ` 41 | apiVersion: apps/v1 42 | kind: Deployment 43 | metadata: 44 | name: yaml-deployment 45 | namespace: default 46 | spec: 47 | replicas: 1 48 | selector: 49 | matchLabels: 50 | app: yaml-intg-test 51 | template: 52 | metadata: 53 | labels: 54 | app: yaml-intg-test 55 | spec: 56 | containers: 57 | - name: nginx 58 | image: nginx:1.14.2 59 | ports: 60 | - containerPort: 80 61 | ` 62 | 63 | export default function () { 64 | const kubernetes = new Kubernetes(); 65 | 66 | describe('JSON-based resources', () => { 67 | const name = json.metadata.name 68 | const ns = json.metadata.namespace 69 | 70 | let deployment 71 | 72 | describe('Create our Deployment using the JSON definition', () => { 73 | deployment = kubernetes.create(json) 74 | expect(deployment.metadata, 'new deployment').to.have.property('uid') 75 | }) 76 | 77 | describe('Retrieve all available Deployments', () => { 78 | expect(kubernetes.list("Deployment.apps", ns).length, 'total deployments').to.be.at.least(1) 79 | }) 80 | 81 | describe('Retrieve our Deployment by name and namespace', () => { 82 | let fetched = kubernetes.get("Deployment.apps", name, ns) 83 | expect(deployment.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 84 | }) 85 | 86 | describe('Update our Deployment with a modified JSON definition', () => { 87 | const newValue = 2 88 | json.spec.replicas = newValue 89 | 90 | kubernetes.update(json) 91 | let updated = kubernetes.get("Deployment.apps", name, ns) 92 | expect(updated.spec.replicas, 'changed value').to.be.equal(newValue) 93 | }) 94 | 95 | describe('Remove our Deployment to cleanup', () => { 96 | kubernetes.delete("Deployment.apps", name, ns) 97 | }) 98 | }) 99 | 100 | describe('YAML-based resources', () => { 101 | let yamlObject = load(yaml) 102 | const name = yamlObject.metadata.name 103 | const ns = yamlObject.metadata.namespace 104 | 105 | describe('Create our Deployment using the YAML definition', () => { 106 | kubernetes.apply(yaml) 107 | let created = kubernetes.get("Deployment.apps", name, ns) 108 | expect(created.metadata, 'new deployment').to.have.property('uid') 109 | }) 110 | 111 | describe('Update our Deployment with a modified YAML definition', () => { 112 | const newValue = 2 113 | yamlObject.spec.replicas = newValue 114 | let newYaml = dump(yamlObject) 115 | 116 | kubernetes.apply(newYaml) 117 | let updated = kubernetes.get("Deployment.apps", name, ns) 118 | expect(updated.spec.replicas, 'changed value').to.be.equal(newValue) 119 | }) 120 | 121 | describe('Remove our Deployment to cleanup', () => { 122 | kubernetes.delete("Deployment.apps", name, ns) 123 | }) 124 | }) 125 | 126 | } 127 | -------------------------------------------------------------------------------- /examples/endpoints_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from "k6/x/kubernetes"; 2 | import { describe, expect } from "https://jslib.k6.io/k6chaijs/4.3.4.3/index.js"; 3 | import { load } from "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs"; 4 | 5 | let json = { 6 | apiVersion: "v1", 7 | kind: "Endpoints", 8 | metadata: { 9 | name: "json-endpoint", 10 | namespace: "default", 11 | }, 12 | subsets: [ 13 | { 14 | addresses: [ 15 | {ip: "192.168.0.32"}, 16 | ], 17 | ports: [ 18 | { 19 | name: "https", 20 | port: 6443, 21 | protocol: "TCP", 22 | } 23 | ], 24 | } 25 | ] 26 | } 27 | 28 | let yaml = ` 29 | apiVersion: v1 30 | kind: Endpoints 31 | metadata: 32 | name: yaml-endpoint 33 | namespace: default 34 | subsets: 35 | - addresses: 36 | - ip: 192.168.0.32 37 | ports: 38 | - name: https 39 | port: 6443 40 | protocol: TCP 41 | ` 42 | 43 | export default function () { 44 | const kubernetes = new Kubernetes(); 45 | 46 | describe('JSON-based resources', () => { 47 | const name = json.metadata.name 48 | const ns = json.metadata.namespace 49 | 50 | let endpoint 51 | 52 | describe('Create our Endpoints using the JSON definition', () => { 53 | endpoint = kubernetes.create(json) 54 | expect(endpoint.metadata, 'new endpoint').to.have.property('uid') 55 | }) 56 | 57 | describe('Retrieve all available Endpoints', () => { 58 | expect(kubernetes.list("Endpoints", ns).length, 'total endpoints').to.be.at.least(1) 59 | }) 60 | 61 | describe('Retrieve our Endpoints by name and namespace', () => { 62 | let fetched = kubernetes.get("Endpoints", name, ns) 63 | expect(endpoint.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 64 | }) 65 | 66 | describe('Remove our Endpoints to cleanup', () => { 67 | kubernetes.delete("Endpoints", name, ns) 68 | }) 69 | }) 70 | 71 | describe('YAML-based resources', () => { 72 | let yamlObject = load(yaml) 73 | const name = yamlObject.metadata.name 74 | const ns = yamlObject.metadata.namespace 75 | 76 | describe('Create our Endpoints using the YAML definition', () => { 77 | kubernetes.apply(yaml) 78 | let created = kubernetes.get("Endpoints", name, ns) 79 | expect(created.metadata, 'new endpoint').to.have.property('uid') 80 | }) 81 | 82 | describe('Remove our Endpoints to cleanup', () => { 83 | kubernetes.delete("Endpoints", name, ns) 84 | }) 85 | }) 86 | 87 | } 88 | -------------------------------------------------------------------------------- /examples/ingress_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from 'k6/x/kubernetes'; 2 | import { describe, expect } from 'https://jslib.k6.io/k6chaijs/4.3.4.3/index.js'; 3 | import { load, dump } from 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs'; 4 | 5 | let json = { 6 | apiVersion: "networking.k8s.io/v1", 7 | kind: "Ingress", 8 | metadata: { 9 | name: "json-ingress", 10 | namespace: "default", 11 | }, 12 | spec: { 13 | ingressClassName: "nginx", 14 | rules: [ 15 | { 16 | http: { 17 | paths: [ 18 | { 19 | path: "/my-service-path", 20 | pathType: "Prefix", 21 | backend: { 22 | service: { 23 | name: "my-service", 24 | port: { 25 | number: 80 26 | } 27 | } 28 | } 29 | } 30 | ] 31 | } 32 | } 33 | ] 34 | } 35 | } 36 | 37 | let yaml = ` 38 | apiVersion: networking.k8s.io/v1 39 | kind: Ingress 40 | metadata: 41 | name: yaml-ingress 42 | namespace: default 43 | spec: 44 | ingressClassName: nginx 45 | rules: 46 | - http: 47 | paths: 48 | - path: /my-service-path 49 | pathType: Prefix 50 | backend: 51 | service: 52 | name: my-service 53 | port: 54 | number: 80 55 | ` 56 | 57 | export default function () { 58 | const kubernetes = new Kubernetes(); 59 | 60 | describe('JSON-based resources', () => { 61 | const name = json.metadata.name 62 | const ns = json.metadata.namespace 63 | 64 | let ingress 65 | 66 | describe('Create our ingress using the JSON definition', () => { 67 | ingress = kubernetes.create(json) 68 | expect(ingress.metadata, 'new ingress').to.have.property('uid') 69 | }) 70 | 71 | describe('Retrieve all available ingresses', () => { 72 | expect(kubernetes.list("Ingress.networking.k8s.io", ns).length, 'total ingresses').to.be.at.least(1) 73 | }) 74 | 75 | describe('Retrieve our ingress by name and namespace', () => { 76 | let fetched = kubernetes.get("Ingress.networking.k8s.io", name, ns) 77 | expect(ingress.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 78 | }) 79 | 80 | describe('Update our ingress with a modified JSON definition', () => { 81 | const newValue = json.spec.rules[0].http.paths[0].path + '-updated' 82 | json.spec.rules[0].http.paths[0].path = newValue 83 | 84 | kubernetes.update(json) 85 | let updated = kubernetes.get("Ingress.networking.k8s.io", name, ns) 86 | expect(updated.spec.rules[0].http.paths[0].path, 'changed value').to.be.equal(newValue) 87 | expect(updated.metadata.generation, 'ingress revision').to.be.at.least(2) 88 | }) 89 | 90 | describe('Remove our ingresses to cleanup', () => { 91 | kubernetes.delete("Ingress.networking.k8s.io", name, ns) 92 | }) 93 | }) 94 | 95 | describe('YAML-based resources', () => { 96 | let yamlObject = load(yaml) 97 | const name = yamlObject.metadata.name 98 | const ns = yamlObject.metadata.namespace 99 | 100 | describe('Create our ingress using the YAML definition', () => { 101 | kubernetes.apply(yaml) 102 | let created = kubernetes.get("Ingress.networking.k8s.io", name, ns) 103 | expect(created.metadata, 'new ingress').to.have.property('uid') 104 | }) 105 | 106 | describe('Update our ingress with a modified YAML definition', () => { 107 | const newValue = yamlObject.spec.rules[0].http.paths[0].path + '-updated' 108 | yamlObject.spec.rules[0].http.paths[0].path = newValue 109 | let newYaml = dump(yamlObject) 110 | 111 | kubernetes.apply(newYaml) 112 | let updated = kubernetes.get("Ingress.networking.k8s.io", name, ns) 113 | expect(updated.spec.rules[0].http.paths[0].path, 'changed value').to.be.equal(newValue) 114 | expect(updated.metadata.generation, 'ingress revision').to.be.at.least(2) 115 | }) 116 | 117 | describe('Remove our ingresses to cleanup', () => { 118 | kubernetes.delete("Ingress.networking.k8s.io", name, ns) 119 | }) 120 | }) 121 | 122 | } 123 | -------------------------------------------------------------------------------- /examples/job_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from 'k6/x/kubernetes'; 2 | import { describe, expect } from 'https://jslib.k6.io/k6chaijs/4.3.4.3/index.js'; 3 | import { load } from 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs'; 4 | 5 | let json = { 6 | apiVersion: "batch/v1", 7 | kind: "Job", 8 | metadata: { 9 | name: "json-job", 10 | namespace: "default", 11 | }, 12 | spec: { 13 | ttlSecondsAfterFinished: 30, 14 | template: { 15 | spec: { 16 | containers: [ 17 | { 18 | name: "myjob", 19 | image: "perl:5.34.0", 20 | command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"], 21 | }, 22 | ], 23 | restartPolicy: "Never", 24 | } 25 | }, 26 | backoffLimit: 4, 27 | } 28 | } 29 | 30 | let yaml = ` 31 | apiVersion: batch/v1 32 | kind: Job 33 | metadata: 34 | name: yaml-job 35 | namespace: default 36 | spec: 37 | ttlSecondsAfterFinished: 30 38 | suspend: false 39 | template: 40 | spec: 41 | containers: 42 | - name: myjob 43 | image: perl:5.34.0 44 | command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"] 45 | restartPolicy: Never 46 | backoffLimit: 4 47 | ` 48 | 49 | export default function () { 50 | const kubernetes = new Kubernetes(); 51 | 52 | describe('JSON-based resources', () => { 53 | const name = json.metadata.name 54 | const ns = json.metadata.namespace 55 | const helpers = kubernetes.helpers(ns) 56 | 57 | let job 58 | 59 | describe('Create our job using the JSON definition and wait until completed', () => { 60 | job = kubernetes.create(json) 61 | expect(job.metadata, 'new job').to.have.property('uid') 62 | 63 | let timeout = 10 64 | expect(helpers.waitJobCompleted(name, timeout), `job completion within ${timeout}s`).to.be.true 65 | 66 | let fetched = kubernetes.get("Job.batch", name, ns) 67 | expect(job.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 68 | }) 69 | 70 | describe('Retrieve all available jobs', () => { 71 | expect(kubernetes.list("Job.batch", ns).length, 'total jobs').to.be.at.least(1) 72 | }) 73 | 74 | describe('Remove our jobs to cleanup', () => { 75 | kubernetes.delete("Job.batch", name, ns) 76 | }) 77 | }) 78 | 79 | describe('YAML-based resources', () => { 80 | let yamlObject = load(yaml) 81 | const name = yamlObject.metadata.name 82 | const ns = yamlObject.metadata.namespace 83 | 84 | describe('Create our job using the YAML definition', () => { 85 | kubernetes.apply(yaml) 86 | let created = kubernetes.get("Job.batch", name, ns) 87 | expect(created.metadata, 'new job').to.have.property('uid') 88 | }) 89 | }) 90 | 91 | } 92 | -------------------------------------------------------------------------------- /examples/namespace_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from 'k6/x/kubernetes'; 2 | import { describe, expect } from 'https://jslib.k6.io/k6chaijs/4.3.4.3/index.js'; 3 | import { load, dump } from 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs'; 4 | 5 | let json = { 6 | apiVersion: "v1", 7 | kind: "Namespace", 8 | metadata: { 9 | name: "json-namespace", 10 | labels: { 11 | "k6.io/created_by": "xk6-kubernetes", 12 | } 13 | } 14 | } 15 | 16 | let yaml = ` 17 | apiVersion: v1 18 | kind: Namespace 19 | metadata: 20 | name: yaml-namespace 21 | labels: 22 | k6.io/created_by: xk6-kubernetes 23 | ` 24 | 25 | export default function () { 26 | const kubernetes = new Kubernetes(); 27 | 28 | describe('JSON-based resources', () => { 29 | const name = json.metadata.name 30 | 31 | let namespace 32 | 33 | describe('Create our Namespace using the JSON definition', () => { 34 | namespace = kubernetes.create(json) 35 | expect(namespace.metadata, 'new namespace').to.have.property('uid') 36 | }) 37 | 38 | describe('Retrieve all available Namespaces', () => { 39 | expect(kubernetes.list("Namespace").length, 'total namespaces').to.be.at.least(1) 40 | }) 41 | 42 | describe('Retrieve our Namespace by name', () => { 43 | let fetched = kubernetes.get("Namespace", name) 44 | expect(namespace.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 45 | }) 46 | 47 | describe('Update our Namespace with a modified JSON definition', () => { 48 | const newValue = "xk6-kubernetes-example" 49 | json.metadata.labels["k6.io/created_by"] = newValue 50 | 51 | kubernetes.update(json) 52 | let updated = kubernetes.get("Namespace", name) 53 | expect(updated.metadata.labels["k6.io/created_by"], 'changed value').to.be.equal(newValue) 54 | }) 55 | 56 | describe('Remove our Namespace to cleanup', () => { 57 | kubernetes.delete("Namespace", name) 58 | }) 59 | }) 60 | 61 | describe('YAML-based resources', () => { 62 | let yamlObject = load(yaml) 63 | const name = yamlObject.metadata.name 64 | 65 | describe('Create our Namespace using the YAML definition', () => { 66 | kubernetes.apply(yaml) 67 | let created = kubernetes.get("Namespace", name) 68 | expect(created.metadata, 'new namespace').to.have.property('uid') 69 | }) 70 | 71 | describe('Update our Namespace with a modified YAML definition', () => { 72 | const newValue = "xk6-kubernetes-example" 73 | yamlObject.metadata.labels["k6.io/created_by"] = newValue 74 | let newYaml = dump(yamlObject) 75 | 76 | kubernetes.apply(newYaml) 77 | let updated = kubernetes.get("Namespace", name) 78 | expect(updated.metadata.labels["k6.io/created_by"], 'changed value').to.be.equal(newValue) 79 | }) 80 | 81 | describe('Remove our Namespace to cleanup', () => { 82 | kubernetes.delete("Namespace", name) 83 | }) 84 | }) 85 | 86 | } 87 | -------------------------------------------------------------------------------- /examples/node_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from "k6/x/kubernetes"; 2 | import { describe, expect } from "https://jslib.k6.io/k6chaijs/4.3.4.3/index.js"; 3 | 4 | export default function () { 5 | const kubernetes = new Kubernetes(); 6 | 7 | let nodes 8 | 9 | describe('Retrieve all available Nodes', () => { 10 | nodes = kubernetes.list("Node") 11 | expect(nodes.length, 'total nodes').to.be.at.least(1) 12 | }) 13 | 14 | describe('Retrieve our Node by name', () => { 15 | let fetched = kubernetes.get("Node", nodes[0].metadata.name) 16 | expect(nodes[0].metadata.uid, 'fetched uids').to.equal(fetched.metadata.uid) 17 | }) 18 | 19 | } 20 | -------------------------------------------------------------------------------- /examples/persistentvolume_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from 'k6/x/kubernetes'; 2 | import { describe, expect } from 'https://jslib.k6.io/k6chaijs/4.3.4.3/index.js'; 3 | import { load, dump } from 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs'; 4 | 5 | let json = { 6 | apiVersion: "v1", 7 | kind: "PersistentVolume", 8 | metadata: { 9 | name: "json-pv", 10 | }, 11 | spec: { 12 | storageClassName: "manual", 13 | capacity: { 14 | storage: "1Mi", 15 | }, 16 | accessModes: [ 17 | "ReadWriteOnce" 18 | ], 19 | hostPath: { 20 | path: "/tmp/k3dvol", 21 | }, 22 | } 23 | } 24 | 25 | let yaml = ` 26 | apiVersion: v1 27 | kind: PersistentVolume 28 | metadata: 29 | name: yaml-pv 30 | spec: 31 | storageClassName: "manual" 32 | capacity: 33 | storage: 1Mi 34 | accessModes: 35 | - ReadWriteOnce 36 | hostPath: 37 | path: /tmp/k3dvol 38 | ` 39 | 40 | export default function () { 41 | const kubernetes = new Kubernetes(); 42 | 43 | describe('JSON-based resources', () => { 44 | const name = json.metadata.name 45 | 46 | let created 47 | 48 | describe('Create our PersistentVolume using the JSON definition', () => { 49 | created = kubernetes.create(json) 50 | expect(created.metadata, 'new persistentvolume').to.have.property('uid') 51 | }) 52 | 53 | describe('Retrieve our PersistentVolume by name', () => { 54 | let fetched = kubernetes.get("PersistentVolume", name) 55 | expect(created.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 56 | }) 57 | 58 | describe('Update our PersistentVolume with a modified JSON definition', () => { 59 | const newValue = "10Mi" 60 | json.spec.capacity.storage = newValue 61 | 62 | kubernetes.update(json) 63 | let updated = kubernetes.get("PersistentVolume", name) 64 | expect(updated.spec.capacity.storage, 'changed value').to.be.equal(newValue) 65 | }) 66 | 67 | describe('Remove our PersistentVolume to cleanup', () => { 68 | kubernetes.delete("PersistentVolume", name) 69 | }) 70 | }) 71 | 72 | describe('YAML-based resources', () => { 73 | let yamlObject = load(yaml) 74 | const name = yamlObject.metadata.name 75 | 76 | describe('Create our PersistentVolume using the YAML definition', () => { 77 | kubernetes.apply(yaml) 78 | let created = kubernetes.get("PersistentVolume", name) 79 | expect(created.metadata, 'new persistentvolume').to.have.property('uid') 80 | }) 81 | 82 | describe('Update our PersistentVolume with a modified YAML definition', () => { 83 | const newValue = "10Mi" 84 | yamlObject.spec.capacity.storage = newValue 85 | let newYaml = dump(yamlObject) 86 | 87 | kubernetes.apply(newYaml) 88 | let updated = kubernetes.get("PersistentVolume", name) 89 | expect(updated.spec.capacity.storage, 'changed value').to.be.equal(newValue) 90 | }) 91 | 92 | describe('Remove our PersistentVolume to cleanup', () => { 93 | kubernetes.delete("PersistentVolume", name) 94 | }) 95 | }) 96 | 97 | } 98 | -------------------------------------------------------------------------------- /examples/persistentvolumeclaim_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from 'k6/x/kubernetes'; 2 | import { describe, expect } from 'https://jslib.k6.io/k6chaijs/4.3.4.3/index.js'; 3 | import { load } from 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs'; 4 | 5 | let json = { 6 | apiVersion: "v1", 7 | kind: "PersistentVolumeClaim", 8 | metadata: { 9 | name: "json-pvc", 10 | namespace: "default", 11 | }, 12 | spec: { 13 | storageClassName: "", 14 | accessModes: ["ReadWriteMany"], 15 | resources: { 16 | requests: { 17 | storage: "10Mi", 18 | } 19 | } 20 | } 21 | } 22 | 23 | let yaml = ` 24 | apiVersion: v1 25 | kind: PersistentVolumeClaim 26 | metadata: 27 | name: yaml-pvc 28 | namespace: default 29 | spec: 30 | storageClassName: "" 31 | accessModes: 32 | - ReadWriteMany 33 | resources: 34 | requests: 35 | storage: "10Mi" 36 | ` 37 | 38 | export default function () { 39 | const kubernetes = new Kubernetes(); 40 | 41 | describe('JSON-based resources', () => { 42 | const name = json.metadata.name 43 | const ns = json.metadata.namespace 44 | 45 | let pvc 46 | 47 | describe('Create our PersistentVolumeClaim using the JSON definition', () => { 48 | pvc = kubernetes.create(json) 49 | expect(pvc.metadata, 'new persistentvolumeclaim').to.have.property('uid') 50 | }) 51 | 52 | describe('Retrieve all available PersistentVolumeClaims', () => { 53 | expect(kubernetes.list("PersistentVolumeClaim", ns).length, 'total persistentvolumeclaims').to.be.at.least(1) 54 | }) 55 | 56 | describe('Retrieve our PersistentVolumeClaim by name', () => { 57 | let fetched = kubernetes.get("PersistentVolumeClaim", name, ns) 58 | expect(pvc.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 59 | }) 60 | 61 | describe('Remove our PersistentVolumeClaim to cleanup', () => { 62 | kubernetes.delete("PersistentVolumeClaim", name, ns) 63 | }) 64 | }) 65 | 66 | describe('YAML-based resources', () => { 67 | let yamlObject = load(yaml) 68 | const name = yamlObject.metadata.name 69 | const ns = yamlObject.metadata.namespace 70 | 71 | describe('Create our PersistentVolumeClaim using the YAML definition', () => { 72 | kubernetes.apply(yaml) 73 | let created = kubernetes.get("PersistentVolumeClaim", name, ns) 74 | expect(created.metadata, 'new persistentvolumeclaim').to.have.property('uid') 75 | }) 76 | 77 | describe('Remove our PersistentVolumeClaim to cleanup', () => { 78 | kubernetes.delete("PersistentVolumeClaim", name, ns) 79 | }) 80 | }) 81 | 82 | } 83 | -------------------------------------------------------------------------------- /examples/pod_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from 'k6/x/kubernetes'; 2 | import { describe, expect } from 'https://jslib.k6.io/k6chaijs/4.3.4.3/index.js'; 3 | import { load, dump } from 'https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs'; 4 | 5 | let json = { 6 | apiVersion: "v1", 7 | kind: "Pod", 8 | metadata: { 9 | name: "json-pod", 10 | namespace: "default" 11 | }, 12 | spec: { 13 | containers: [ 14 | { 15 | name: "busybox", 16 | image: "busybox", 17 | command: ["sh", "-c", "sleep 30"] 18 | } 19 | ] 20 | } 21 | } 22 | 23 | let yaml = ` 24 | apiVersion: v1 25 | kind: Pod 26 | metadata: 27 | name: yaml-pod 28 | namespace: default 29 | spec: 30 | containers: 31 | - name: busybox 32 | image: busybox 33 | command: ["sh", "-c", "sleep 30"] 34 | ` 35 | 36 | export default function () { 37 | const kubernetes = new Kubernetes(); 38 | 39 | describe('JSON-based resources', () => { 40 | const name = json.metadata.name 41 | const ns = json.metadata.namespace 42 | const helpers = kubernetes.helpers(ns) 43 | 44 | let pod 45 | 46 | describe('Create our pod using the JSON definition and wait until running', () => { 47 | pod = kubernetes.create(json) 48 | expect(pod.metadata, 'new pod').to.have.property('uid') 49 | expect(pod.status.phase, 'new pod status').to.equal('Pending') 50 | 51 | helpers.waitPodRunning(name, 10) 52 | 53 | let fetched = kubernetes.get("Pod", name, ns) 54 | expect(fetched.status.phase, 'status after waiting').to.equal('Running') 55 | }) 56 | 57 | describe('Retrieve all available pods', () => { 58 | expect(kubernetes.list("Pod", ns).length, 'total pods').to.be.at.least(1) 59 | }) 60 | 61 | describe('Retrieve our pod by name and namespace, then execute a command within the pod', () => { 62 | let fetched = kubernetes.get("Pod", name, ns) 63 | expect(pod.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 64 | 65 | let greeting = 'hello xk6-kubernetes' 66 | let exec = { 67 | pod: name, 68 | container: fetched.spec.containers[0].name, 69 | command: ["echo", greeting] 70 | } 71 | let result = helpers.executeInPod(exec) 72 | const stdout = String.fromCharCode(...result.stdout) 73 | const stderr = String.fromCharCode(...result.stderr) 74 | expect(stdout, 'execution result').to.contain(greeting) 75 | expect(stderr, 'execution error').to.be.empty 76 | }) 77 | 78 | describe('Remove our pods to cleanup', () => { 79 | kubernetes.delete("Pod", name, ns) 80 | }) 81 | }) 82 | 83 | describe('YAML-based resources', () => { 84 | let yamlObject = load(yaml) 85 | const name = yamlObject.metadata.name 86 | const ns = yamlObject.metadata.namespace 87 | 88 | describe('Create our pod using the YAML definition', () => { 89 | kubernetes.apply(yaml) 90 | let created = kubernetes.get("Pod", name, ns) 91 | expect(created.metadata, 'new pod').to.have.property('uid') 92 | }) 93 | 94 | describe('Update our Pod with a modified YAML definition', () => { 95 | const newValue = "busybox:1.35.0" 96 | yamlObject.spec.containers[0].image = newValue 97 | let newYaml = dump(yamlObject) 98 | 99 | kubernetes.apply(newYaml) 100 | let updated = kubernetes.get("Pod", name, ns) 101 | expect(updated.spec.containers[0].image, 'changed value').to.be.equal(newValue) 102 | }) 103 | 104 | describe('Remove our pod to cleanup', () => { 105 | kubernetes.delete("Pod", name, ns) 106 | }) 107 | }) 108 | 109 | } 110 | -------------------------------------------------------------------------------- /examples/secret_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from "k6/x/kubernetes"; 2 | import { describe, expect } from "https://jslib.k6.io/k6chaijs/4.3.4.3/index.js"; 3 | import { load, dump } from "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs"; 4 | 5 | let json = { 6 | apiVersion: "v1", 7 | kind: "Secret", 8 | metadata: { 9 | name: "json-secret", 10 | namespace: "default", 11 | }, 12 | type: "Opaque", 13 | data: { 14 | mysecret: "dGhlIHNlY3JldCB3b3JkIGlzLi4u", 15 | } 16 | } 17 | 18 | let yaml = ` 19 | apiVersion: v1 20 | kind: Secret 21 | metadata: 22 | name: yaml-secret 23 | namespace: default 24 | type: Opaque 25 | data: 26 | mysecret: dGhlIHNlY3JldCB3b3JkIGlzLi4u 27 | ` 28 | 29 | export default function () { 30 | const kubernetes = new Kubernetes(); 31 | 32 | describe('JSON-based resources', () => { 33 | const name = json.metadata.name 34 | const ns = json.metadata.namespace 35 | 36 | let secret 37 | 38 | describe('Create our Secret using the JSON definition', () => { 39 | secret = kubernetes.create(json) 40 | expect(secret.metadata, 'new secret').to.have.property('uid') 41 | }) 42 | 43 | describe('Retrieve all available Secret', () => { 44 | expect(kubernetes.list("Secret", ns).length, 'total secrets').to.be.at.least(1) 45 | }) 46 | 47 | describe('Retrieve our Secret by name and namespace', () => { 48 | let fetched = kubernetes.get("Secret", name, ns) 49 | expect(secret.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 50 | }) 51 | 52 | describe('Update our Secret with a modified JSON definition', () => { 53 | const newValue = 'bmV3IHNlY3JldCB2YWx1ZQ==' 54 | json.data.mysecret = newValue 55 | 56 | kubernetes.update(json) 57 | let updated = kubernetes.get("Secret", name, ns) 58 | expect(updated.data.mysecret, 'changed value').to.be.equal(newValue) 59 | }) 60 | 61 | describe('Remove our Secret to cleanup', () => { 62 | kubernetes.delete("Secret", name, ns) 63 | }) 64 | }) 65 | 66 | describe('YAML-based resources', () => { 67 | let yamlObject = load(yaml) 68 | const name = yamlObject.metadata.name 69 | const ns = yamlObject.metadata.namespace 70 | 71 | describe('Create our Secret using the YAML definition', () => { 72 | kubernetes.apply(yaml) 73 | let created = kubernetes.get("Secret", name, ns) 74 | expect(created.metadata, 'new secret').to.have.property('uid') 75 | }) 76 | 77 | describe('Update our Secret with a modified YAML definition', () => { 78 | const newValue = 'bmV3IHNlY3JldCB2YWx1ZQ==' 79 | yamlObject.data.mysecret = newValue 80 | let newYaml = dump(yamlObject) 81 | 82 | kubernetes.apply(newYaml) 83 | let updated = kubernetes.get("Secret", name, ns) 84 | expect(updated.data.mysecret, 'changed value').to.be.equal(newValue) 85 | }) 86 | 87 | describe('Remove our Secret to cleanup', () => { 88 | kubernetes.delete("Secret", name, ns) 89 | }) 90 | }) 91 | 92 | } 93 | -------------------------------------------------------------------------------- /examples/service_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from "k6/x/kubernetes"; 2 | import { describe, expect } from "https://jslib.k6.io/k6chaijs/4.3.4.3/index.js"; 3 | import { load, dump } from "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs"; 4 | 5 | let json = { 6 | apiVersion: "v1", 7 | kind: "Service", 8 | metadata: { 9 | name: "json-service", 10 | namespace: "default", 11 | }, 12 | spec: { 13 | selector: { 14 | app: "json-intg-test" 15 | }, 16 | type: "ClusterIP", 17 | ports: [ 18 | { 19 | name: "http", 20 | protocol: "TCP", 21 | port: 80, 22 | targetPort: 80, 23 | } 24 | ] 25 | } 26 | } 27 | 28 | let yaml = ` 29 | apiVersion: v1 30 | kind: Service 31 | metadata: 32 | name: yaml-service 33 | namespace: default 34 | spec: 35 | selector: 36 | app: yaml-intg-test 37 | type: ClusterIP 38 | ports: 39 | - name: http 40 | protocol: TCP 41 | port: 80 42 | targetPort: 80 43 | ` 44 | 45 | export default function () { 46 | const kubernetes = new Kubernetes(); 47 | 48 | describe('JSON-based resources', () => { 49 | const name = json.metadata.name 50 | const ns = json.metadata.namespace 51 | 52 | let service 53 | 54 | describe('Create our Service using the JSON definition', () => { 55 | service = kubernetes.create(json) 56 | expect(service.metadata, 'new service').to.have.property('uid') 57 | }) 58 | 59 | describe('Retrieve all available Services', () => { 60 | expect(kubernetes.list("Service", ns).length, 'total services').to.be.at.least(1) 61 | }) 62 | 63 | describe('Retrieve our Service by name and namespace', () => { 64 | let fetched = kubernetes.get("Service", name, ns) 65 | expect(service.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 66 | }) 67 | 68 | describe('Update our Service with a modified JSON definition', () => { 69 | const newValue = json.spec.selector.app + '-updated' 70 | json.spec.selector.app = newValue 71 | 72 | kubernetes.update(json) 73 | let updated = kubernetes.get("Service", name, ns) 74 | expect(updated.spec.selector.app, 'changed value').to.be.equal(newValue) 75 | }) 76 | 77 | describe('Remove our Service to cleanup', () => { 78 | kubernetes.delete("Service", name, ns) 79 | }) 80 | }) 81 | 82 | describe('YAML-based resources', () => { 83 | let yamlObject = load(yaml) 84 | const name = yamlObject.metadata.name 85 | const ns = yamlObject.metadata.namespace 86 | 87 | describe('Create our Service using the YAML definition', () => { 88 | kubernetes.apply(yaml) 89 | let created = kubernetes.get("Service", name, ns) 90 | expect(created.metadata, 'new service').to.have.property('uid') 91 | }) 92 | 93 | describe('Update our Service with a modified YAML definition', () => { 94 | const newValue = yamlObject.spec.selector.app + '-updated' 95 | yamlObject.spec.selector.app = newValue 96 | let newYaml = dump(yamlObject) 97 | 98 | kubernetes.apply(newYaml) 99 | let updated = kubernetes.get("Service", name, ns) 100 | expect(updated.spec.selector.app, 'changed value').to.be.equal(newValue) 101 | }) 102 | 103 | describe('Remove our Service to cleanup', () => { 104 | kubernetes.delete("Service", name, ns) 105 | }) 106 | }) 107 | 108 | } 109 | -------------------------------------------------------------------------------- /examples/statefulset_operations.js: -------------------------------------------------------------------------------- 1 | import { Kubernetes } from "k6/x/kubernetes"; 2 | import { describe, expect } from "https://jslib.k6.io/k6chaijs/4.3.4.3/index.js"; 3 | import { load, dump } from "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.mjs"; 4 | 5 | let json = { 6 | apiVersion: "apps/v1", 7 | kind: "StatefulSet", 8 | metadata: { 9 | name: "json-statefulset", 10 | namespace: "default", 11 | }, 12 | spec: { 13 | replicas: 1, 14 | selector: { 15 | matchLabels: { 16 | app: "json-intg-test" 17 | } 18 | }, 19 | template: { 20 | metadata: { 21 | labels: { 22 | app: "json-intg-test" 23 | } 24 | }, 25 | spec: { 26 | containers: [ 27 | { 28 | name: "nginx", 29 | image: "nginx:1.14.2", 30 | ports: [ 31 | {containerPort: 80} 32 | ] 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | } 39 | 40 | let yaml = ` 41 | apiVersion: apps/v1 42 | kind: StatefulSet 43 | metadata: 44 | name: yaml-statefulset 45 | namespace: default 46 | spec: 47 | replicas: 1 48 | selector: 49 | matchLabels: 50 | app: yaml-intg-test 51 | template: 52 | metadata: 53 | labels: 54 | app: yaml-intg-test 55 | spec: 56 | containers: 57 | - name: nginx 58 | image: nginx:1.14.2 59 | ports: 60 | - containerPort: 80 61 | ` 62 | 63 | export default function () { 64 | const kubernetes = new Kubernetes(); 65 | 66 | describe('JSON-based resources', () => { 67 | const name = json.metadata.name 68 | const ns = json.metadata.namespace 69 | 70 | let statefulset 71 | 72 | describe('Create our StatefulSet using the JSON definition', () => { 73 | statefulset = kubernetes.create(json) 74 | expect(statefulset.metadata, 'new statefulset').to.have.property('uid') 75 | }) 76 | 77 | describe('Retrieve all available StatefulSets', () => { 78 | expect(kubernetes.list("StatefulSet.apps", ns).length, 'total statefulsets').to.be.at.least(1) 79 | }) 80 | 81 | describe('Retrieve our StatefulSet by name and namespace', () => { 82 | let fetched = kubernetes.get("StatefulSet.apps", name, ns) 83 | expect(statefulset.metadata.uid, 'created and fetched uids').to.equal(fetched.metadata.uid) 84 | }) 85 | 86 | describe('Update our StatefulSet with a modified JSON definition', () => { 87 | const newValue = 2 88 | json.spec.replicas = newValue 89 | 90 | kubernetes.update(json) 91 | let updated = kubernetes.get("StatefulSet.apps", name, ns) 92 | expect(updated.spec.replicas, 'changed value').to.be.equal(newValue) 93 | }) 94 | 95 | describe('Remove our StatefulSet to cleanup', () => { 96 | kubernetes.delete("StatefulSet.apps", name, ns) 97 | }) 98 | }) 99 | 100 | describe('YAML-based resources', () => { 101 | let yamlObject = load(yaml) 102 | const name = yamlObject.metadata.name 103 | const ns = yamlObject.metadata.namespace 104 | 105 | describe('Create our StatefulSet using the YAML definition', () => { 106 | kubernetes.apply(yaml) 107 | let created = kubernetes.get("StatefulSet.apps", name, ns) 108 | expect(created.metadata, 'new statefulset').to.have.property('uid') 109 | }) 110 | 111 | describe('Update our StatefulSet with a modified YAML definition', () => { 112 | const newValue = 2 113 | yamlObject.spec.replicas = newValue 114 | let newYaml = dump(yamlObject) 115 | 116 | kubernetes.apply(newYaml) 117 | let updated = kubernetes.get("StatefulSet.apps", name, ns) 118 | expect(updated.spec.replicas, 'changed value').to.be.equal(newValue) 119 | }) 120 | 121 | describe('Remove our StatefulSet to cleanup', () => { 122 | kubernetes.delete("StatefulSet.apps", name, ns) 123 | }) 124 | }) 125 | 126 | } 127 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/xk6-kubernetes 2 | 3 | go 1.21 4 | 5 | require ( 6 | go.k6.io/k6 v0.51.1-0.20240610082146-1f01a9bc2365 7 | k8s.io/api v0.29.7 8 | k8s.io/apimachinery v0.29.7 9 | k8s.io/client-go v0.29.7 10 | ) 11 | 12 | require ( 13 | github.com/grafana/sobek v0.0.0-20240607083612-4f0cd64f4e78 14 | github.com/sirupsen/logrus v1.9.3 15 | github.com/stretchr/testify v1.9.0 16 | ) 17 | 18 | require ( 19 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/dlclark/regexp2 v1.10.0 // indirect 22 | github.com/dop251/goja v0.0.0-20240516125602-ccbae20bcec2 // indirect 23 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 24 | github.com/evanphx/json-patch v5.7.0+incompatible // indirect 25 | github.com/evanw/esbuild v0.21.2 // indirect 26 | github.com/fatih/color v1.16.0 // indirect 27 | github.com/go-logr/logr v1.4.1 // indirect 28 | github.com/go-logr/stdr v1.2.2 // indirect 29 | github.com/go-openapi/jsonpointer v0.20.0 // indirect 30 | github.com/go-openapi/jsonreference v0.20.2 // indirect 31 | github.com/go-openapi/swag v0.22.4 // indirect 32 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect 33 | github.com/gogo/protobuf v1.3.2 // indirect 34 | github.com/golang/protobuf v1.5.4 // indirect 35 | github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect 36 | github.com/google/gofuzz v1.2.0 // indirect 37 | github.com/google/pprof v0.0.0-20231212022811-ec68065c825e // indirect 38 | github.com/google/uuid v1.6.0 // indirect 39 | github.com/gorilla/websocket v1.5.1 // indirect 40 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect 41 | github.com/imdario/mergo v0.3.16 // indirect 42 | github.com/josharian/intern v1.0.0 // indirect 43 | github.com/json-iterator/go v1.1.12 // indirect 44 | github.com/mailru/easyjson v0.7.7 // indirect 45 | github.com/mattn/go-colorable v0.1.13 // indirect 46 | github.com/mattn/go-isatty v0.0.20 // indirect 47 | github.com/moby/spdystream v0.2.0 // indirect 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 49 | github.com/modern-go/reflect2 v1.0.2 // indirect 50 | github.com/mstoykov/atlas v0.0.0-20220811071828-388f114305dd // indirect 51 | github.com/mstoykov/k6-taskqueue-lib v0.1.0 // indirect 52 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 53 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 54 | github.com/onsi/ginkgo v1.16.5 // indirect 55 | github.com/pkg/errors v0.9.1 // indirect 56 | github.com/pmezard/go-difflib v1.0.0 // indirect 57 | github.com/serenize/snaker v0.0.0-20201027110005-a7ad2135616e // indirect 58 | github.com/spf13/afero v1.11.0 // indirect 59 | github.com/spf13/pflag v1.0.5 // indirect 60 | go.opentelemetry.io/otel v1.24.0 // indirect 61 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect 62 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect 63 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect 64 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 65 | go.opentelemetry.io/otel/sdk v1.24.0 // indirect 66 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 67 | go.opentelemetry.io/proto/otlp v1.1.0 // indirect 68 | golang.org/x/net v0.26.0 // indirect 69 | golang.org/x/oauth2 v0.17.0 // indirect 70 | golang.org/x/sys v0.21.0 // indirect 71 | golang.org/x/term v0.21.0 // indirect 72 | golang.org/x/text v0.16.0 // indirect 73 | golang.org/x/time v0.5.0 // indirect 74 | google.golang.org/appengine v1.6.8 // indirect 75 | google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect 76 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect 77 | google.golang.org/grpc v1.63.2 // indirect 78 | google.golang.org/protobuf v1.33.0 // indirect 79 | gopkg.in/guregu/null.v3 v3.5.0 // indirect 80 | gopkg.in/inf.v0 v0.9.1 // indirect 81 | gopkg.in/yaml.v2 v2.4.0 // indirect 82 | gopkg.in/yaml.v3 v3.0.1 // indirect 83 | k8s.io/klog/v2 v2.120.1 // indirect 84 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 85 | k8s.io/utils v0.0.0-20231127182322-b307cd553661 // indirect 86 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 87 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 88 | sigs.k8s.io/yaml v1.4.0 // indirect 89 | ) 90 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 2 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 3 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 4 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 5 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 6 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= 12 | github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 13 | github.com/dop251/goja v0.0.0-20240516125602-ccbae20bcec2 h1:OFTHt+yJDo/uaIKMGjEKzc3DGhrpQZoqvMUIloZv6ZY= 14 | github.com/dop251/goja v0.0.0-20240516125602-ccbae20bcec2/go.mod h1:o31y53rb/qiIAONF7w3FHJZRqqP3fzHUr1HqanthByw= 15 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 16 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 17 | github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= 18 | github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 19 | github.com/evanw/esbuild v0.21.2 h1:CLplcGi794CfHLVmUbvVfTMKkykm+nyIHU8SU60KUTA= 20 | github.com/evanw/esbuild v0.21.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= 21 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 22 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 23 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 24 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 25 | github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= 26 | github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= 27 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 28 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 29 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 30 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 31 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 32 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 33 | github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= 34 | github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= 35 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 36 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 37 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 38 | github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= 39 | github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 40 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= 41 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 42 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 43 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 44 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 45 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 46 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 47 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 48 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 49 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 50 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 51 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 52 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 53 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 54 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 55 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 56 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 57 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 58 | github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU= 59 | github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M= 60 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 61 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 62 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 63 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 64 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 65 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 66 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 67 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 68 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 69 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 70 | github.com/google/pprof v0.0.0-20231212022811-ec68065c825e h1:bwOy7hAFd0C91URzMIEBfr6BAz29yk7Qj0cy6S7DJlU= 71 | github.com/google/pprof v0.0.0-20231212022811-ec68065c825e/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= 72 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 73 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 74 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 75 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 76 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 77 | github.com/grafana/sobek v0.0.0-20240607083612-4f0cd64f4e78 h1:rVCZdB+13G+aQoGm3CBVaDGl0uxZxfjvQgEJy4IeHTA= 78 | github.com/grafana/sobek v0.0.0-20240607083612-4f0cd64f4e78/go.mod h1:6ZH0b0iOxyigeTh+/IlGoL0Hd3lVXA94xoXf0ldNgCM= 79 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= 80 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= 81 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 82 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 83 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 84 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 85 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 86 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 87 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 88 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 89 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 90 | github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= 91 | github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 92 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 93 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 94 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 95 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 96 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 97 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 98 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 99 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 100 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 101 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 102 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 103 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 104 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 105 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 106 | github.com/mccutchen/go-httpbin v1.1.2-0.20190116014521-c5cb2f4802fa h1:lx8ZnNPwjkXSzOROz0cg69RlErRXs+L3eDkggASWKLo= 107 | github.com/mccutchen/go-httpbin v1.1.2-0.20190116014521-c5cb2f4802fa/go.mod h1:fhpOYavp5g2K74XDl/ao2y4KvhqVtKlkg1e+0UaQv7I= 108 | github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= 109 | github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= 110 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 111 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 112 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 113 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 114 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 115 | github.com/mstoykov/atlas v0.0.0-20220811071828-388f114305dd h1:AC3N94irbx2kWGA8f/2Ks7EQl2LxKIRQYuT9IJDwgiI= 116 | github.com/mstoykov/atlas v0.0.0-20220811071828-388f114305dd/go.mod h1:9vRHVuLCjoFfE3GT06X0spdOAO+Zzo4AMjdIwUHBvAk= 117 | github.com/mstoykov/envconfig v1.5.0 h1:E2FgWf73BQt0ddgn7aoITkQHmgwAcHup1s//MsS5/f8= 118 | github.com/mstoykov/envconfig v1.5.0/go.mod h1:vk/d9jpexY2Z9Bb0uB4Ndesss1Sr0Z9ZiGUrg5o9VGk= 119 | github.com/mstoykov/k6-taskqueue-lib v0.1.0 h1:M3eww1HSOLEN6rIkbNOJHhOVhlqnqkhYj7GTieiMBz4= 120 | github.com/mstoykov/k6-taskqueue-lib v0.1.0/go.mod h1:PXdINulapvmzF545Auw++SCD69942FeNvUztaa9dVe4= 121 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 122 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 123 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= 124 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= 125 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 126 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 127 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 128 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 129 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 130 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 131 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 132 | github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= 133 | github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= 134 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 135 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 136 | github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= 137 | github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= 138 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 139 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 140 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 141 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 142 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 143 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 144 | github.com/serenize/snaker v0.0.0-20201027110005-a7ad2135616e h1:zWKUYT07mGmVBH+9UgnHXd/ekCK99C8EbDSAt5qsjXE= 145 | github.com/serenize/snaker v0.0.0-20201027110005-a7ad2135616e/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs= 146 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 147 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 148 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 149 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 150 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 151 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 152 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 153 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 154 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 155 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 156 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 157 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 158 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 159 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 160 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 161 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 162 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 163 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 164 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 165 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 166 | go.k6.io/k6 v0.51.1-0.20240610082146-1f01a9bc2365 h1:ZXlJs5hXt1hbY4k3jHVJS8xrgypgTZAwbMBVH1EMCgY= 167 | go.k6.io/k6 v0.51.1-0.20240610082146-1f01a9bc2365/go.mod h1:LJKmFwUODAYoxitsJ3Xk+wsyVJDpyQiLyJAVn+oGyVQ= 168 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= 169 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 170 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= 171 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= 172 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE= 173 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0/go.mod h1:CQNu9bj7o7mC6U7+CA/schKEYakYXWr79ucDHTMGhCM= 174 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= 175 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= 176 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= 177 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= 178 | go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= 179 | go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= 180 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= 181 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 182 | go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= 183 | go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= 184 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 185 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 186 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 187 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 188 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 189 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 190 | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= 191 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 192 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 193 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 194 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 195 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 196 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 197 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 198 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 199 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 200 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 201 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 202 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 203 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 204 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 205 | golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= 206 | golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= 207 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 208 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 209 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 210 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 211 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 212 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 213 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 214 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 215 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 216 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 217 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 218 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 219 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 220 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 221 | golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 222 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 223 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 224 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 225 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 226 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 227 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 228 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 229 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 230 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 231 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 232 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 233 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 234 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 235 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 236 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 237 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 238 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 239 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 240 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 241 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 242 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 243 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 244 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 245 | golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 246 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 247 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 248 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 249 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 250 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 251 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 252 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 253 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 254 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 255 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 256 | google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= 257 | google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= 258 | google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= 259 | google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= 260 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= 261 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= 262 | google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= 263 | google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= 264 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 265 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 266 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 267 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 268 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 269 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 270 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 271 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 272 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 273 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 274 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 275 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 276 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 277 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 278 | gopkg.in/guregu/null.v3 v3.5.0 h1:xTcasT8ETfMcUHn0zTvIYtQud/9Mx5dJqD554SZct0o= 279 | gopkg.in/guregu/null.v3 v3.5.0/go.mod h1:E4tX2Qe3h7QdL+uZ3a0vqvYwKQsRSQKM5V4YltdgH9Y= 280 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 281 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 282 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 283 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 284 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 285 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 286 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 287 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 288 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 289 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 290 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 291 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 292 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 293 | k8s.io/api v0.29.7 h1:Q2/thp7YYESgy0MGzxT9RvA/6doLJHBXSFH8GGLxSbc= 294 | k8s.io/api v0.29.7/go.mod h1:mPimdbyuIjwoLtBEVIGVUYb4BKOE+44XHt/n4IqKsLA= 295 | k8s.io/apimachinery v0.29.7 h1:ICXzya58Q7hyEEfnTrbmdfX1n1schSepX2KUfC2/ykc= 296 | k8s.io/apimachinery v0.29.7/go.mod h1:i3FJVwhvSp/6n8Fl4K97PJEP8C+MM+aoDq4+ZJBf70Y= 297 | k8s.io/client-go v0.29.7 h1:vTtiFrGBKlcBhxaeZC4eDrqui1e108nsTyue/KU63IY= 298 | k8s.io/client-go v0.29.7/go.mod h1:69BvVqdRozgR/9TP45u/oO0tfrdbP+I8RqrcCJQshzg= 299 | k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= 300 | k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 301 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= 302 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= 303 | k8s.io/utils v0.0.0-20231127182322-b307cd553661 h1:FepOBzJ0GXm8t0su67ln2wAZjbQ6RxQGZDnzuLcrUTI= 304 | k8s.io/utils v0.0.0-20231127182322-b307cd553661/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 305 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 306 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 307 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 308 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 309 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 310 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 311 | -------------------------------------------------------------------------------- /internal/testutils/fake.go: -------------------------------------------------------------------------------- 1 | // Package testutils provides specialized resources for use with unit testing 2 | package testutils 3 | 4 | import ( 5 | "fmt" 6 | 7 | k8s "k8s.io/client-go/kubernetes" 8 | 9 | "k8s.io/apimachinery/pkg/api/meta" 10 | "k8s.io/apimachinery/pkg/runtime" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | dynamicfake "k8s.io/client-go/dynamic/fake" 13 | "k8s.io/client-go/kubernetes/fake" 14 | ) 15 | 16 | // NewFakeClientset creates a new instance of a fake Kubernetes clientset 17 | func NewFakeClientset(objs ...runtime.Object) k8s.Interface { 18 | return fake.NewSimpleClientset(objs...) 19 | } 20 | 21 | // NewFakeDynamic creates a new instance of a fake dynamic client with a default scheme 22 | func NewFakeDynamic(objs ...runtime.Object) (*dynamicfake.FakeDynamicClient, error) { 23 | scheme := runtime.NewScheme() 24 | err := fake.AddToScheme(scheme) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return dynamicfake.NewSimpleDynamicClient(scheme, objs...), nil 30 | } 31 | 32 | // FakeRESTMapper provides a basic RESTMapper for use with testing 33 | type FakeRESTMapper struct { 34 | meta.RESTMapper 35 | } 36 | 37 | // RESTMapping provides information needed to deal with supported REST resources 38 | func (f *FakeRESTMapper) RESTMapping(gk schema.GroupKind, _ ...string) (*meta.RESTMapping, error) { 39 | kindMapping := map[string]schema.GroupVersionResource{ 40 | "ConfigMap": {Group: "", Version: "v1", Resource: "configmaps"}, 41 | "Deployment": {Group: "apps", Version: "v1", Resource: "deployments"}, 42 | "Endpoints": {Group: "", Version: "v1", Resource: "endpoints"}, 43 | "Ingress": {Group: "networking.k8s.io", Version: "v1", Resource: "ingresses"}, 44 | "Job": {Group: "batch", Version: "v1", Resource: "jobs"}, 45 | "PersistentVolume": {Group: "", Version: "v1", Resource: "persistentvolumes"}, 46 | "PersistentVolumeClaim": {Group: "", Version: "v1", Resource: "persistentvolumeclaims"}, 47 | "Pod": {Group: "", Version: "v1", Resource: "pods"}, 48 | "Namespace": {Group: "", Version: "v1", Resource: "namespaces"}, 49 | "Node": {Group: "", Version: "v1", Resource: "nodes"}, 50 | "Secret": {Group: "", Version: "v1", Resource: "secrets"}, 51 | "Service": {Group: "", Version: "v1", Resource: "services"}, 52 | "StatefulSet": {Group: "apps", Version: "v1", Resource: "statefulsets"}, 53 | } 54 | 55 | gvr, found := kindMapping[gk.Kind] 56 | if !found { 57 | return nil, fmt.Errorf("unknown kind: '%s'", gk.Kind) 58 | } 59 | scope := meta.RESTScopeNamespace 60 | if gk.Kind == "Namespace" || gk.Kind == "Node" { 61 | scope = meta.RESTScopeRoot 62 | } 63 | 64 | return &meta.RESTMapping{ 65 | Resource: gvr, 66 | GroupVersionKind: gvr.GroupVersion().WithKind(gk.Kind), 67 | Scope: scope, 68 | }, nil 69 | } 70 | -------------------------------------------------------------------------------- /kubernetes.go: -------------------------------------------------------------------------------- 1 | // Package kubernetes provides the xk6 Modules implementation for working with Kubernetes resources using Javascript 2 | package kubernetes 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "path/filepath" 9 | 10 | "github.com/grafana/sobek" 11 | "go.k6.io/k6/js/common" 12 | "k8s.io/client-go/rest" 13 | 14 | "github.com/grafana/xk6-kubernetes/pkg/api" 15 | 16 | "go.k6.io/k6/js/modules" 17 | "k8s.io/apimachinery/pkg/api/meta" 18 | metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" 19 | "k8s.io/client-go/dynamic" 20 | "k8s.io/client-go/kubernetes" 21 | _ "k8s.io/client-go/plugin/pkg/client/auth" // Required for access to GKE and AKS 22 | "k8s.io/client-go/tools/clientcmd" 23 | "k8s.io/client-go/util/homedir" 24 | ) 25 | 26 | func init() { 27 | modules.Register("k6/x/kubernetes", new(RootModule)) 28 | } 29 | 30 | // RootModule is the global module object type. It is instantiated once per test 31 | // run and will be used to create `k6/x/kubernetes` module instances for each VU. 32 | type RootModule struct{} 33 | 34 | // ModuleInstance represents an instance of the JS module. 35 | type ModuleInstance struct { 36 | vu modules.VU 37 | // clientset enables injection of a pre-configured Kubernetes environment for unit tests 38 | clientset kubernetes.Interface 39 | // dynamic enables injection of a fake dynamic client for unit tests 40 | dynamic dynamic.Interface 41 | // mapper enables injection of a fake RESTMapper for unit tests 42 | mapper meta.RESTMapper 43 | } 44 | 45 | // Kubernetes is the exported object used within JavaScript. 46 | type Kubernetes struct { 47 | api.Kubernetes 48 | client kubernetes.Interface 49 | metaOptions metaV1.ListOptions 50 | ctx context.Context 51 | } 52 | 53 | // KubeConfig represents the initialization settings for the kubernetes api client. 54 | type KubeConfig struct { 55 | ConfigPath string 56 | Server string 57 | Token string 58 | } 59 | 60 | // Ensure the interfaces are implemented correctly. 61 | var ( 62 | _ modules.Module = &RootModule{} 63 | _ modules.Instance = &ModuleInstance{} 64 | ) 65 | 66 | // NewModuleInstance implements the modules.Module interface to return 67 | // a new instance for each VU. 68 | func (*RootModule) NewModuleInstance(vu modules.VU) modules.Instance { 69 | return &ModuleInstance{ 70 | vu: vu, 71 | } 72 | } 73 | 74 | // Exports implements the modules.Instance interface and returns the exports 75 | // of the JS module. 76 | func (mi *ModuleInstance) Exports() modules.Exports { 77 | return modules.Exports{ 78 | Named: map[string]interface{}{ 79 | "Kubernetes": mi.newClient, 80 | }, 81 | } 82 | } 83 | 84 | func (mi *ModuleInstance) newClient(c sobek.ConstructorCall) *sobek.Object { 85 | rt := mi.vu.Runtime() 86 | ctx := mi.vu.Context() 87 | 88 | obj := &Kubernetes{} 89 | var config *rest.Config 90 | 91 | // if clientset was not injected for unit testing 92 | if mi.clientset == nil { 93 | var options KubeConfig 94 | err := rt.ExportTo(c.Argument(0), &options) 95 | if err != nil { 96 | common.Throw(rt, 97 | fmt.Errorf("Kubernetes constructor expects KubeConfig as it's argument: %w", err)) 98 | } 99 | config, err = getClientConfig(options) 100 | if err != nil { 101 | common.Throw(rt, err) 102 | } 103 | clientset, err := kubernetes.NewForConfig(config) 104 | if err != nil { 105 | common.Throw(rt, err) 106 | } 107 | obj.client = clientset 108 | } else { 109 | // Pre-configured clientset is being injected for unit testing 110 | obj.client = mi.clientset 111 | } 112 | 113 | // If dynamic client was not injected for unit testing 114 | // It is assumed rest config is set 115 | if mi.dynamic == nil { 116 | k8s, err := api.NewFromConfig( 117 | api.KubernetesConfig{ 118 | Clientset: obj.client, 119 | Config: config, 120 | Context: ctx, 121 | }, 122 | ) 123 | if err != nil { 124 | common.Throw(rt, err) 125 | } 126 | obj.Kubernetes = k8s 127 | } else { 128 | // Pre-configured dynamic client and RESTMapper are injected for unit testing 129 | k8s, err := api.NewFromConfig( 130 | api.KubernetesConfig{ 131 | Clientset: obj.client, 132 | Client: mi.dynamic, 133 | Mapper: mi.mapper, 134 | Context: ctx, 135 | }, 136 | ) 137 | if err != nil { 138 | common.Throw(rt, err) 139 | } 140 | obj.Kubernetes = k8s 141 | } 142 | 143 | obj.metaOptions = metaV1.ListOptions{} 144 | obj.ctx = ctx 145 | 146 | return rt.ToValue(obj).ToObject(rt) 147 | } 148 | 149 | func getClientConfig(options KubeConfig) (*rest.Config, error) { 150 | // If server and token are provided, use them 151 | if options.Server != "" && options.Token != "" { 152 | return &rest.Config{ 153 | Host: options.Server, 154 | BearerToken: options.Token, 155 | TLSClientConfig: rest.TLSClientConfig{ 156 | Insecure: true, 157 | }, 158 | }, nil 159 | } 160 | 161 | // If server and token are not provided, use kubeconfig 162 | kubeconfig := options.ConfigPath 163 | if kubeconfig == "" { 164 | // are we in-cluster? 165 | config, err := rest.InClusterConfig() 166 | if err == nil { 167 | return config, nil 168 | } 169 | // we aren't in-cluster 170 | home := homedir.HomeDir() 171 | if home == "" { 172 | return nil, errors.New("home directory not found") 173 | } 174 | kubeconfig = filepath.Join(home, ".kube", "config") 175 | } 176 | return clientcmd.BuildConfigFromFlags("", kubeconfig) 177 | } 178 | -------------------------------------------------------------------------------- /kubernetes_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "testing" 7 | 8 | "github.com/grafana/sobek" 9 | localutils "github.com/grafana/xk6-kubernetes/internal/testutils" 10 | "github.com/sirupsen/logrus" 11 | "github.com/stretchr/testify/require" 12 | "go.k6.io/k6/js/common" 13 | "go.k6.io/k6/js/modulestest" 14 | "go.k6.io/k6/lib" 15 | "go.k6.io/k6/metrics" 16 | "k8s.io/apimachinery/pkg/runtime" 17 | ) 18 | 19 | // setupTestEnv should be called from each test to build the execution environment for the test 20 | func setupTestEnv(t *testing.T, objs ...runtime.Object) *sobek.Runtime { 21 | rt := sobek.New() 22 | rt.SetFieldNameMapper(common.FieldNameMapper{}) 23 | 24 | testLog := logrus.New() 25 | testLog.SetOutput(io.Discard) 26 | 27 | state := &lib.State{ 28 | Options: lib.Options{ 29 | SystemTags: metrics.NewSystemTagSet(metrics.TagVU), 30 | }, 31 | Logger: testLog, 32 | Tags: lib.NewVUStateTags(metrics.NewRegistry().RootTagSet()), 33 | } 34 | 35 | root := &RootModule{} 36 | m, ok := root.NewModuleInstance( 37 | &modulestest.VU{ 38 | RuntimeField: rt, 39 | InitEnvField: &common.InitEnvironment{}, 40 | CtxField: context.Background(), 41 | StateField: state, 42 | }, 43 | ).(*ModuleInstance) 44 | require.True(t, ok) 45 | require.NoError(t, rt.Set("Kubernetes", m.Exports().Named["Kubernetes"])) 46 | 47 | m.clientset = localutils.NewFakeClientset(objs...) 48 | 49 | dynamic, err := localutils.NewFakeDynamic() 50 | if err != nil { 51 | t.Errorf("unexpected error creating fake client %v", err) 52 | } 53 | m.dynamic = dynamic 54 | m.mapper = &localutils.FakeRESTMapper{} 55 | 56 | return rt 57 | } 58 | 59 | // TestGenericApiIsScriptable runs through creating, getting, listing and deleting an object 60 | func TestGenericApiIsScriptable(t *testing.T) { 61 | t.Parallel() 62 | 63 | rt := setupTestEnv(t) 64 | 65 | _, err := rt.RunString(` 66 | const k8s = new Kubernetes() 67 | 68 | const podSpec = { 69 | apiVersion: "v1", 70 | kind: "Pod", 71 | metadata: { 72 | name: "busybox", 73 | namespace: "testns" 74 | }, 75 | spec: { 76 | containers: [ 77 | { 78 | name: "busybox", 79 | image: "busybox", 80 | command: ["sh", "-c", "sleep 30"] 81 | } 82 | ] 83 | } 84 | } 85 | 86 | var created = k8s.create(podSpec) 87 | 88 | var pod = k8s.get(podSpec.kind, podSpec.metadata.name, podSpec.metadata.namespace) 89 | if (podSpec.metadata.name != pod.metadata.name) { 90 | throw new Error("Fetch by name did not return the Service. Expected: " + podSpec.metadata.name + " but got: " + fetched.name) 91 | } 92 | 93 | const pods = k8s.list(podSpec.kind, podSpec.metadata.namespace) 94 | if (pods === undefined || pods.length < 1) { 95 | throw new Error("Expected listing with 1 Pod") 96 | } 97 | 98 | k8s.delete(podSpec.kind, podSpec.metadata.name, podSpec.metadata.namespace) 99 | if (k8s.list(podSpec.kind, podSpec.metadata.namespace).length != 0) { 100 | throw new Error("Deletion failed to remove pod") 101 | } 102 | `) 103 | require.NoError(t, err) 104 | } 105 | 106 | // TestHelpersAreScriptable runs helpers 107 | func TestHelpersAreScriptable(t *testing.T) { 108 | t.Parallel() 109 | 110 | rt := setupTestEnv(t) 111 | 112 | _, err := rt.RunString(` 113 | const k8s = new Kubernetes() 114 | 115 | let pod = { 116 | apiVersion: "v1", 117 | kind: "Pod", 118 | metadata: { 119 | name: "busybox", 120 | namespace: "default" 121 | }, 122 | spec: { 123 | containers: [ 124 | { 125 | name: "busybox", 126 | image: "busybox", 127 | command: ["sh", "-c", "sleep 30"] 128 | } 129 | ] 130 | }, 131 | status: { 132 | phase: "Running" 133 | } 134 | } 135 | 136 | // create pod in test namespace 137 | k8s.create(pod) 138 | 139 | // get helpers for test namespace 140 | const helpers = k8s.helpers() 141 | 142 | // wait for pod to be running 143 | if (!helpers.waitPodRunning(pod.metadata.name, 5)) { 144 | throw new Error("should not timeout") 145 | } 146 | `) 147 | require.NoError(t, err) 148 | } 149 | -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | // Package api implements helper functions for manipulating resources in a 2 | // Kubernetes cluster. 3 | package api 4 | 5 | import ( 6 | "context" 7 | 8 | k8s "k8s.io/client-go/kubernetes" 9 | 10 | "github.com/grafana/xk6-kubernetes/pkg/helpers" 11 | "github.com/grafana/xk6-kubernetes/pkg/resources" 12 | 13 | "k8s.io/apimachinery/pkg/api/meta" 14 | "k8s.io/client-go/discovery" 15 | "k8s.io/client-go/discovery/cached/memory" 16 | "k8s.io/client-go/dynamic" 17 | "k8s.io/client-go/rest" 18 | "k8s.io/client-go/restmapper" 19 | ) 20 | 21 | // Kubernetes defines an interface that extends kubernetes interface[k8s.io/client-go/kubernetes.Interface] adding 22 | // generic functions that operate on any kind of object 23 | type Kubernetes interface { 24 | resources.UnstructuredOperations 25 | // Helpers returns helpers for the given namespace. If none is specified, "default" is used 26 | Helpers(namespace string) helpers.Helpers 27 | } 28 | 29 | // KubernetesConfig defines the configuration for creating a Kubernetes instance 30 | type KubernetesConfig struct { 31 | // Context for executing kubernetes operations 32 | Context context.Context 33 | // kubernetes rest config 34 | Config *rest.Config 35 | // Clientset provides access to various API-specific clients 36 | Clientset k8s.Interface 37 | // Client is a pre-configured dynamic client. If provided, the rest config is not used 38 | Client dynamic.Interface 39 | // Mapper is a pre-configured RESTMapper. If provided, the rest config is not used 40 | Mapper meta.RESTMapper 41 | } 42 | 43 | // kubernetes holds references to implementation of the Kubernetes interface 44 | type kubernetes struct { 45 | ctx context.Context 46 | Clientset k8s.Interface 47 | *resources.Client 48 | Config *rest.Config 49 | *restmapper.DeferredDiscoveryRESTMapper 50 | } 51 | 52 | // NewFromConfig returns a Kubernetes instance 53 | func NewFromConfig(c KubernetesConfig) (Kubernetes, error) { 54 | var ( 55 | err error 56 | discoveryClient *discovery.DiscoveryClient 57 | ) 58 | 59 | ctx := c.Context 60 | if ctx == nil { 61 | ctx = context.TODO() 62 | } 63 | 64 | var client *resources.Client 65 | if c.Client != nil { 66 | client = resources.NewFromClient(ctx, c.Client).WithMapper(c.Mapper) 67 | } else { 68 | client, err = resources.NewFromConfig(ctx, c.Config) 69 | if err != nil { 70 | return nil, err 71 | } 72 | } 73 | 74 | if c.Mapper == nil { 75 | discoveryClient, err = discovery.NewDiscoveryClientForConfig(c.Config) 76 | mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)) 77 | if err != nil { 78 | return nil, err 79 | } 80 | client.WithMapper(mapper) 81 | } 82 | 83 | return &kubernetes{ 84 | ctx: ctx, 85 | Clientset: c.Clientset, 86 | Client: client, 87 | Config: c.Config, 88 | }, nil 89 | } 90 | 91 | func (k *kubernetes) Helpers(namespace string) helpers.Helpers { 92 | if namespace == "" { 93 | namespace = "default" 94 | } 95 | return helpers.NewHelper( 96 | k.ctx, 97 | k.Clientset, 98 | k.Client, 99 | k.Config, 100 | namespace, 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /pkg/helpers/helpers.go: -------------------------------------------------------------------------------- 1 | // Package helpers offers functions to simplify dealing with kubernetes resources. 2 | package helpers 3 | 4 | import ( 5 | "context" 6 | 7 | k8s "k8s.io/client-go/kubernetes" 8 | 9 | "github.com/grafana/xk6-kubernetes/pkg/resources" 10 | "k8s.io/client-go/rest" 11 | ) 12 | 13 | // Helpers offers Helper functions grouped by the objects they handle 14 | type Helpers interface { 15 | JobHelper 16 | PodHelper 17 | ServiceHelper 18 | } 19 | 20 | // helpers struct holds the data required by the helpers 21 | type helpers struct { 22 | client *resources.Client 23 | clientset k8s.Interface 24 | config *rest.Config 25 | ctx context.Context 26 | namespace string 27 | } 28 | 29 | // NewHelper creates a set of helper functions on the specified namespace 30 | func NewHelper( 31 | ctx context.Context, 32 | clientset k8s.Interface, 33 | client *resources.Client, 34 | config *rest.Config, 35 | namespace string, 36 | ) Helpers { 37 | return &helpers{ 38 | client: client, 39 | clientset: clientset, 40 | config: config, 41 | ctx: ctx, 42 | namespace: namespace, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/helpers/jobs.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | batchv1 "k8s.io/api/batch/v1" 8 | corev1 "k8s.io/api/core/v1" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/fields" 11 | "k8s.io/apimachinery/pkg/watch" 12 | ) 13 | 14 | // JobHelper defines helper functions for manipulating Jobs 15 | type JobHelper interface { 16 | // WaitJobCompleted waits for the Job to be completed for up to the given timeout (in seconds) and returns 17 | // a boolean indicating if the status was reached. If the job is Failed an error is returned. 18 | WaitJobCompleted(name string, timeout uint) (bool, error) 19 | } 20 | 21 | // isCompleted returns if the job is completed or not. Returns an error if the job is failed. 22 | func isCompleted(job *batchv1.Job) (bool, error) { 23 | for _, condition := range job.Status.Conditions { 24 | if condition.Type == batchv1.JobFailed && condition.Status == corev1.ConditionTrue { 25 | return false, fmt.Errorf("job failed with reason: %v", condition.Reason) 26 | } 27 | if condition.Type == batchv1.JobComplete && condition.Status == corev1.ConditionTrue { 28 | return true, nil 29 | } 30 | } 31 | return false, nil 32 | } 33 | 34 | func (h *helpers) WaitJobCompleted(name string, timeout uint) (bool, error) { 35 | deadline := time.Duration(timeout) * time.Second 36 | selector := fields.Set{ 37 | "metadata.name": name, 38 | }.AsSelector() 39 | watcher, err := h.clientset.BatchV1().Jobs(h.namespace).Watch( 40 | h.ctx, 41 | metav1.ListOptions{ 42 | FieldSelector: selector.String(), 43 | }, 44 | ) 45 | if err != nil { 46 | return false, err 47 | } 48 | defer watcher.Stop() 49 | 50 | for { 51 | select { 52 | case <-time.After(deadline): 53 | return false, nil 54 | case event := <-watcher.ResultChan(): 55 | if event.Type == watch.Error { 56 | return false, fmt.Errorf("error watching for job: %v", event.Object) 57 | } 58 | if event.Type == watch.Modified { 59 | job, isJob := event.Object.(*batchv1.Job) 60 | if !isJob { 61 | return false, fmt.Errorf("received unknown object while watching for jobs") 62 | } 63 | completed, jobError := isCompleted(job) 64 | if jobError != nil { 65 | return false, jobError 66 | } 67 | if completed { 68 | return true, nil 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/helpers/jobs_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/grafana/xk6-kubernetes/pkg/resources" 9 | batchv1 "k8s.io/api/batch/v1" 10 | corev1 "k8s.io/api/core/v1" 11 | metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/watch" 13 | "k8s.io/client-go/kubernetes/fake" 14 | k8stest "k8s.io/client-go/testing" 15 | 16 | "github.com/grafana/xk6-kubernetes/internal/testutils" 17 | ) 18 | 19 | const ( 20 | jobName = "test-job" 21 | ) 22 | 23 | func newJob(name string, namespace string) *batchv1.Job { 24 | return &batchv1.Job{ 25 | TypeMeta: metaV1.TypeMeta{ 26 | APIVersion: "batch/v1", 27 | Kind: "Job", 28 | }, 29 | ObjectMeta: metaV1.ObjectMeta{ 30 | Name: name, 31 | Namespace: namespace, 32 | Labels: map[string]string{ 33 | "app": "xk6-kubernetes/unit-test", 34 | }, 35 | }, 36 | Spec: batchv1.JobSpec{ 37 | BackoffLimit: nil, 38 | Template: corev1.PodTemplateSpec{}, 39 | }, 40 | Status: batchv1.JobStatus{ 41 | Conditions: []batchv1.JobCondition{}, 42 | }, 43 | } 44 | } 45 | 46 | func newJobWithStatus(name string, namespace string, status string) *batchv1.Job { 47 | job := newJob(name, namespace) 48 | job.Status.Conditions = []batchv1.JobCondition{ 49 | { 50 | Type: batchv1.JobConditionType(status), 51 | Status: corev1.ConditionTrue, 52 | }, 53 | } 54 | return job 55 | } 56 | 57 | func TestWaitJobCompleted(t *testing.T) { 58 | t.Parallel() 59 | type TestCase struct { 60 | test string 61 | status string 62 | delay time.Duration 63 | expectError bool 64 | expectedResult bool 65 | timeout uint 66 | } 67 | 68 | testCases := []TestCase{ 69 | { 70 | test: "job completed before timeout", 71 | status: "Complete", 72 | delay: 1 * time.Second, 73 | expectError: false, 74 | expectedResult: true, 75 | timeout: 60, 76 | }, 77 | { 78 | test: "timeout waiting for job to complete", 79 | status: "Complete", 80 | delay: 10 * time.Second, 81 | expectError: false, 82 | expectedResult: false, 83 | timeout: 5, 84 | }, 85 | { 86 | test: "job failed before timeout", 87 | status: "Failed", 88 | delay: 1 * time.Second, 89 | expectError: true, 90 | expectedResult: false, 91 | timeout: 60, 92 | }, 93 | } 94 | 95 | for _, tc := range testCases { 96 | tc := tc 97 | t.Run(tc.test, func(t *testing.T) { 98 | t.Parallel() 99 | 100 | clientset, ok := testutils.NewFakeClientset().(*fake.Clientset) 101 | if !ok { 102 | t.Errorf("invalid type assertion") 103 | } 104 | watcher := watch.NewRaceFreeFake() 105 | clientset.PrependWatchReactor("jobs", k8stest.DefaultWatchReactor(watcher, nil)) 106 | 107 | fake, _ := testutils.NewFakeDynamic() 108 | client := resources.NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) 109 | 110 | fixture := NewHelper(context.TODO(), clientset, client, nil, "default") 111 | job := newJob(jobName, "default") 112 | _, err := client.Structured().Create(job) 113 | if err != nil { 114 | t.Errorf("unexpected error: %v", err) 115 | return 116 | } 117 | 118 | go func(tc TestCase) { 119 | time.Sleep(tc.delay) 120 | job = newJobWithStatus(jobName, "default", tc.status) 121 | _, e := client.Structured().Update(job) 122 | if e != nil { 123 | t.Errorf("unexpected error: %v", e) 124 | return 125 | } 126 | watcher.Modify(job) 127 | }(tc) 128 | 129 | result, err := fixture.WaitJobCompleted( 130 | jobName, 131 | tc.timeout, 132 | ) 133 | 134 | if !tc.expectError && err != nil { 135 | t.Errorf("unexpected error: %v", err) 136 | return 137 | } 138 | if tc.expectError && err == nil { 139 | t.Error("expected an error but none returned") 140 | return 141 | } 142 | if result != tc.expectedResult { 143 | t.Errorf("expected result %t but %t returned", tc.expectedResult, result) 144 | return 145 | } 146 | }) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /pkg/helpers/pods.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "time" 7 | 8 | "k8s.io/client-go/kubernetes/scheme" 9 | "k8s.io/client-go/tools/remotecommand" 10 | 11 | "github.com/grafana/xk6-kubernetes/pkg/utils" 12 | corev1 "k8s.io/api/core/v1" 13 | "k8s.io/apimachinery/pkg/api/errors" 14 | ) 15 | 16 | // PodHelper defines helper functions for manipulating Pods 17 | type PodHelper interface { 18 | // ExecuteInPod executes a non-interactive command described in options and returns the stdout and stderr outputs 19 | ExecuteInPod(options PodExecOptions) (*PodExecResult, error) 20 | // WaitPodRunning waits for the Pod to be running for up to given timeout (in seconds) and returns 21 | // a boolean indicating if the status was reached. If the pod is Failed returns error. 22 | WaitPodRunning(name string, timeout uint) (bool, error) 23 | } 24 | 25 | func (h *helpers) WaitPodRunning(name string, timeout uint) (bool, error) { 26 | return utils.Retry(time.Duration(timeout)*time.Second, time.Second, func() (bool, error) { 27 | pod := &corev1.Pod{} 28 | err := h.client.Structured().Get("Pod", name, h.namespace, pod) 29 | if errors.IsNotFound(err) { 30 | return false, nil 31 | } 32 | if err != nil { 33 | return false, err 34 | } 35 | if pod.Status.Phase == corev1.PodFailed { 36 | return false, fmt.Errorf("pod has failed") 37 | } 38 | if pod.Status.Phase == corev1.PodRunning { 39 | return true, nil 40 | } 41 | return false, nil 42 | }) 43 | } 44 | 45 | // PodExecOptions describe the command to be executed and the target container 46 | type PodExecOptions struct { 47 | Pod string // name of the Pod to execute the command in 48 | Container string // name of the container to execute the command in 49 | Command []string // command to be executed with its parameters 50 | Stdin []byte // stdin to be supplied to the command 51 | Timeout uint // number of seconds allowed to wait for completion 52 | } 53 | 54 | // PodExecResult contains the output obtained from the execution of a command 55 | type PodExecResult struct { 56 | Stdout []byte 57 | Stderr []byte 58 | } 59 | 60 | func (h *helpers) ExecuteInPod(options PodExecOptions) (*PodExecResult, error) { 61 | result := PodExecResult{} 62 | _, err := utils.Retry(time.Duration(options.Timeout)*time.Second, time.Second, func() (bool, error) { 63 | req := h.clientset.CoreV1().RESTClient(). 64 | Post(). 65 | Namespace(h.namespace). 66 | Resource("pods"). 67 | Name(options.Pod). 68 | SubResource("exec"). 69 | VersionedParams(&corev1.PodExecOptions{ 70 | Container: options.Container, 71 | Command: options.Command, 72 | Stdin: true, 73 | Stdout: true, 74 | Stderr: true, 75 | }, scheme.ParameterCodec) 76 | 77 | exec, err := remotecommand.NewSPDYExecutor(h.config, "POST", req.URL()) 78 | if err != nil { 79 | return false, err 80 | } 81 | 82 | var stdout, stderr bytes.Buffer 83 | stdin := bytes.NewReader(options.Stdin) 84 | err = exec.StreamWithContext(h.ctx, remotecommand.StreamOptions{ 85 | Stdin: stdin, 86 | Stdout: &stdout, 87 | Stderr: &stderr, 88 | Tty: true, 89 | }) 90 | if err != nil { 91 | return false, err 92 | } 93 | 94 | result = PodExecResult{ 95 | Stdout: stdout.Bytes(), 96 | Stderr: stderr.Bytes(), 97 | } 98 | return true, nil 99 | }) 100 | return &result, err 101 | } 102 | -------------------------------------------------------------------------------- /pkg/helpers/pods_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/grafana/xk6-kubernetes/internal/testutils" 9 | "github.com/grafana/xk6-kubernetes/pkg/resources" 10 | 11 | corev1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | ) 14 | 15 | const ( 16 | podName = "test-pod" 17 | testNamespace = "ns-test" 18 | ) 19 | 20 | func buildPod() corev1.Pod { 21 | return corev1.Pod{ 22 | TypeMeta: metav1.TypeMeta{ 23 | APIVersion: "v1", 24 | Kind: "Pod", 25 | }, 26 | ObjectMeta: metav1.ObjectMeta{ 27 | Name: podName, 28 | Namespace: testNamespace, 29 | }, 30 | Spec: corev1.PodSpec{ 31 | Containers: []corev1.Container{ 32 | { 33 | Name: "busybox", 34 | Image: "busybox", 35 | Command: []string{"sh", "-c", "sleep 300"}, 36 | }, 37 | }, 38 | }, 39 | Status: corev1.PodStatus{ 40 | Phase: corev1.PodPending, 41 | }, 42 | } 43 | } 44 | 45 | func TestPods_Wait(t *testing.T) { 46 | t.Parallel() 47 | type TestCase struct { 48 | test string 49 | status corev1.PodPhase 50 | delay time.Duration 51 | expectError bool 52 | expectedResult bool 53 | timeout uint 54 | } 55 | 56 | testCases := []TestCase{ 57 | { 58 | test: "wait pod running", 59 | delay: 1 * time.Second, 60 | status: corev1.PodRunning, 61 | expectError: false, 62 | expectedResult: true, 63 | timeout: 5, 64 | }, 65 | { 66 | test: "timeout waiting pod running", 67 | status: corev1.PodRunning, 68 | delay: 10 * time.Second, 69 | expectError: false, 70 | expectedResult: false, 71 | timeout: 5, 72 | }, 73 | { 74 | test: "wait failed pod", 75 | status: corev1.PodFailed, 76 | delay: 1 * time.Second, 77 | expectError: true, 78 | expectedResult: false, 79 | timeout: 5, 80 | }, 81 | } 82 | for _, tc := range testCases { 83 | tc := tc 84 | t.Run(tc.test, func(t *testing.T) { 85 | t.Parallel() 86 | fake, _ := testutils.NewFakeDynamic() 87 | client := resources.NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) 88 | clientset := testutils.NewFakeClientset() 89 | h := NewHelper(context.TODO(), clientset, client, nil, testNamespace) 90 | pod := buildPod() 91 | _, err := client.Structured().Create(pod) 92 | if err != nil { 93 | t.Errorf("unexpected error: %v", err) 94 | return 95 | } 96 | 97 | go func(tc TestCase) { 98 | pod.Status.Phase = tc.status 99 | time.Sleep(tc.delay) 100 | _, e := client.Structured().Update(pod) 101 | if e != nil { 102 | t.Errorf("unexpected error: %v", e) 103 | return 104 | } 105 | }(tc) 106 | 107 | result, err := h.WaitPodRunning( 108 | podName, 109 | tc.timeout, 110 | ) 111 | 112 | if !tc.expectError && err != nil { 113 | t.Errorf("unexpected error: %v", err) 114 | return 115 | } 116 | if tc.expectError && err == nil { 117 | t.Error("expected an error but none returned") 118 | return 119 | } 120 | if result != tc.expectedResult { 121 | t.Errorf("expected result %t but %t returned", tc.expectedResult, result) 122 | return 123 | } 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /pkg/helpers/services.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/grafana/xk6-kubernetes/pkg/utils" 8 | 9 | corev1 "k8s.io/api/core/v1" 10 | "k8s.io/apimachinery/pkg/api/errors" 11 | ) 12 | 13 | // ServiceHelper implements functions for dealing with services 14 | type ServiceHelper interface { 15 | // WaitServiceReady waits for the given service to have at least one endpoint available 16 | // or the timeout (in seconds) expires. It returns a boolean indicating if the service is ready 17 | WaitServiceReady(service string, timeout uint) (bool, error) 18 | // GetExternalIP returns one external ip for the given service. If none is assigned after the timeout 19 | // expires, returns an empty address "". 20 | GetExternalIP(service string, timeout uint) (string, error) 21 | } 22 | 23 | func (h *helpers) WaitServiceReady(service string, timeout uint) (bool, error) { 24 | return utils.Retry(time.Duration(timeout)*time.Second, time.Second, func() (bool, error) { 25 | ep := &corev1.Endpoints{} 26 | err := h.client.Structured().Get("Endpoints", service, h.namespace, ep) 27 | if err != nil { 28 | if errors.IsNotFound(err) { 29 | return false, nil 30 | } 31 | return false, fmt.Errorf("failed to access service: %w", err) 32 | } 33 | 34 | for _, subset := range ep.Subsets { 35 | if len(subset.Addresses) > 0 { 36 | return true, nil 37 | } 38 | } 39 | 40 | return false, nil 41 | }) 42 | } 43 | 44 | func (h *helpers) GetExternalIP(service string, timeout uint) (string, error) { 45 | addr := "" 46 | _, err := utils.Retry(time.Duration(timeout)*time.Second, time.Second, func() (bool, error) { 47 | svc := &corev1.Service{} 48 | err := h.client.Structured().Get("Service", service, h.namespace, svc) 49 | if err != nil { 50 | return false, fmt.Errorf("failed to access service: %w", err) 51 | } 52 | 53 | if len(svc.Status.LoadBalancer.Ingress) > 0 { 54 | addr = svc.Status.LoadBalancer.Ingress[0].IP 55 | return true, nil 56 | } 57 | 58 | return false, nil 59 | }) 60 | 61 | return addr, err 62 | } 63 | -------------------------------------------------------------------------------- /pkg/helpers/services_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/grafana/xk6-kubernetes/internal/testutils" 9 | "github.com/grafana/xk6-kubernetes/pkg/resources" 10 | corev1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | ) 14 | 15 | func buildEndpointsWithoutAddresses() *corev1.Endpoints { 16 | return &corev1.Endpoints{ 17 | TypeMeta: metav1.TypeMeta{ 18 | APIVersion: "v1", 19 | Kind: "Endpoints", 20 | }, 21 | ObjectMeta: metav1.ObjectMeta{ 22 | Name: "service", 23 | Namespace: "default", 24 | }, 25 | Subsets: []corev1.EndpointSubset{}, 26 | } 27 | } 28 | 29 | func buildEndpointsWithAddresses() *corev1.Endpoints { 30 | return &corev1.Endpoints{ 31 | TypeMeta: metav1.TypeMeta{ 32 | APIVersion: "v1", 33 | Kind: "Endpoints", 34 | }, 35 | ObjectMeta: metav1.ObjectMeta{ 36 | Name: "service", 37 | Namespace: "default", 38 | }, 39 | Subsets: []corev1.EndpointSubset{ 40 | { 41 | Addresses: []corev1.EndpointAddress{ 42 | { 43 | IP: "1.1.1.1", 44 | }, 45 | }, 46 | }, 47 | }, 48 | } 49 | } 50 | 51 | func buildOtherEndpointsWithAddresses() *corev1.Endpoints { 52 | return &corev1.Endpoints{ 53 | TypeMeta: metav1.TypeMeta{ 54 | APIVersion: "v1", 55 | Kind: "Endpoints", 56 | }, 57 | ObjectMeta: metav1.ObjectMeta{ 58 | Name: "otherservice", 59 | Namespace: "default", 60 | }, 61 | Subsets: []corev1.EndpointSubset{ 62 | { 63 | Addresses: []corev1.EndpointAddress{ 64 | { 65 | IP: "1.1.1.1", 66 | }, 67 | }, 68 | }, 69 | }, 70 | } 71 | } 72 | 73 | func buildEndpointsWithNotReadyAddresses() *corev1.Endpoints { 74 | return &corev1.Endpoints{ 75 | TypeMeta: metav1.TypeMeta{ 76 | APIVersion: "v1", 77 | Kind: "Endpoints", 78 | }, 79 | ObjectMeta: metav1.ObjectMeta{ 80 | Name: "service", 81 | Namespace: "default", 82 | }, 83 | Subsets: []corev1.EndpointSubset{ 84 | { 85 | NotReadyAddresses: []corev1.EndpointAddress{ 86 | { 87 | IP: "1.1.1.1", 88 | }, 89 | }, 90 | }, 91 | }, 92 | } 93 | } 94 | 95 | func buildService() corev1.Service { 96 | return corev1.Service{ 97 | TypeMeta: metav1.TypeMeta{ 98 | APIVersion: "v1", 99 | Kind: "Service", 100 | }, 101 | ObjectMeta: metav1.ObjectMeta{ 102 | Name: "service", 103 | Namespace: "default", 104 | }, 105 | Spec: corev1.ServiceSpec{ 106 | Type: corev1.ServiceTypeLoadBalancer, 107 | }, 108 | Status: corev1.ServiceStatus{ 109 | LoadBalancer: corev1.LoadBalancerStatus{ 110 | Ingress: []corev1.LoadBalancerIngress{}, 111 | }, 112 | }, 113 | } 114 | } 115 | 116 | func Test_WaitServiceReady(t *testing.T) { 117 | t.Parallel() 118 | 119 | type TestCase struct { 120 | test string 121 | delay time.Duration 122 | endpoints *corev1.Endpoints 123 | updated *corev1.Endpoints 124 | expectedValue bool 125 | expectError bool 126 | timeout uint 127 | } 128 | 129 | testCases := []TestCase{ 130 | { 131 | test: "endpoint not created", 132 | endpoints: nil, 133 | updated: nil, 134 | delay: time.Second * 0, 135 | expectedValue: false, 136 | expectError: false, 137 | timeout: 5, 138 | }, 139 | { 140 | test: "endpoint already ready", 141 | endpoints: buildEndpointsWithAddresses(), 142 | updated: nil, 143 | delay: time.Second * 0, 144 | expectedValue: true, 145 | expectError: false, 146 | timeout: 5, 147 | }, 148 | { 149 | test: "wait for endpoint to be ready", 150 | endpoints: buildEndpointsWithoutAddresses(), 151 | updated: buildEndpointsWithAddresses(), 152 | delay: time.Second * 2, 153 | expectedValue: true, 154 | expectError: false, 155 | timeout: 5, 156 | }, 157 | { 158 | test: "not ready addresses", 159 | endpoints: buildEndpointsWithoutAddresses(), 160 | updated: buildEndpointsWithNotReadyAddresses(), 161 | delay: time.Second * 2, 162 | expectedValue: false, 163 | expectError: false, 164 | timeout: 5, 165 | }, 166 | { 167 | test: "timeout waiting for addresses", 168 | endpoints: buildEndpointsWithoutAddresses(), 169 | updated: buildEndpointsWithAddresses(), 170 | delay: time.Second * 10, 171 | expectedValue: false, 172 | expectError: false, 173 | timeout: 5, 174 | }, 175 | { 176 | test: "other endpoint ready", 177 | endpoints: buildOtherEndpointsWithAddresses(), 178 | updated: nil, 179 | delay: time.Second * 10, 180 | expectedValue: false, 181 | expectError: false, 182 | timeout: 5, 183 | }, 184 | } 185 | for _, tc := range testCases { 186 | tc := tc 187 | t.Run(tc.test, func(t *testing.T) { 188 | t.Parallel() 189 | objs := []runtime.Object{} 190 | if tc.endpoints != nil { 191 | objs = append(objs, tc.endpoints) 192 | } 193 | fake, _ := testutils.NewFakeDynamic(objs...) 194 | client := resources.NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) 195 | clientset := testutils.NewFakeClientset() 196 | h := NewHelper(context.TODO(), clientset, client, nil, "default") 197 | 198 | go func(tc TestCase) { 199 | if tc.updated == nil { 200 | return 201 | } 202 | time.Sleep(tc.delay) 203 | 204 | _, e := client.Structured().Update(tc.updated) 205 | if e != nil { 206 | t.Errorf("error updating endpoint: %v", e) 207 | } 208 | }(tc) 209 | 210 | ready, err := h.WaitServiceReady("service", tc.timeout) 211 | if !tc.expectError && err != nil { 212 | t.Errorf("unexpected error: %v", err) 213 | return 214 | } 215 | if tc.expectError && err == nil { 216 | t.Error("expected an error but none returned") 217 | return 218 | } 219 | 220 | if ready != tc.expectedValue { 221 | t.Errorf("invalid value returned expected %t actual %t", tc.expectedValue, ready) 222 | return 223 | } 224 | }) 225 | } 226 | } 227 | 228 | func Test_GetServiceIP(t *testing.T) { 229 | t.Parallel() 230 | 231 | type TestCase struct { 232 | test string 233 | delay time.Duration 234 | updated []corev1.LoadBalancerIngress 235 | expectedValue string 236 | expectError bool 237 | timeout uint 238 | } 239 | 240 | testCases := []TestCase{ 241 | { 242 | test: "wait for ip to be assigned", 243 | updated: []corev1.LoadBalancerIngress{ 244 | { 245 | IP: "1.1.1.1", 246 | }, 247 | }, 248 | delay: time.Second * 2, 249 | expectedValue: "1.1.1.1", 250 | expectError: false, 251 | timeout: 5, 252 | }, 253 | { 254 | test: "timeout waiting for addresses", 255 | updated: []corev1.LoadBalancerIngress{}, 256 | delay: time.Second * 10, 257 | expectedValue: "", 258 | expectError: false, 259 | timeout: 5, 260 | }, 261 | } 262 | 263 | for _, tc := range testCases { 264 | tc := tc 265 | t.Run(tc.test, func(t *testing.T) { 266 | t.Parallel() 267 | 268 | fake, _ := testutils.NewFakeDynamic() 269 | client := resources.NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) 270 | clientset := testutils.NewFakeClientset() 271 | h := NewHelper(context.TODO(), clientset, client, nil, "default") 272 | 273 | svc := buildService() 274 | _, err := client.Structured().Create(svc) 275 | if err != nil { 276 | t.Errorf("unexpected error creating service: %v", err) 277 | return 278 | } 279 | 280 | go func(tc TestCase, svc corev1.Service) { 281 | time.Sleep(tc.delay) 282 | svc.Status.LoadBalancer.Ingress = tc.updated 283 | _, e := client.Structured().Update(svc) 284 | if e != nil { 285 | t.Errorf("error updating service: %v", e) 286 | } 287 | }(tc, svc) 288 | 289 | addr, err := h.GetExternalIP("service", tc.timeout) 290 | if !tc.expectError && err != nil { 291 | t.Errorf("unexpected error: %v", err) 292 | return 293 | } 294 | if tc.expectError && err == nil { 295 | t.Error("expected an error but none returned") 296 | return 297 | } 298 | 299 | if addr != tc.expectedValue { 300 | t.Errorf("invalid value returned expected %s actual %s", tc.expectedValue, addr) 301 | return 302 | } 303 | }) 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /pkg/resources/resources.go: -------------------------------------------------------------------------------- 1 | // Package resources implement the interface for accessing kubernetes resources 2 | package resources 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "reflect" 8 | 9 | "github.com/grafana/xk6-kubernetes/pkg/utils" 10 | 11 | "k8s.io/apimachinery/pkg/api/meta" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | 16 | "k8s.io/apimachinery/pkg/runtime/schema" 17 | "k8s.io/apimachinery/pkg/runtime/serializer/yaml" 18 | "k8s.io/client-go/dynamic" 19 | "k8s.io/client-go/rest" 20 | ) 21 | 22 | // UnstructuredOperations defines generic functions that operate on any kind of Kubernetes object 23 | type UnstructuredOperations interface { 24 | Apply(manifest string) error 25 | Create(obj map[string]interface{}) (map[string]interface{}, error) 26 | Delete(kind string, name string, namespace string) error 27 | Get(kind string, name string, namespace string) (map[string]interface{}, error) 28 | List(kind string, namespace string) ([]map[string]interface{}, error) 29 | Update(obj map[string]interface{}) (map[string]interface{}, error) 30 | } 31 | 32 | // StructuredOperations defines generic operations that handles runtime objects such as corev1.Pod. 33 | // It facilitates handling objects in the situations where their type is known as opposed to the 34 | // UnstructuredOperations 35 | type StructuredOperations interface { 36 | // Create creates a resource described in the runtime object given as input and returns the resource created. 37 | // The resource must be passed by value (e.g corev1.Pod) and a value (not a reference) will be returned 38 | Create(obj interface{}) (interface{}, error) 39 | // Delete deletes a resource given its kind, name and namespace 40 | Delete(kind string, name string, namespace string) error 41 | // Get retrieves a resource into the given placeholder given its kind, name and namespace 42 | Get(kind string, name string, namespace string, obj interface{}) error 43 | // List retrieves a list of resources in the given slice given their kind and namespace 44 | List(kind string, namespace string, list interface{}) error 45 | // Update updates an existing resource and returns the updated version 46 | // The resource must be passed by value (e.g corev1.Pod) and a value (not a reference) will be returned 47 | Update(obj interface{}) (interface{}, error) 48 | } 49 | 50 | // structured holds the 51 | type structured struct { 52 | client *Client 53 | } 54 | 55 | // Client holds the state to access kubernetes 56 | type Client struct { 57 | ctx context.Context 58 | dynamic dynamic.Interface 59 | mapper meta.RESTMapper 60 | serializer runtime.Serializer 61 | } 62 | 63 | // NewFromConfig creates a new Client using the provided kubernetes client configuration 64 | func NewFromConfig(ctx context.Context, config *rest.Config) (*Client, error) { 65 | dynamic, err := dynamic.NewForConfig(config) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return NewFromClient(ctx, dynamic), nil 71 | } 72 | 73 | // NewFromClient creates a new client from a dynamic Kubernetes client 74 | func NewFromClient(ctx context.Context, dynamic dynamic.Interface) *Client { 75 | return &Client{ 76 | ctx: ctx, 77 | dynamic: dynamic, 78 | serializer: yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme), 79 | } 80 | } 81 | 82 | // WithMapper specifies the RESTMapper for the client to utilize 83 | func (c *Client) WithMapper(mapper meta.RESTMapper) *Client { 84 | c.mapper = mapper 85 | return c 86 | } 87 | 88 | // getResource maps kinds to api resources 89 | func (c *Client) getResource(kind string, namespace string, versions ...string) (dynamic.ResourceInterface, error) { 90 | gk := schema.ParseGroupKind(kind) 91 | if c.mapper == nil { 92 | return nil, fmt.Errorf("RESTMapper not initialized") 93 | } 94 | 95 | mapping, err := c.mapper.RESTMapping(gk, versions...) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | var resource dynamic.ResourceInterface 101 | if mapping.Scope.Name() == meta.RESTScopeNameNamespace { 102 | resource = c.dynamic.Resource(mapping.Resource).Namespace(namespace) 103 | } else { 104 | resource = c.dynamic.Resource(mapping.Resource) 105 | } 106 | 107 | return resource, nil 108 | } 109 | 110 | // Apply creates a resource in a kubernetes cluster from a YAML manifest 111 | func (c *Client) Apply(manifest string) error { 112 | uObj := &unstructured.Unstructured{} 113 | _, gvk, err := c.serializer.Decode([]byte(manifest), nil, uObj) 114 | if err != nil { 115 | return fmt.Errorf("failed to decode manifest: %w", err) 116 | } 117 | 118 | name := uObj.GetName() 119 | namespace := uObj.GetNamespace() 120 | if namespace == "" { 121 | namespace = "default" 122 | } 123 | 124 | resource, err := c.getResource(gvk.GroupKind().String(), namespace, gvk.Version) 125 | if err != nil { 126 | return fmt.Errorf("failed to get resource: %w", err) 127 | } 128 | 129 | _, err = resource.Apply( 130 | c.ctx, 131 | name, 132 | uObj, 133 | metav1.ApplyOptions{ 134 | FieldManager: "xk6-kubernetes", 135 | }, 136 | ) 137 | return err 138 | } 139 | 140 | // Create creates a resource in a kubernetes cluster from an object with its specification 141 | func (c *Client) Create(obj map[string]interface{}) (map[string]interface{}, error) { 142 | uObj := &unstructured.Unstructured{ 143 | Object: obj, 144 | } 145 | 146 | gvk := uObj.GroupVersionKind() 147 | namespace := uObj.GetNamespace() 148 | if namespace == "" { 149 | namespace = "default" 150 | } 151 | 152 | resource, err := c.getResource(gvk.GroupKind().String(), namespace) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | resp, err := resource.Create( 158 | c.ctx, 159 | uObj, 160 | metav1.CreateOptions{}, 161 | ) 162 | if err != nil { 163 | return nil, err 164 | } 165 | return resp.UnstructuredContent(), nil 166 | } 167 | 168 | // Get returns an object given its kind, name and namespace 169 | func (c *Client) Get(kind string, name string, namespace string) (map[string]interface{}, error) { 170 | resource, err := c.getResource(kind, namespace) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | resp, err := resource.Get( 176 | c.ctx, 177 | name, 178 | metav1.GetOptions{}, 179 | ) 180 | if err != nil { 181 | return nil, err 182 | } 183 | return resp.UnstructuredContent(), nil 184 | } 185 | 186 | // List returns a list of objects given its kind and namespace 187 | func (c *Client) List(kind string, namespace string) ([]map[string]interface{}, error) { 188 | resource, err := c.getResource(kind, namespace) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | resp, err := resource.List(c.ctx, metav1.ListOptions{}) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | list := []map[string]interface{}{} 199 | for _, uObj := range resp.Items { 200 | list = append(list, uObj.UnstructuredContent()) 201 | } 202 | return list, nil 203 | } 204 | 205 | // Delete deletes an object given its kind, name and namespace 206 | func (c *Client) Delete(kind string, name string, namespace string) error { 207 | resource, err := c.getResource(kind, namespace) 208 | if err != nil { 209 | return err 210 | } 211 | err = resource.Delete(c.ctx, name, metav1.DeleteOptions{}) 212 | 213 | return err 214 | } 215 | 216 | // Update updates a resource in a kubernetes cluster from an object with its specification 217 | func (c *Client) Update(obj map[string]interface{}) (map[string]interface{}, error) { 218 | uObj := &unstructured.Unstructured{ 219 | Object: obj, 220 | } 221 | 222 | gvk := uObj.GroupVersionKind() 223 | namespace := uObj.GetNamespace() 224 | if namespace == "" { 225 | namespace = "default" 226 | } 227 | resource, err := c.getResource(gvk.GroupKind().String(), namespace) 228 | if err != nil { 229 | return nil, err 230 | } 231 | 232 | resp, err := resource.Update( 233 | c.ctx, 234 | uObj, 235 | metav1.UpdateOptions{}, 236 | ) 237 | if err != nil { 238 | return nil, err 239 | } 240 | return resp.UnstructuredContent(), nil 241 | } 242 | 243 | // Structured returns a reference to a StructuredOperations interface 244 | func (c *Client) Structured() StructuredOperations { 245 | return &structured{ 246 | client: c, 247 | } 248 | } 249 | 250 | // Creates a resources defined in the runtime object provided as input 251 | func (s *structured) Create(obj interface{}) (interface{}, error) { 252 | uObj, err := utils.RuntimeToGeneric(&obj) 253 | if err != nil { 254 | return nil, err 255 | } 256 | 257 | created, err := s.client.Create(uObj) 258 | if err != nil { 259 | return nil, err 260 | } 261 | 262 | // create a new object of the same time than one provided as input 263 | result := reflect.New(reflect.TypeOf(obj)) 264 | err = utils.GenericToRuntime(created, result.Interface()) 265 | if err != nil { 266 | return nil, err 267 | } 268 | 269 | return result.Elem().Interface(), nil 270 | } 271 | 272 | func (s *structured) Get(kind string, name string, namespace string, obj interface{}) error { 273 | gObj, err := s.client.Get(kind, name, namespace) 274 | if err != nil { 275 | return err 276 | } 277 | 278 | return utils.GenericToRuntime(gObj, obj) 279 | } 280 | 281 | func (s *structured) Delete(kind string, name string, namespace string) error { 282 | return s.client.Delete(kind, name, namespace) 283 | } 284 | 285 | func (s *structured) List(kind string, namespace string, objList interface{}) error { 286 | objListType := reflect.ValueOf(objList).Elem().Kind().String() 287 | if objListType != reflect.Slice.String() { 288 | return fmt.Errorf("must provide an slice to return results but %s received", objListType) 289 | } 290 | 291 | list, err := s.client.List(kind, namespace) 292 | if err != nil { 293 | return err 294 | } 295 | 296 | // get the type of the elements of the input slice for creating new instanced 297 | // used to convert from the generic structure to the corresponding runtime object 298 | rtList := reflect.ValueOf(objList).Elem() 299 | rtType := reflect.TypeOf(objList).Elem().Elem() 300 | for _, gObj := range list { 301 | rtObj := reflect.New(rtType) 302 | err = utils.GenericToRuntime(gObj, rtObj.Interface()) 303 | if err != nil { 304 | return err 305 | } 306 | 307 | rtList.Set(reflect.Append(rtList, rtObj.Elem())) 308 | } 309 | return nil 310 | } 311 | 312 | func (s *structured) Update(obj interface{}) (interface{}, error) { 313 | uObj, err := utils.RuntimeToGeneric(&obj) 314 | if err != nil { 315 | return nil, err 316 | } 317 | 318 | updated, err := s.client.Update(uObj) 319 | if err != nil { 320 | return nil, err 321 | } 322 | 323 | // create a new object of the same time than one provided as input 324 | result := reflect.New(reflect.TypeOf(obj)) 325 | err = utils.GenericToRuntime(updated, result.Interface()) 326 | if err != nil { 327 | return nil, err 328 | } 329 | 330 | return result.Elem().Interface(), nil 331 | } 332 | -------------------------------------------------------------------------------- /pkg/resources/resources_test.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/grafana/xk6-kubernetes/internal/testutils" 8 | 9 | corev1 "k8s.io/api/core/v1" 10 | "k8s.io/apimachinery/pkg/api/errors" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/apimachinery/pkg/runtime/schema" 15 | ) 16 | 17 | func buildUnstructuredPod() map[string]interface{} { 18 | return map[string]interface{}{ 19 | "apiVersion": "v1", 20 | "kind": "Pod", 21 | "metadata": map[string]interface{}{ 22 | "name": "busybox", 23 | "namespace": "testns", 24 | }, 25 | "spec": map[string]interface{}{ 26 | "containers": []interface{}{ 27 | map[string]interface{}{ 28 | "name": "busybox", 29 | "image": "busybox", 30 | "command": []interface{}{"sh", "-c", "sleep 30"}, 31 | }, 32 | }, 33 | }, 34 | } 35 | } 36 | 37 | func buildUnstructuredNamespace() map[string]interface{} { 38 | return map[string]interface{}{ 39 | "apiVersion": "v1", 40 | "kind": "Namespace", 41 | "metadata": map[string]interface{}{ 42 | "name": "testns", 43 | }, 44 | } 45 | } 46 | 47 | func buildPod() *corev1.Pod { 48 | return &corev1.Pod{ 49 | TypeMeta: metav1.TypeMeta{ 50 | APIVersion: "v1", 51 | Kind: "Pod", 52 | }, 53 | ObjectMeta: metav1.ObjectMeta{ 54 | Name: "busybox", 55 | Namespace: "testns", 56 | }, 57 | Spec: corev1.PodSpec{ 58 | Containers: []corev1.Container{ 59 | { 60 | Name: "busybox", 61 | Image: "busybox", 62 | Command: []string{"sh", "-c", "sleep 30"}, 63 | }, 64 | }, 65 | }, 66 | Status: corev1.PodStatus{ 67 | Phase: corev1.PodPending, 68 | }, 69 | } 70 | } 71 | 72 | func buildNamespace() *corev1.Namespace { 73 | return &corev1.Namespace{ 74 | TypeMeta: metav1.TypeMeta{ 75 | APIVersion: "v1", 76 | Kind: "Namespace", 77 | }, 78 | ObjectMeta: metav1.ObjectMeta{ 79 | Name: "testns", 80 | }, 81 | } 82 | } 83 | 84 | func buildNode() *corev1.Node { 85 | return &corev1.Node{ 86 | TypeMeta: metav1.TypeMeta{ 87 | APIVersion: "v1", 88 | Kind: "Node", 89 | }, 90 | ObjectMeta: metav1.ObjectMeta{ 91 | Name: "node1", 92 | }, 93 | } 94 | } 95 | 96 | func newForTest(objs ...runtime.Object) (*Client, error) { 97 | dynamic, err := testutils.NewFakeDynamic(objs...) 98 | if err != nil { 99 | return nil, err 100 | } 101 | return NewFromClient(context.TODO(), dynamic).WithMapper(&testutils.FakeRESTMapper{}), nil 102 | } 103 | 104 | func TestCreate(t *testing.T) { 105 | t.Parallel() 106 | testCases := []struct { 107 | test string 108 | obj map[string]interface{} 109 | kind string 110 | resource schema.GroupVersionResource 111 | name string 112 | ns string 113 | }{ 114 | { 115 | test: "Create Pod", 116 | obj: buildUnstructuredPod(), 117 | kind: "Pod", 118 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, 119 | name: "busybox", 120 | ns: "testns", 121 | }, 122 | { 123 | test: "Create Namespace", 124 | obj: buildUnstructuredNamespace(), 125 | kind: "Namespace", 126 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}, 127 | name: "testns", 128 | ns: "", 129 | }, 130 | } 131 | 132 | for _, tc := range testCases { 133 | tc := tc 134 | t.Run(tc.test, func(t *testing.T) { 135 | t.Parallel() 136 | fake, _ := testutils.NewFakeDynamic() 137 | c := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) 138 | 139 | created, err := c.Create(tc.obj) 140 | if err != nil { 141 | t.Errorf("failed %v", err) 142 | return 143 | } 144 | 145 | name, found, err := unstructured.NestedString(created, "metadata", "name") 146 | if err != nil { 147 | t.Errorf("error retrieving pod name %v", err) 148 | return 149 | } 150 | 151 | if !found { 152 | t.Errorf("object created has no name field") 153 | return 154 | } 155 | 156 | if name != tc.name { 157 | t.Errorf("wrong object retrieved. Expected %s Received %s", tc.name, name) 158 | return 159 | } 160 | 161 | // check the object was added to the fake client's object tracker 162 | _, err = fake.Tracker().Get(tc.resource, tc.ns, tc.name) 163 | if err != nil { 164 | t.Errorf("error retrieving object %v", err) 165 | return 166 | } 167 | }) 168 | } 169 | } 170 | 171 | func podManifest() string { 172 | return ` 173 | apiVersion: v1 174 | kind: Pod 175 | metadata: 176 | name: busybox 177 | namespace: testns 178 | spec: 179 | containers: 180 | - name: busybox 181 | image: busybox 182 | command: ["sleep", "300"] 183 | ` 184 | } 185 | 186 | func TestApply(t *testing.T) { 187 | // Skip test. see comments on test cases why 188 | t.Skip() 189 | t.Parallel() 190 | testCases := []struct { 191 | test string 192 | manifest string 193 | kind string 194 | name string 195 | ns string 196 | objects []runtime.Object 197 | }{ 198 | // This test case does not work due to https://github.com/kubernetes/client-go/issues/1184 199 | { 200 | test: "Apply: create new pod", 201 | manifest: podManifest(), 202 | kind: "Pod", 203 | name: "busybox", 204 | ns: "testns", 205 | objects: []runtime.Object{}, 206 | }, 207 | // This test case does not work due to https://github.com/kubernetes/client-go/issues/970 208 | { 209 | test: "Apply: existing pod", 210 | manifest: podManifest(), 211 | kind: "Pod", 212 | name: "busybox", 213 | ns: "testns", 214 | objects: []runtime.Object{ 215 | buildPod(), 216 | }, 217 | }, 218 | } 219 | 220 | for _, tc := range testCases { 221 | tc := tc 222 | t.Run(tc.test, func(t *testing.T) { 223 | t.Parallel() 224 | c, err := newForTest(tc.objects...) 225 | if err != nil { 226 | t.Errorf("failed %v", err) 227 | return 228 | } 229 | err = c.Apply(tc.manifest) 230 | if err != nil { 231 | t.Errorf("failed %v", err) 232 | return 233 | } 234 | 235 | obj, err := c.Get(tc.kind, tc.name, tc.ns) 236 | if err != nil { 237 | t.Errorf("failed %v", err) 238 | return 239 | } 240 | if obj == nil { 241 | t.Errorf("invalid value returned") 242 | return 243 | } 244 | }) 245 | } 246 | } 247 | 248 | func TestUpdate(t *testing.T) { 249 | t.Parallel() 250 | 251 | // initialize with pod 252 | obj := buildPod() 253 | c, err := newForTest(obj) 254 | if err != nil { 255 | t.Errorf("failed %v", err) 256 | return 257 | } 258 | 259 | // set the status 260 | pod := buildUnstructuredPod() 261 | pod["status"] = map[string]interface{}{ 262 | "phase": string(corev1.PodFailed), 263 | } 264 | 265 | updated, err := c.Update(pod) 266 | if err != nil { 267 | t.Errorf("failed %v", err) 268 | return 269 | } 270 | 271 | // get the status 272 | status, found, err := unstructured.NestedString(updated, "status", "phase") 273 | if err != nil { 274 | t.Errorf("failed %v", err) 275 | return 276 | } 277 | if !found || status != string(corev1.PodFailed) { 278 | t.Errorf("pod phase was not updated") 279 | } 280 | } 281 | 282 | func TestDelete(t *testing.T) { 283 | t.Parallel() 284 | testCases := []struct { 285 | test string 286 | obj runtime.Object 287 | kind string 288 | resource schema.GroupVersionResource 289 | name string 290 | ns string 291 | }{ 292 | { 293 | test: "Delete Pod", 294 | obj: buildPod(), 295 | kind: "Pod", 296 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, 297 | name: "busybox", 298 | ns: "testns", 299 | }, 300 | { 301 | test: "Delete Namespace", 302 | obj: buildNamespace(), 303 | kind: "Namespace", 304 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}, 305 | name: "testns", 306 | ns: "", 307 | }, 308 | } 309 | 310 | for _, tc := range testCases { 311 | tc := tc 312 | t.Run(tc.test, func(t *testing.T) { 313 | t.Parallel() 314 | 315 | fake, err := testutils.NewFakeDynamic(tc.obj) 316 | if err != nil { 317 | t.Errorf("unexpected error creating fake client %v", err) 318 | return 319 | } 320 | c, err := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}), nil 321 | if err != nil { 322 | t.Errorf("failed %v", err) 323 | return 324 | } 325 | 326 | err = c.Delete(tc.kind, tc.name, tc.ns) 327 | if err != nil { 328 | t.Errorf("failed %v", err) 329 | return 330 | } 331 | 332 | // check the object was added to the fake client's object tracker 333 | _, err = fake.Tracker().Get(tc.resource, tc.ns, tc.name) 334 | if !errors.IsNotFound(err) { 335 | t.Errorf("error retrieving object %v", err) 336 | return 337 | } 338 | }) 339 | } 340 | } 341 | 342 | func TestGet(t *testing.T) { 343 | t.Parallel() 344 | testCases := []struct { 345 | test string 346 | obj runtime.Object 347 | kind string 348 | resource schema.GroupVersionResource 349 | name string 350 | ns string 351 | }{ 352 | { 353 | test: "Get Pod", 354 | obj: buildPod(), 355 | kind: "Pod", 356 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, 357 | name: "busybox", 358 | ns: "testns", 359 | }, 360 | { 361 | test: "Get Namespace", 362 | obj: buildNamespace(), 363 | kind: "Namespace", 364 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}, 365 | name: "testns", 366 | ns: "", 367 | }, 368 | { 369 | test: "Get Node", 370 | obj: buildNode(), 371 | kind: "Node", 372 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "nodes"}, 373 | name: "node1", 374 | ns: "", 375 | }, 376 | } 377 | 378 | for _, tc := range testCases { 379 | tc := tc 380 | t.Run(tc.test, func(t *testing.T) { 381 | t.Parallel() 382 | 383 | fake, err := testutils.NewFakeDynamic(tc.obj) 384 | if err != nil { 385 | t.Errorf("unexpected error creating fake client %v", err) 386 | return 387 | } 388 | c, err := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}), nil 389 | if err != nil { 390 | t.Errorf("failed %v", err) 391 | return 392 | } 393 | 394 | obj, err := c.Get(tc.kind, tc.name, tc.ns) 395 | if err != nil { 396 | t.Errorf("failed %v", err) 397 | return 398 | } 399 | 400 | name, found, err := unstructured.NestedString(obj, "metadata", "name") 401 | if err != nil { 402 | t.Errorf("unexpected error %v", err) 403 | return 404 | } 405 | if !found { 406 | t.Errorf("object does not have field name") 407 | return 408 | } 409 | if name != tc.name { 410 | t.Errorf("invalid pod returned. Expected %s Returned %s", tc.name, name) 411 | return 412 | } 413 | }) 414 | } 415 | } 416 | 417 | func TestList(t *testing.T) { 418 | t.Parallel() 419 | testCases := []struct { 420 | test string 421 | obj runtime.Object 422 | kind string 423 | resource schema.GroupVersionResource 424 | name string 425 | ns string 426 | }{ 427 | { 428 | test: "List Pods", 429 | obj: buildPod(), 430 | kind: "Pod", 431 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, 432 | name: "busybox", 433 | ns: "testns", 434 | }, 435 | { 436 | test: "List Namespace", 437 | obj: buildNamespace(), 438 | kind: "Namespace", 439 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}, 440 | name: "testns", 441 | ns: "", 442 | }, 443 | { 444 | test: "List Nodes", 445 | obj: buildNode(), 446 | kind: "Node", 447 | resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "nodes"}, 448 | name: "node1", 449 | ns: "", 450 | }, 451 | } 452 | 453 | for _, tc := range testCases { 454 | tc := tc 455 | t.Run(tc.test, func(t *testing.T) { 456 | t.Parallel() 457 | 458 | fake, err := testutils.NewFakeDynamic(tc.obj) 459 | if err != nil { 460 | t.Errorf("unexpected error creating fake client %v", err) 461 | return 462 | } 463 | c, err := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}), nil 464 | if err != nil { 465 | t.Errorf("failed %v", err) 466 | return 467 | } 468 | 469 | list, err := c.List(tc.kind, tc.ns) 470 | if err != nil { 471 | t.Errorf("failed %v", err) 472 | return 473 | } 474 | 475 | if len(list) != 1 { 476 | t.Errorf("expect %d %s but %d received", 1, tc.resource.Resource, len(list)) 477 | return 478 | } 479 | }) 480 | } 481 | } 482 | 483 | func TestStructuredCreate(t *testing.T) { 484 | t.Parallel() 485 | 486 | fake, _ := testutils.NewFakeDynamic() 487 | c := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) 488 | 489 | pod := buildPod() 490 | created, err := c.Structured().Create(*pod) 491 | if err != nil { 492 | t.Errorf("failed %v", err) 493 | return 494 | } 495 | 496 | createdPod, ok := created.(corev1.Pod) 497 | if !ok { 498 | t.Errorf("invalid type assertion") 499 | } 500 | if createdPod.Name != pod.Name { 501 | t.Errorf("invalid pod returned. Expected %s Returned %s", pod.Name, createdPod.Name) 502 | return 503 | } 504 | } 505 | 506 | func TestStructuredGet(t *testing.T) { 507 | t.Parallel() 508 | // initialize with pod 509 | initPod := buildPod() 510 | c, err := newForTest(initPod) 511 | if err != nil { 512 | t.Errorf("failed %v", err) 513 | return 514 | } 515 | 516 | pod := &corev1.Pod{} 517 | err = c.Structured().Get("Pod", "busybox", "testns", pod) 518 | if err != nil { 519 | t.Errorf("failed %v", err) 520 | return 521 | } 522 | if pod.Name != initPod.Name { 523 | t.Errorf("invalid pod returned. Expected %s Returned %s", initPod.Name, pod.Name) 524 | return 525 | } 526 | } 527 | 528 | func TestStructuredList(t *testing.T) { 529 | t.Parallel() 530 | // initialize with pod 531 | pod := buildPod() 532 | c, err := newForTest(pod) 533 | if err != nil { 534 | t.Errorf("failed %v", err) 535 | return 536 | } 537 | 538 | podList := []corev1.Pod{} 539 | err = c.Structured().List("Pod", "testns", &podList) 540 | if err != nil { 541 | t.Errorf("failed %v", err) 542 | return 543 | } 544 | 545 | if len(podList) != 1 { 546 | t.Errorf("one pod expected but %d returned", len(podList)) 547 | return 548 | } 549 | 550 | if podList[0].Name != pod.Name { 551 | t.Errorf("invalid pod returned. Expected %s Returned %s", pod.Name, podList[0].Name) 552 | return 553 | } 554 | } 555 | 556 | func TestStructuredDelete(t *testing.T) { 557 | t.Parallel() 558 | // initialize with pod 559 | obj := buildPod() 560 | c, err := newForTest(obj) 561 | if err != nil { 562 | t.Errorf("failed %v", err) 563 | return 564 | } 565 | 566 | err = c.Structured().Delete("Pod", "busybox", "testns") 567 | if err != nil { 568 | t.Errorf("failed %v", err) 569 | return 570 | } 571 | } 572 | 573 | func TestStructuredUpdate(t *testing.T) { 574 | t.Parallel() 575 | // initialize with pod 576 | pod := buildPod() 577 | c, err := newForTest(pod) 578 | if err != nil { 579 | t.Errorf("failed %v", err) 580 | return 581 | } 582 | 583 | // change status 584 | pod.Status.Phase = corev1.PodFailed 585 | updated, err := c.Structured().Update(*pod) 586 | if err != nil { 587 | t.Errorf("failed %v", err) 588 | return 589 | } 590 | 591 | updatedPod, ok := updated.(corev1.Pod) 592 | if !ok { 593 | t.Errorf("invalid type assertion") 594 | } 595 | status := updatedPod.Status.Phase 596 | if status != corev1.PodFailed { 597 | t.Errorf("pod status not updated") 598 | return 599 | } 600 | } 601 | -------------------------------------------------------------------------------- /pkg/utils/retry.go: -------------------------------------------------------------------------------- 1 | // Package utils offers functions of general utility in other parts of the system 2 | package utils 3 | 4 | import ( 5 | "time" 6 | ) 7 | 8 | // Retry retries a function until it returns true, error, or the timeout expires. 9 | // If the function returns false, a new attempt is tried after the backoff period 10 | func Retry(timeout time.Duration, backoff time.Duration, f func() (bool, error)) (bool, error) { 11 | expired := time.After(timeout) 12 | for { 13 | select { 14 | case <-expired: 15 | return false, nil 16 | default: 17 | done, err := f() 18 | if err != nil { 19 | return false, err 20 | } 21 | if done { 22 | return true, nil 23 | } 24 | time.Sleep(backoff) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/utils/retry_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func Test_Retry(t *testing.T) { 10 | t.Parallel() 11 | testCases := []struct { 12 | title string 13 | timeout time.Duration 14 | backoff time.Duration 15 | failedRetries int 16 | expectedValue bool 17 | expectError bool 18 | }{ 19 | { 20 | title: "Succeed on first call", 21 | timeout: time.Second * 5, 22 | backoff: time.Second, 23 | failedRetries: 0, 24 | expectedValue: true, 25 | expectError: false, 26 | }, 27 | { 28 | title: "Succeed on second call", 29 | timeout: time.Second * 5, 30 | backoff: time.Second, 31 | failedRetries: 1, 32 | expectedValue: true, 33 | expectError: false, 34 | }, 35 | { 36 | title: "error on first call", 37 | timeout: time.Second * 5, 38 | backoff: time.Second, 39 | failedRetries: 0, 40 | expectedValue: false, 41 | expectError: true, 42 | }, 43 | { 44 | title: "error on second call", 45 | timeout: time.Second * 5, 46 | backoff: time.Second, 47 | failedRetries: 1, 48 | expectedValue: false, 49 | expectError: true, 50 | }, 51 | { 52 | title: "timeout", 53 | timeout: time.Second * 5, 54 | backoff: time.Second, 55 | failedRetries: 100, 56 | expectedValue: false, 57 | expectError: false, 58 | }, 59 | } 60 | 61 | for _, tc := range testCases { 62 | tc := tc 63 | t.Run(tc.title, func(t *testing.T) { 64 | t.Parallel() 65 | retries := 0 66 | done, err := Retry(tc.timeout, tc.backoff, func() (bool, error) { 67 | retries++ 68 | if retries < tc.failedRetries { 69 | return false, nil 70 | } 71 | if tc.expectError { 72 | return false, fmt.Errorf("Error") 73 | } 74 | return true, nil 75 | }) 76 | 77 | if !tc.expectError && err != nil { 78 | t.Errorf("unexpected error: %v", err) 79 | return 80 | } 81 | 82 | if tc.expectError && err == nil { 83 | t.Errorf("should have failed") 84 | return 85 | } 86 | 87 | if done != tc.expectedValue { 88 | t.Errorf("invalid value returned expected %t actual %t", tc.expectedValue, done) 89 | return 90 | } 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /pkg/utils/unstructured.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 5 | "k8s.io/apimachinery/pkg/runtime" 6 | ) 7 | 8 | // RuntimeToUnstructured converts a runtime object in a unstructured object 9 | func RuntimeToUnstructured(obj interface{}) (*unstructured.Unstructured, error) { 10 | // transform runtime into a generic object 11 | generic, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) 12 | if err != nil { 13 | return nil, err 14 | } 15 | // create an unstructured object form the generic object 16 | uObj := &unstructured.Unstructured{ 17 | Object: generic, 18 | } 19 | 20 | return uObj, nil 21 | } 22 | 23 | // UnstructuredToRuntime converts an unstructured object in a runtime object 24 | func UnstructuredToRuntime(uObj *unstructured.Unstructured, obj interface{}) error { 25 | return runtime.DefaultUnstructuredConverter.FromUnstructured(uObj.UnstructuredContent(), obj) 26 | } 27 | 28 | // GenericToRuntime converts a generic object to a Runtime object 29 | func GenericToRuntime(obj map[string]interface{}, rtObj interface{}) error { 30 | return runtime.DefaultUnstructuredConverter.FromUnstructured(obj, rtObj) 31 | } 32 | 33 | // RuntimeToGeneric converts a runtime object in a unstructured object 34 | func RuntimeToGeneric(obj interface{}) (map[string]interface{}, error) { 35 | return runtime.DefaultUnstructuredConverter.ToUnstructured(obj) 36 | } 37 | --------------------------------------------------------------------------------