├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── pr.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yaml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Earthfile ├── LICENSE ├── README.md ├── cmd ├── api_key.go ├── auth.go ├── delete.go ├── deploy.go ├── generate_client_token.go ├── get_datasources_api_key.go ├── login.go ├── root.go ├── service_account.go ├── status.go └── version.go ├── go.mod ├── go.sum ├── install.sh ├── main.go └── pkg ├── api ├── backend.go ├── client.go ├── cluster.go ├── errors.go └── tenant.go ├── auth ├── api_key.go ├── auth0.go ├── client.go ├── device_code.go ├── errors.go ├── sa_token.go ├── token.go └── token_test.go ├── helm ├── chart.go ├── client.go ├── presets │ ├── agent │ │ ├── kernel-5-11.yaml │ │ └── low-resources.yaml │ ├── backend │ │ ├── custom-metrics.yaml │ │ ├── high-resources.yaml │ │ ├── huge-resources.yaml │ │ ├── kube-state-metrics.yaml │ │ └── low-resources.yaml │ └── quay.yaml ├── release.go ├── repo.go ├── templates │ └── backend │ │ └── storage-class.yaml ├── tune.go ├── tune_test.go ├── values.go └── values_test.go ├── k8s ├── auth.go ├── auth_test.go ├── client.go ├── cluster.go ├── cluster_test.go ├── node.go ├── node_test.go ├── pod.go ├── requirement.go ├── requirement_test.go ├── storage.go ├── taint.go └── taint_test.go ├── segment ├── client.go ├── event.go └── user.go ├── selfupdate └── selfupdate.go ├── sentry ├── client.go ├── client_test.go ├── context.go ├── context_test.go └── mocks_test.go ├── ui ├── spinner.go ├── spinner_test.go └── writer.go └── utils ├── browser.go └── diskv.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | open-pull-requests-limit: 0 9 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: cli-pr 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | cancel-in-progress: true 11 | group: ${{ github.ref_name }} 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | steps: 19 | - 20 | name: Checkout 21 | uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 24 | - 25 | if: ${{ github.event_name == 'pull_request' }} 26 | name: Dependency Review 27 | uses: actions/dependency-review-action@v3 28 | with: 29 | fail-on-severity: low 30 | - 31 | name: Set up Go 32 | uses: actions/setup-go@v4 33 | with: 34 | cache: true 35 | go-version-file: go.mod 36 | cache-dependency-path: go.sum 37 | - 38 | name: Run GoReleaser 39 | uses: goreleaser/goreleaser-action@v4 40 | with: 41 | version: ~> 1.18 42 | args: build --snapshot --clean 43 | env: 44 | SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 45 | SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: cli-release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - 15 | name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - 20 | name: Set up Go 21 | uses: actions/setup-go@v4 22 | with: 23 | cache: true 24 | go-version-file: go.mod 25 | cache-dependency-path: go.sum 26 | - 27 | name: Run GoReleaser 28 | uses: goreleaser/goreleaser-action@v4 29 | with: 30 | version: ~> 1.18 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 35 | SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | artifacts/* 18 | dist/ 19 | 20 | # vscode files 21 | .vscode/ -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: groundcover 2 | before: 3 | hooks: 4 | - go mod tidy 5 | - go test ./... 6 | builds: 7 | - 8 | main: main.go 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | - windows 15 | goarch: 16 | - amd64 17 | - arm64 18 | ldflags: 19 | - '-X groundcover.com/cmd.BinaryVersion={{ .Version }}' 20 | - '-X groundcover.com/pkg/sentry.Dsn={{ .Env.SENTRY_DSN }}' 21 | - '-X groundcover.com/pkg/segment.WriteKey={{ .Env.SEGMENT_WRITE_KEY }}' 22 | archives: 23 | - 24 | format: tar.gz 25 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' 26 | files: 27 | - none* 28 | checksum: 29 | algorithm: sha256 30 | name_template: '{{ .ProjectName }}_{{ .Version }}_checksums' 31 | changelog: 32 | use: github-native 33 | snapshot: 34 | name_template: "{{ incpatch .Version }}-next" 35 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | info@groundcover.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Earthfile: -------------------------------------------------------------------------------- 1 | groundcover-deps: 2 | FROM golang:1.19-alpine3.15 3 | RUN apk add build-base 4 | WORKDIR /builder 5 | COPY go.mod . 6 | COPY go.sum . 7 | RUN go mod download 8 | # Output these back in case go mod download changes them. 9 | SAVE ARTIFACT go.mod AS LOCAL go.mod 10 | SAVE ARTIFACT go.sum AS LOCAL go.sum 11 | 12 | 13 | pkg-base: 14 | FROM +groundcover-deps 15 | COPY --dir pkg . 16 | 17 | 18 | build-cli: 19 | FROM +pkg-base 20 | COPY --dir cmd main.go . 21 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /bin/groundcover ./main.go 22 | SAVE ARTIFACT /bin/groundcover AS LOCAL ./artifacts/groundcover 23 | 24 | build-cli-image: 25 | FROM alpine/helm:3.9.0 26 | COPY +build-cli/groundcover ./ 27 | ENTRYPOINT ["./groundcover"] 28 | ARG EARTHLY_GIT_HASH 29 | ARG IMAGE_TAG=$EARTHLY_GIT_HASH 30 | ARG REG=125608480246.dkr.ecr.eu-west-3.amazonaws.com 31 | SAVE IMAGE --push $REG/groundcover-cli:$IMAGE_TAG 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # groundcover CLI 2 | 3 | ## Installation 4 | 5 | ### From Script 6 | 7 | CLI now has an installer script that will automatically grab the latest version and install it locally. 8 | 9 | `sh -c "$(curl -fsSL https://groundcover.com/install.sh)"` 10 | 11 | ### From the Binary Releases 12 | 13 | Binary downloads of the CLI can be found on [the Releases page](https://github.com/groundcover-com/cli/releases/latest). 14 | 15 | #### Linux 16 | 17 | ```bash 18 | VERSION=0.1.0 19 | 20 | # Intel Chip 21 | curl -SsL https://github.com/groundcover-com/cli/releases/download/v${VERSION}/groundcover_${VERSION}_linux_amd64.tar.gz -o /tmp/groundcover.tar.gz 22 | # ARM chip 23 | curl -SsL https://github.com/groundcover-com/cli/releases/download/v${VERSION}/groundcover_${VERSION}_linux_arm64.tar.gz -o /tmp/groundcover.tar.gz 24 | 25 | mkdir -p ~/.groundcover/bin 26 | tar -zxf /tmp/groundcover.tar.gz -C ~/.groundcover/bin 27 | chmod +x ~/.groundcover/bin/groundcover 28 | 29 | echo 'export PATH=~/.groundcover/bin:/$PATH' >> ~/.bashrc 30 | ``` 31 | 32 | #### MacOS 33 | 34 | ```bash 35 | VERSION=0.1.0 36 | 37 | # Intel chip 38 | curl -SsL https://github.com/groundcover-com/cli/releases/download/v${VERSION}/groundcover_${VERSION}_darwin_amd64.tar.gz -o /tmp/groundcover.tar.gz 39 | # Apple chip 40 | curl -SsL https://github.com/groundcover-com/cli/releases/download/v${VERSION}/groundcover_${VERSION}_darwin_arm64.tar.gz -o /tmp/groundcover.tar.gz 41 | 42 | mkdir -p ~/.groundcover/bin 43 | tar -zxf /tmp/groundcover.tar.gz -C ~/.groundcover/bin 44 | chmod +x ~/.groundcover/bin/groundcover 45 | 46 | echo 'export PATH=~/.groundcover/bin:/$PATH' >> ~/.zshrc 47 | ``` 48 | -------------------------------------------------------------------------------- /cmd/api_key.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "groundcover.com/pkg/api" 6 | "groundcover.com/pkg/auth" 7 | "groundcover.com/pkg/ui" 8 | ) 9 | 10 | var apiKeyCmd = &cobra.Command{ 11 | Use: "print-api-key", 12 | Short: "Print api-key", 13 | RunE: func(cmd *cobra.Command, args []string) error { 14 | var err error 15 | 16 | var tenant *api.TenantInfo 17 | if tenant, err = fetchTenant(); err != nil { 18 | return err 19 | } 20 | 21 | var apiKey *auth.ApiKey 22 | if apiKey, err = fetchApiKey(tenant.UUID); err != nil { 23 | return err 24 | } 25 | 26 | ui.QuietWriter.Println(apiKey.ApiKey) 27 | 28 | return nil 29 | }, 30 | } 31 | 32 | func init() { 33 | AuthCmd.AddCommand(apiKeyCmd) 34 | } 35 | -------------------------------------------------------------------------------- /cmd/auth.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var AuthCmd = &cobra.Command{ 8 | Use: "auth", 9 | Short: "Manage groundcover auth credentials", 10 | } 11 | 12 | func init() { 13 | RootCmd.AddCommand(AuthCmd) 14 | } 15 | -------------------------------------------------------------------------------- /cmd/delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | "groundcover.com/pkg/helm" 12 | "groundcover.com/pkg/k8s" 13 | sentry_utils "groundcover.com/pkg/sentry" 14 | "groundcover.com/pkg/ui" 15 | helm_driver "helm.sh/helm/v3/pkg/storage/driver" 16 | v1 "k8s.io/api/core/v1" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | ) 19 | 20 | const ( 21 | DELETE_NAMESPACE_FLAG = "delete-namespace" 22 | ) 23 | 24 | var ( 25 | pvcLabelNames = []string{"release", "app.kubernetes.io/instance"} 26 | ) 27 | 28 | func init() { 29 | RootCmd.AddCommand(DeleteCmd) 30 | 31 | DeleteCmd.PersistentFlags().Bool(DELETE_NAMESPACE_FLAG, false, "force delete groundcover namespace") 32 | viper.BindPFlag(DELETE_NAMESPACE_FLAG, DeleteCmd.PersistentFlags().Lookup(DELETE_NAMESPACE_FLAG)) 33 | } 34 | 35 | var DeleteCmd = &cobra.Command{ 36 | Use: "delete", 37 | Short: "Delete groundcover", 38 | RunE: func(cmd *cobra.Command, args []string) error { 39 | var err error 40 | 41 | ctx := cmd.Context() 42 | namespace := viper.GetString(NAMESPACE_FLAG) 43 | kubeconfig := viper.GetString(KUBECONFIG_FLAG) 44 | kubecontext := viper.GetString(KUBECONTEXT_FLAG) 45 | releaseName := viper.GetString(HELM_RELEASE_FLAG) 46 | 47 | sentryKubeContext := sentry_utils.NewKubeContext(kubeconfig, kubecontext) 48 | sentryKubeContext.SetOnCurrentScope() 49 | 50 | var kubeClient *k8s.Client 51 | if kubeClient, err = k8s.NewKubeClient(kubeconfig, kubecontext); err != nil { 52 | return err 53 | } 54 | 55 | var clusterSummary *k8s.ClusterSummary 56 | if clusterSummary, err = kubeClient.GetClusterSummary(ctx, namespace, viper.GetString(STORAGE_CLASS_FLAG)); err != nil { 57 | sentryKubeContext.ClusterReport = &k8s.ClusterReport{ 58 | ClusterSummary: clusterSummary, 59 | } 60 | sentryKubeContext.SetOnCurrentScope() 61 | return err 62 | } 63 | 64 | clusterReport := k8s.DefaultClusterRequirements.Validate(ctx, kubeClient, clusterSummary) 65 | 66 | sentryKubeContext.ClusterReport = clusterReport 67 | sentryKubeContext.SetOnCurrentScope() 68 | 69 | var clusterName string 70 | if clusterName, err = getClusterName(kubeClient); err != nil { 71 | return err 72 | } 73 | 74 | var helmClient *helm.Client 75 | if helmClient, err = helm.NewHelmClient(namespace, kubecontext); err != nil { 76 | return err 77 | } 78 | 79 | var sentryHelmContext sentry_utils.HelmContext 80 | sentryHelmContext.ReleaseName = releaseName 81 | sentryHelmContext.SetOnCurrentScope() 82 | 83 | if err = namespaceExists(ctx, kubeClient, namespace); err != nil { 84 | return err 85 | } 86 | 87 | var shouldDelete bool 88 | var shouldEraseData bool 89 | var shouldDeleteNamespace bool 90 | if shouldDelete, shouldEraseData, shouldDeleteNamespace, err = promptDelete(ctx, kubeClient, helmClient, clusterName, releaseName, namespace, &sentryHelmContext); err != nil { 91 | return err 92 | } 93 | 94 | if shouldDelete { 95 | if err = deleteHelmRelease(ctx, kubeClient, helmClient, releaseName, namespace); err != nil { 96 | return err 97 | } 98 | } 99 | 100 | if shouldEraseData { 101 | if err = deletePvcs(ctx, kubeClient, releaseName, namespace); err != nil { 102 | return err 103 | } 104 | } 105 | 106 | if shouldDeleteNamespace { 107 | if err = deleteNamespace(ctx, kubeClient, namespace); err != nil { 108 | return err 109 | } 110 | } 111 | 112 | return nil 113 | }, 114 | } 115 | 116 | func promptDelete(ctx context.Context, kubeClient *k8s.Client, helmClient *helm.Client, clusterName, releaseName, namespace string, sentryHelmContext *sentry_utils.HelmContext) (bool, bool, bool, error) { 117 | var err error 118 | 119 | ui.GlobalWriter.PrintlnWithPrefixln("Deleting groundcover:") 120 | 121 | var shouldDelete bool 122 | if shouldDelete, err = promptDeleteRelease(ctx, kubeClient, helmClient, clusterName, releaseName, namespace, sentryHelmContext); err != nil { 123 | return false, false, false, err 124 | } 125 | 126 | var shouldEraseData bool 127 | if shouldEraseData, err = promptEraseData(ctx, kubeClient, releaseName, namespace); err != nil { 128 | return false, false, false, err 129 | } 130 | 131 | var shouldDeleteNamespace bool 132 | if viper.GetBool(DELETE_NAMESPACE_FLAG) { 133 | shouldDeleteNamespace = ui.GlobalWriter.YesNoPrompt(fmt.Sprintf("Are you sure you want to delete %s namespace?", namespace), true) 134 | } 135 | 136 | if !shouldDelete && !shouldEraseData && !shouldDeleteNamespace { 137 | ui.GlobalWriter.PrintWarningMessageln(fmt.Sprintf( 138 | "could not find release %s in namespace %s, maybe groundcover is installed elsewhere? (use --%s, --%s flags)", 139 | releaseName, namespace, HELM_RELEASE_FLAG, NAMESPACE_FLAG), 140 | ) 141 | return false, false, false, ErrSilentExecutionAbort 142 | } 143 | 144 | sentry_utils.SetTagOnCurrentScope(sentry_utils.ERASE_DATA_TAG, strconv.FormatBool(shouldEraseData)) 145 | 146 | return shouldDelete, shouldEraseData, shouldDeleteNamespace, nil 147 | } 148 | 149 | func namespaceExists(ctx context.Context, kubeClient *k8s.Client, namespace string) error { 150 | var err error 151 | 152 | namespaceListOptions := metav1.ListOptions{ 153 | LabelSelector: fmt.Sprintf("kubernetes.io/metadata.name=%s", namespace), 154 | } 155 | 156 | var namespaceList *v1.NamespaceList 157 | if namespaceList, err = kubeClient.CoreV1().Namespaces().List(ctx, namespaceListOptions); err != nil { 158 | return err 159 | } 160 | 161 | if len(namespaceList.Items) == 0 { 162 | ui.GlobalWriter.PrintWarningMessageln(fmt.Sprintf("could not find namespace %s, maybe groundcover is installed elsewhere? (use --%s flag)", namespace, NAMESPACE_FLAG)) 163 | return ErrSilentExecutionAbort 164 | } 165 | 166 | return nil 167 | } 168 | 169 | func promptDeleteRelease(ctx context.Context, kubeClient *k8s.Client, helmClient *helm.Client, clusterName, releaseName, namespace string, sentryHelmContext *sentry_utils.HelmContext) (bool, error) { 170 | var err error 171 | 172 | var release *helm.Release 173 | if release, err = helmClient.GetCurrentRelease(releaseName); err != nil { 174 | if errors.Is(err, helm_driver.ErrReleaseNotFound) { 175 | return releaseLeftoversExists(ctx, kubeClient, releaseName, namespace) 176 | } 177 | 178 | return false, err 179 | } 180 | 181 | sentryHelmContext.RepoUrl = HELM_REPO_URL 182 | sentryHelmContext.ChartName = release.Chart.Name() 183 | sentryHelmContext.ChartVersion = release.Chart.Metadata.Version 184 | sentryHelmContext.SetOnCurrentScope() 185 | sentry_utils.SetTagOnCurrentScope(sentry_utils.CHART_VERSION_TAG, sentryHelmContext.ChartVersion) 186 | 187 | promptMessage := fmt.Sprintf( 188 | "Current groundcover installation in your cluster: (cluster: %s, namespace: %s, version: %s).\nAre you sure you want to delete?", 189 | clusterName, namespace, release.Version(), 190 | ) 191 | 192 | if !ui.GlobalWriter.YesNoPrompt(promptMessage, true) { 193 | return false, ErrExecutionAborted 194 | } 195 | 196 | return true, nil 197 | } 198 | 199 | func promptEraseData(ctx context.Context, kubeClient *k8s.Client, releaseName, namespace string) (bool, error) { 200 | var err error 201 | var foundReleasePvcs bool 202 | 203 | pvcClient := kubeClient.CoreV1().PersistentVolumeClaims(namespace) 204 | for _, labelName := range pvcLabelNames { 205 | listOptions := metav1.ListOptions{ 206 | LabelSelector: fmt.Sprintf("%s=%s", labelName, releaseName), 207 | } 208 | 209 | var pvcList *v1.PersistentVolumeClaimList 210 | if pvcList, err = pvcClient.List(ctx, listOptions); err != nil { 211 | return false, err 212 | } 213 | if len(pvcList.Items) > 0 { 214 | foundReleasePvcs = true 215 | break 216 | } 217 | } 218 | 219 | if !foundReleasePvcs { 220 | return false, nil 221 | } 222 | 223 | // we found PVCs, and we are deleting 224 | return true, nil 225 | } 226 | 227 | func deleteHelmRelease(ctx context.Context, kubeClient *k8s.Client, helmClient *helm.Client, releaseName, namespace string) error { 228 | var err error 229 | 230 | spinner := ui.GlobalWriter.NewSpinner("Deleting groundcover helm release") 231 | spinner.Start() 232 | spinner.SetStopMessage("groundcover helm release is deleted") 233 | defer spinner.WriteStop() 234 | 235 | if err = helmClient.Uninstall(releaseName); err != nil { 236 | if !errors.Is(err, helm_driver.ErrReleaseNotFound) { 237 | spinner.WriteStopFail() 238 | return err 239 | } 240 | } 241 | 242 | if err = deleteReleaseLeftovers(ctx, kubeClient, releaseName, namespace); err != nil { 243 | spinner.WriteStopFail() 244 | return err 245 | } 246 | 247 | return nil 248 | } 249 | 250 | func releaseLeftoversExists(ctx context.Context, kubeClient *k8s.Client, releaseName, namespace string) (bool, error) { 251 | var err error 252 | 253 | listOptions := metav1.ListOptions{ 254 | LabelSelector: fmt.Sprintf("release=%s", releaseName), 255 | } 256 | 257 | svcClient := kubeClient.CoreV1().Services(namespace) 258 | 259 | var svcList *v1.ServiceList 260 | if svcList, err = svcClient.List(ctx, listOptions); err != nil { 261 | return false, err 262 | } 263 | 264 | if len(svcList.Items) > 0 { 265 | return true, nil 266 | } 267 | 268 | epClient := kubeClient.CoreV1().Endpoints(namespace) 269 | 270 | var epList *v1.EndpointsList 271 | if epList, err = epClient.List(ctx, listOptions); err != nil { 272 | return false, err 273 | } 274 | 275 | if len(epList.Items) > 0 { 276 | return true, nil 277 | } 278 | 279 | return false, nil 280 | } 281 | 282 | func deleteReleaseLeftovers(ctx context.Context, kubeClient *k8s.Client, releaseName, namespace string) error { 283 | var err error 284 | 285 | deleteOptions := metav1.DeleteOptions{} 286 | listOptions := metav1.ListOptions{ 287 | LabelSelector: fmt.Sprintf("release=%s", releaseName), 288 | } 289 | 290 | svcClient := kubeClient.CoreV1().Services(namespace) 291 | 292 | var svcList *v1.ServiceList 293 | if svcList, err = svcClient.List(ctx, listOptions); err != nil { 294 | return err 295 | } 296 | for _, svc := range svcList.Items { 297 | if err = svcClient.Delete(ctx, svc.Name, deleteOptions); err != nil { 298 | return err 299 | } 300 | } 301 | 302 | epClient := kubeClient.CoreV1().Endpoints(namespace) 303 | if err = epClient.DeleteCollection(ctx, deleteOptions, listOptions); err != nil { 304 | return err 305 | } 306 | 307 | return nil 308 | } 309 | 310 | func deletePvcs(ctx context.Context, kubeClient *k8s.Client, releaseName, namespace string) error { 311 | var err error 312 | 313 | spinner := ui.GlobalWriter.NewSpinner("Deleting groundcover pvcs") 314 | spinner.Start() 315 | spinner.SetStopMessage("groundcover pvcs are deleted") 316 | spinner.SetStopFailMessage("failed to delete groundcover pvcs") 317 | defer spinner.WriteStop() 318 | 319 | deleteOptions := metav1.DeleteOptions{} 320 | 321 | pvcClient := kubeClient.CoreV1().PersistentVolumeClaims(namespace) 322 | for _, labelName := range pvcLabelNames { 323 | listOptions := metav1.ListOptions{ 324 | LabelSelector: fmt.Sprintf("%s=%s", labelName, releaseName), 325 | } 326 | 327 | if err = pvcClient.DeleteCollection(ctx, deleteOptions, listOptions); err != nil { 328 | spinner.WriteStopFail() 329 | return err 330 | } 331 | } 332 | 333 | return nil 334 | } 335 | 336 | func deleteNamespace(ctx context.Context, kubeClient *k8s.Client, namespace string) error { 337 | var err error 338 | 339 | spinner := ui.GlobalWriter.NewSpinner("Deleting groundcover namespace") 340 | spinner.Start() 341 | spinner.SetStopMessage(fmt.Sprintf("%s namespace is deleted", namespace)) 342 | spinner.SetStopFailMessage(fmt.Sprintf("failed to delete %s namespace", namespace)) 343 | defer spinner.WriteStop() 344 | 345 | if err = kubeClient.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{}); err != nil { 346 | spinner.WriteStopFail() 347 | return err 348 | } 349 | 350 | return nil 351 | } 352 | -------------------------------------------------------------------------------- /cmd/generate_client_token.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "groundcover.com/pkg/api" 6 | "groundcover.com/pkg/auth" 7 | "groundcover.com/pkg/ui" 8 | ) 9 | 10 | var generateClientTokenCmd = &cobra.Command{ 11 | Use: "generate-client-token", 12 | Short: "Get Client Token for Grafana API", 13 | RunE: func(cmd *cobra.Command, args []string) error { 14 | var err error 15 | 16 | var tenant *api.TenantInfo 17 | if tenant, err = fetchTenant(); err != nil { 18 | return err 19 | } 20 | 21 | var apiToken *auth.ApiKey 22 | if apiToken, err = fetchClientToken(tenant); err != nil { 23 | return err 24 | } 25 | 26 | ui.QuietWriter.Println(apiToken.ApiKey) 27 | 28 | return nil 29 | }, 30 | } 31 | 32 | func fetchClientToken(tenant *api.TenantInfo) (*auth.ApiKey, error) { 33 | var err error 34 | 35 | var auth0Token *auth.Auth0Token 36 | if auth0Token, err = auth.LoadAuth0Token(); err != nil { 37 | return nil, err 38 | } 39 | 40 | apiClient := api.NewClient(auth0Token) 41 | 42 | var clientToken *auth.ApiKey 43 | if clientToken, err = apiClient.GetOrCreateClientToken(tenant); err != nil { 44 | return nil, err 45 | } 46 | 47 | return clientToken, nil 48 | } 49 | 50 | func init() { 51 | AuthCmd.AddCommand(generateClientTokenCmd) 52 | } 53 | -------------------------------------------------------------------------------- /cmd/get_datasources_api_key.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "groundcover.com/pkg/api" 6 | "groundcover.com/pkg/auth" 7 | "groundcover.com/pkg/ui" 8 | ) 9 | 10 | var getDatasourcesAPIKeyCmd = &cobra.Command{ 11 | Use: "get-datasources-api-key", 12 | Short: "Get the API key for datasources", 13 | RunE: func(cmd *cobra.Command, args []string) error { 14 | var err error 15 | 16 | var tenant *api.TenantInfo 17 | if tenant, err = fetchTenant(); err != nil { 18 | return err 19 | } 20 | 21 | var backendName string 22 | if backendName, err = selectBackendName(tenant); err != nil { 23 | return err 24 | } 25 | 26 | var apiToken *auth.ApiKey 27 | if apiToken, err = fetchDatasourcesAPIKey(tenant, backendName); err != nil { 28 | return err 29 | } 30 | 31 | ui.QuietWriter.Println(apiToken.ApiKey) 32 | return nil 33 | }, 34 | } 35 | 36 | func fetchDatasourcesAPIKey(tenant *api.TenantInfo, backendName string) (*auth.ApiKey, error) { 37 | var err error 38 | 39 | var auth0Token *auth.Auth0Token 40 | if auth0Token, err = auth.LoadAuth0Token(); err != nil { 41 | return nil, err 42 | } 43 | 44 | apiClient := api.NewClient(auth0Token) 45 | 46 | var apiToken *auth.ApiKey 47 | if apiToken, err = apiClient.GetDatasourcesAPIKey(tenant, backendName); err != nil { 48 | return nil, err 49 | } 50 | 51 | return apiToken, nil 52 | } 53 | 54 | func init() { 55 | AuthCmd.AddCommand(getDatasourcesAPIKeyCmd) 56 | } 57 | -------------------------------------------------------------------------------- /cmd/login.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/getsentry/sentry-go" 7 | "github.com/pkg/errors" 8 | "github.com/spf13/cobra" 9 | "golang.org/x/exp/maps" 10 | "groundcover.com/pkg/api" 11 | "groundcover.com/pkg/auth" 12 | "groundcover.com/pkg/segment" 13 | sentry_utils "groundcover.com/pkg/sentry" 14 | "groundcover.com/pkg/ui" 15 | "groundcover.com/pkg/utils" 16 | ) 17 | 18 | const ( 19 | AUTHENTICATION_EVENT_NAME = "authentication" 20 | AUTHENTICATION_VALIDATION_EVENT_NAME = "authentication_validation" 21 | ) 22 | 23 | func init() { 24 | AuthCmd.AddCommand(LoginCmd) 25 | RootCmd.AddCommand(LoginCmd) 26 | } 27 | 28 | var LoginCmd = &cobra.Command{ 29 | Use: "login", 30 | Short: "Login to groundcover", 31 | RunE: runLoginCmd, 32 | } 33 | 34 | func runLoginCmd(cmd *cobra.Command, args []string) error { 35 | var err error 36 | var auth0Token *auth.Auth0Token 37 | 38 | ctx := cmd.Context() 39 | 40 | event := segment.NewEvent(AUTHENTICATION_EVENT_NAME) 41 | event.Set("authType", "auth0") 42 | event.Start() 43 | defer func() { 44 | event.StatusByError(err) 45 | }() 46 | 47 | if auth0Token, err = attemptAuth0Login(ctx); err != nil { 48 | return errors.Wrap(err, "failed to login") 49 | } 50 | 51 | email := auth0Token.GetEmail() 52 | org := auth0Token.GetOrg() 53 | 54 | event.UserId = segment.GenerateUserId(email) 55 | segment.NewUser(email, org) 56 | 57 | sentry_utils.SetUserOnCurrentScope(sentry.User{Email: email}) 58 | sentry_utils.SetTagOnCurrentScope(sentry_utils.ORGANIZATION_TAG, org) 59 | 60 | return nil 61 | } 62 | 63 | func attemptAuth0Login(ctx context.Context) (*auth.Auth0Token, error) { 64 | var err error 65 | 66 | var deviceCode *auth.DeviceCode 67 | if deviceCode, err = auth.NewDeviceCode(); err != nil { 68 | return nil, err 69 | } 70 | 71 | utils.TryOpenBrowser(ui.QuietWriter, "Browse to:", deviceCode.VerificationURIComplete) 72 | 73 | var auth0Token auth.Auth0Token 74 | if err = deviceCode.PollToken(ctx, &auth0Token); err != nil { 75 | return nil, err 76 | } 77 | 78 | if err = auth0Token.Save(); err != nil { 79 | return nil, err 80 | } 81 | 82 | return &auth0Token, err 83 | } 84 | 85 | func fetchTenant() (*api.TenantInfo, error) { 86 | var err error 87 | 88 | var auth0Token *auth.Auth0Token 89 | if auth0Token, err = auth.LoadAuth0Token(); err != nil { 90 | return nil, err 91 | } 92 | 93 | apiClient := api.NewClient(auth0Token) 94 | 95 | var tenants []*api.TenantInfo 96 | if tenants, err = apiClient.TenantList(); err != nil { 97 | return nil, errors.Wrap(err, "failed to load api key") 98 | } 99 | 100 | switch len(tenants) { 101 | case 0: 102 | return nil, errors.New("no active tenants") 103 | case 1: 104 | return tenants[0], nil 105 | default: 106 | tenantsByName := make(map[string]*api.TenantInfo, len(tenants)) 107 | 108 | for _, tenant := range tenants { 109 | tenantsByName[tenant.TenantName] = tenant 110 | } 111 | 112 | tenantName := ui.GlobalWriter.SelectPrompt("Select tenant:", maps.Keys(tenantsByName)) 113 | return tenantsByName[tenantName], nil 114 | } 115 | } 116 | 117 | func fetchApiKey(tenantUUID string) (*auth.ApiKey, error) { 118 | var err error 119 | 120 | var auth0Token *auth.Auth0Token 121 | if auth0Token, err = auth.LoadAuth0Token(); err != nil { 122 | return nil, err 123 | } 124 | 125 | apiClient := api.NewClient(auth0Token) 126 | 127 | var apiKey *auth.ApiKey 128 | if apiKey, err = apiClient.ApiKey(tenantUUID); err != nil { 129 | return nil, err 130 | } 131 | 132 | return apiKey, nil 133 | } 134 | 135 | func selectBackendName(tenant *api.TenantInfo) (string, error) { 136 | var err error 137 | var auth0Token *auth.Auth0Token 138 | if auth0Token, err = auth.LoadAuth0Token(); err != nil { 139 | return "", err 140 | } 141 | 142 | apiClient := api.NewClient(auth0Token) 143 | 144 | var backendsList []api.BackendInfo 145 | if backendsList, err = apiClient.BackendsList(tenant.UUID); err != nil { 146 | return "", err 147 | } 148 | 149 | backendNames := make([]string, len(backendsList)) 150 | for index, backend := range backendsList { 151 | backendNames[index] = backend.Name 152 | } 153 | 154 | backendId := "" 155 | 156 | switch len(backendNames) { 157 | case 0: 158 | return "", errors.New("no active backends") 159 | case 1: 160 | backendId = backendNames[0] 161 | default: 162 | backendId = ui.GlobalWriter.SelectPrompt("Select backend:", backendNames) 163 | } 164 | 165 | return backendId, nil 166 | } 167 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/blang/semver/v4" 12 | "github.com/getsentry/sentry-go" 13 | "github.com/pkg/errors" 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/viper" 16 | "groundcover.com/pkg/auth" 17 | "groundcover.com/pkg/segment" 18 | "groundcover.com/pkg/selfupdate" 19 | sentry_utils "groundcover.com/pkg/sentry" 20 | "groundcover.com/pkg/ui" 21 | "k8s.io/client-go/util/homedir" 22 | "k8s.io/utils/strings/slices" 23 | ) 24 | 25 | const ( 26 | GITHUB_REPO = "cli" 27 | GITHUB_OWNER = "groundcover-com" 28 | TOKEN_FLAG = "token" 29 | API_KEY_FLAG = "api-key" 30 | TENANT_UUID_FLAG = "tenant-uuid" 31 | NAMESPACE_FLAG = "namespace" 32 | KUBECONFIG_FLAG = "kubeconfig" 33 | KUBECONTEXT_FLAG = "kube-context" 34 | HELM_RELEASE_FLAG = "release-name" 35 | CLUSTER_NAME_FLAG = "cluster-name" 36 | SKIP_CLI_UPDATE_FLAG = "skip-cli-update" 37 | INSTALLATION_ID_FLAG = "installation-id" 38 | INVALID_TOKEN_MESSAGE = "Issue with authentication - try again to copy command line and rerun" 39 | ) 40 | 41 | var ( 42 | JOIN_SLACK_LINK = ui.GlobalWriter.UrlLink("https://groundcover.com/join-slack") 43 | SUPPORT_SLACK_MESSAGE = fmt.Sprintf("questions? issues? ping us anytime %s", JOIN_SLACK_LINK) 44 | JOIN_SLACK_MESSAGE = fmt.Sprintf("join us on slack, we promise to keep things interesting %s", JOIN_SLACK_LINK) 45 | ) 46 | 47 | func init() { 48 | home := homedir.HomeDir() 49 | 50 | RootCmd.PersistentFlags().String(API_KEY_FLAG, "", "optional api-key") 51 | viper.BindPFlag(API_KEY_FLAG, RootCmd.PersistentFlags().Lookup(API_KEY_FLAG)) 52 | 53 | RootCmd.PersistentFlags().String(TENANT_UUID_FLAG, "", "optional tenant-uuid") 54 | viper.BindPFlag(TENANT_UUID_FLAG, RootCmd.PersistentFlags().Lookup(TENANT_UUID_FLAG)) 55 | 56 | RootCmd.PersistentFlags().String(TOKEN_FLAG, "", "optional login token") 57 | viper.BindPFlag(TOKEN_FLAG, RootCmd.PersistentFlags().Lookup(TOKEN_FLAG)) 58 | 59 | RootCmd.PersistentFlags().Bool(ui.ASSUME_YES_FLAG, false, "assume yes on interactive prompts") 60 | viper.BindPFlag(ui.ASSUME_YES_FLAG, RootCmd.PersistentFlags().Lookup(ui.ASSUME_YES_FLAG)) 61 | 62 | RootCmd.PersistentFlags().Bool(SKIP_CLI_UPDATE_FLAG, false, "disable automatic cli update check") 63 | viper.BindPFlag(SKIP_CLI_UPDATE_FLAG, RootCmd.PersistentFlags().Lookup(SKIP_CLI_UPDATE_FLAG)) 64 | 65 | RootCmd.PersistentFlags().String(CLUSTER_NAME_FLAG, "", "cluster name") 66 | viper.BindPFlag(CLUSTER_NAME_FLAG, RootCmd.PersistentFlags().Lookup(CLUSTER_NAME_FLAG)) 67 | 68 | RootCmd.PersistentFlags().String(KUBECONTEXT_FLAG, "", "name of the kubeconfig context to use") 69 | viper.BindPFlag(KUBECONTEXT_FLAG, RootCmd.PersistentFlags().Lookup(KUBECONTEXT_FLAG)) 70 | 71 | RootCmd.PersistentFlags().String(KUBECONFIG_FLAG, filepath.Join(home, ".kube", "config"), "path to the kubeconfig file") 72 | viper.BindPFlag(KUBECONFIG_FLAG, RootCmd.PersistentFlags().Lookup(KUBECONFIG_FLAG)) 73 | viper.BindEnv(KUBECONFIG_FLAG) 74 | 75 | RootCmd.PersistentFlags().String(NAMESPACE_FLAG, DEFAULT_GROUNDCOVER_NAMESPACE, "groundcover deployment namespace") 76 | viper.BindPFlag(NAMESPACE_FLAG, RootCmd.PersistentFlags().Lookup(NAMESPACE_FLAG)) 77 | 78 | RootCmd.PersistentFlags().String(HELM_RELEASE_FLAG, DEFAULT_GROUNDCOVER_RELEASE, "groundcover chart release name") 79 | viper.BindPFlag(HELM_RELEASE_FLAG, RootCmd.PersistentFlags().Lookup(HELM_RELEASE_FLAG)) 80 | } 81 | 82 | var ( 83 | skipAuthCommandNames = []string{ 84 | "help", 85 | LoginCmd.Name(), 86 | VersionCmd.Name(), 87 | } 88 | 89 | ErrExecutionAborted = errors.New("execution aborted") 90 | ErrSilentExecutionAbort = errors.New("silent execution abort") 91 | ErrExecutionPartialSuccess = errors.New("execution partial success") 92 | ) 93 | 94 | var RootCmd = &cobra.Command{ 95 | SilenceUsage: true, 96 | SilenceErrors: true, 97 | CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, 98 | Use: "groundcover", 99 | Short: "groundcover cli", 100 | Long: ` 101 | _ 102 | __ _ _ __ ___ _ _ _ __ __| | ___ _____ _____ _ __ 103 | / _` + "`" + ` | '__/ _ \| | | | '_ \ / _` + "`" + ` |/ __/ _ \ \ / / _ \ '__| 104 | | (_| | | | (_) | |_| | | | | (_| | (_| (_) \ V / __/ | 105 | \__, |_| \___/ \__,_|_| |_|\__,_|\___\___/ \_/ \___|_| 106 | |___/ 107 | 108 | groundcover, more data at: https://docs.groundcover.com/docs`, 109 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 110 | var err error 111 | 112 | segment.SetScope(cmd.Name()) 113 | sentry_utils.SetTransactionOnCurrentScope(cmd.Name()) 114 | 115 | event := segment.NewEvent(cmd.Name()) 116 | defer event.Start() 117 | 118 | if err = validateAuthentication(cmd, args); err != nil { 119 | return err 120 | } 121 | 122 | if !viper.GetBool(SKIP_CLI_UPDATE_FLAG) { 123 | return checkAndUpgradeVersion(cmd.Context()) 124 | } 125 | 126 | return nil 127 | }, 128 | } 129 | 130 | func checkAndUpgradeVersion(ctx context.Context) error { 131 | if shouldUpdate, selfUpdater := checkLatestVersionUpdate(ctx); shouldUpdate { 132 | if err := selfUpdater.Apply(ctx); err != nil { 133 | return err 134 | } 135 | command := strings.Join(os.Args, " ") 136 | ui.GlobalWriter.PrintWarningMessage(fmt.Sprintf("Please re-run %s\n", command)) 137 | sentry.CaptureMessage("cli-update executed successfully") 138 | return ErrSilentExecutionAbort 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func checkLatestVersionUpdate(ctx context.Context) (bool, *selfupdate.SelfUpdater) { 145 | var err error 146 | var currentVersion semver.Version 147 | var selfUpdater *selfupdate.SelfUpdater 148 | 149 | if currentVersion, err = GetVersion(); err != nil { 150 | sentry.CaptureException(err) 151 | return false, nil 152 | } 153 | 154 | if selfUpdater, err = selfupdate.NewSelfUpdater(ctx, GITHUB_OWNER, GITHUB_REPO); err != nil { 155 | sentry.CaptureException(err) 156 | return false, nil 157 | } 158 | 159 | if !selfUpdater.IsLatestNewer(currentVersion) || selfUpdater.IsDevVersion(currentVersion) { 160 | return false, nil 161 | } 162 | 163 | promptFormat := "Your groundcover cli version %s is out of date! The latest cli version is %s. Do you want to update your cli?" 164 | shouldUpdate := ui.GlobalWriter.YesNoPrompt(fmt.Sprintf(promptFormat, currentVersion, selfUpdater.Version), true) 165 | 166 | if shouldUpdate { 167 | sentry_utils.SetTransactionOnCurrentScope(sentry_utils.SELF_UPDATE_CONTEXT_NAME) 168 | sentryContext := sentry_utils.NewSelfUpdateContext(currentVersion, selfUpdater.Version) 169 | sentryContext.SetOnCurrentScope() 170 | } 171 | 172 | return shouldUpdate, selfUpdater 173 | } 174 | 175 | func validateAuthentication(cmd *cobra.Command, args []string) error { 176 | var err error 177 | 178 | isAuthenicationRequired := !viper.IsSet(TOKEN_FLAG) 179 | 180 | if slices.Contains(skipAuthCommandNames, cmd.Name()) { 181 | return nil 182 | } 183 | 184 | event := segment.NewEvent(AUTHENTICATION_VALIDATION_EVENT_NAME) 185 | defer func() { 186 | event.StatusByError(err) 187 | }() 188 | 189 | ui.GlobalWriter.Println("Validating groundcover authentication:") 190 | 191 | var token auth.Token 192 | if isAuthenicationRequired { 193 | event.Set("authType", "auth0") 194 | if token, err = auth.LoadAuth0Token(); err != nil { 195 | if ui.GlobalWriter.YesNoPrompt("authentication is required, do you want to login?", true) { 196 | return runLoginCmd(cmd, args) 197 | } 198 | os.Exit(0) 199 | } 200 | 201 | segment.SetUserId(token.GetEmail()) 202 | ui.GlobalWriter.PrintSuccessMessageln("Device authentication is valid") 203 | } else { 204 | event.Set("authType", "installationToken") 205 | if token, err = validateInstallationToken(); err != nil { 206 | ui.GlobalWriter.PrintErrorMessageln(INVALID_TOKEN_MESSAGE) 207 | return err 208 | } 209 | 210 | segment.SetSessionId(token.GetSessionId()) 211 | segment.NewUser(token.GetEmail(), token.GetOrg()) 212 | ui.GlobalWriter.PrintSuccessMessageln("Token authentication success") 213 | } 214 | 215 | event.Set("installationId", token.GetId()) 216 | viper.Set(INSTALLATION_ID_FLAG, token.GetId()) 217 | sentry_utils.SetUserOnCurrentScope(sentry.User{Email: token.GetEmail()}) 218 | sentry_utils.SetTagOnCurrentScope(sentry_utils.TOKEN_ID_TAG, token.GetId()) 219 | sentry_utils.SetTagOnCurrentScope(sentry_utils.ORGANIZATION_TAG, token.GetOrg()) 220 | 221 | return nil 222 | } 223 | 224 | func ExecuteContext(ctx context.Context) error { 225 | start := time.Now() 226 | err := RootCmd.ExecuteContext(ctx) 227 | 228 | event := segment.NewEvent(segment.GetScope()) 229 | sentryCommandContext := sentry_utils.NewCommandContext(start) 230 | sentryCommandContext.SetOnCurrentScope() 231 | 232 | if err == nil { 233 | event.Success() 234 | sentry.CaptureMessage(fmt.Sprintf("%s executed successfully", sentryCommandContext.Name)) 235 | return nil 236 | } 237 | 238 | if errors.Is(err, ErrExecutionPartialSuccess) { 239 | event.PartialSuccess() 240 | sentry_utils.SetLevelOnCurrentScope(sentry.LevelWarning) 241 | sentry.CaptureMessage(fmt.Sprintf("%s execution partial success", sentryCommandContext.Name)) 242 | return nil 243 | } 244 | 245 | if errors.Is(err, ErrSilentExecutionAbort) { 246 | event.Abort() 247 | sentry.CaptureMessage(fmt.Sprintf("%s execution aborted silently", sentryCommandContext.Name)) 248 | return nil 249 | } 250 | 251 | if errors.Is(err, ErrExecutionAborted) { 252 | event.Abort() 253 | sentry.CaptureMessage(fmt.Sprintf("%s execution aborted", sentryCommandContext.Name)) 254 | return nil 255 | } 256 | 257 | if strings.HasPrefix(err.Error(), "unknown") { 258 | ui.GlobalWriter.PrintErrorMessageln(err.Error()) 259 | // in case the unknown flag / command is due to an old version of the cli 260 | checkAndUpgradeVersion(ctx) 261 | return nil 262 | } 263 | 264 | ui.GlobalWriter.PrintErrorMessageln(err.Error()) 265 | ui.GlobalWriter.PrintlnWithPrefixln(SUPPORT_SLACK_MESSAGE) 266 | 267 | sentry.CaptureMessage(fmt.Sprintf("%s execution failed - %s", sentryCommandContext.Name, err.Error())) 268 | event.Failure(err) 269 | return err 270 | } 271 | 272 | func validateInstallationToken() (*auth.InstallationToken, error) { 273 | var err error 274 | 275 | encodedToken := viper.GetString(TOKEN_FLAG) 276 | 277 | var installationToken *auth.InstallationToken 278 | if installationToken, err = auth.NewInstallationToken(encodedToken); err != nil { 279 | return nil, err 280 | } 281 | 282 | viper.Set(API_KEY_FLAG, installationToken.ApiKey.ApiKey) 283 | viper.Set(TENANT_UUID_FLAG, installationToken.TenantUUID) 284 | return installationToken, nil 285 | } 286 | -------------------------------------------------------------------------------- /cmd/service_account.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "groundcover.com/pkg/api" 6 | "groundcover.com/pkg/auth" 7 | "groundcover.com/pkg/ui" 8 | ) 9 | 10 | var serviceAccountTokenCmd = &cobra.Command{ 11 | Use: "generate-service-account-token", 12 | Short: "Generate Grafana service account token", 13 | RunE: func(cmd *cobra.Command, args []string) error { 14 | var err error 15 | 16 | var tenant *api.TenantInfo 17 | if tenant, err = fetchTenant(); err != nil { 18 | return err 19 | } 20 | 21 | var saToken *auth.SAToken 22 | if saToken, err = fetchServiceAccountToken(tenant.UUID); err != nil { 23 | return err 24 | } 25 | 26 | ui.QuietWriter.Println(saToken.Token) 27 | 28 | return nil 29 | }, 30 | } 31 | 32 | func fetchServiceAccountToken(tenantUUID string) (*auth.SAToken, error) { 33 | var err error 34 | 35 | var auth0Token *auth.Auth0Token 36 | if auth0Token, err = auth.LoadAuth0Token(); err != nil { 37 | return nil, err 38 | } 39 | 40 | apiClient := api.NewClient(auth0Token) 41 | 42 | var saToken *auth.SAToken 43 | if saToken, err = apiClient.ServiceAccountToken(tenantUUID); err != nil { 44 | return nil, err 45 | } 46 | 47 | return saToken, nil 48 | } 49 | 50 | func init() { 51 | AuthCmd.AddCommand(serviceAccountTokenCmd) 52 | } 53 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/blang/semver/v4" 5 | "github.com/spf13/cobra" 6 | "groundcover.com/pkg/ui" 7 | ) 8 | 9 | var ( 10 | // this is a placeholder value which will be overriden by the build process 11 | BinaryVersion = "0.0.0-dev" 12 | ) 13 | 14 | func init() { 15 | RootCmd.AddCommand(VersionCmd) 16 | } 17 | 18 | var VersionCmd = &cobra.Command{ 19 | Use: "version", 20 | Short: "Get groundcover cli version", 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | ui.GlobalWriter.Println(BinaryVersion) 23 | return nil 24 | }, 25 | } 26 | 27 | func GetVersion() (semver.Version, error) { 28 | return semver.ParseTolerant(BinaryVersion) 29 | } 30 | 31 | func IsDevVersion() bool { 32 | return BinaryVersion == "0.0.0-dev" 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module groundcover.com 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.22.4 6 | 7 | replace groundcover.com => ./pkg/ 8 | 9 | require ( 10 | github.com/blang/semver/v4 v4.0.0 11 | github.com/getsentry/sentry-go v0.13.0 12 | github.com/golang-jwt/jwt/v4 v4.5.1 13 | github.com/spf13/cobra v1.8.1 14 | github.com/spf13/viper v1.18.2 15 | k8s.io/apimachinery v0.31.3 16 | k8s.io/client-go v0.31.3 17 | ) 18 | 19 | require ( 20 | github.com/google/go-github v17.0.0+incompatible 21 | github.com/minio/selfupdate v0.6.0 22 | k8s.io/api v0.31.3 23 | ) 24 | 25 | require helm.sh/helm/v3 v3.16.4 26 | 27 | require ( 28 | github.com/AlecAivazis/survey/v2 v2.3.7 29 | github.com/MicahParks/keyfunc v1.9.0 30 | github.com/containerd/containerd v1.7.23 31 | github.com/fatih/color v1.16.0 32 | github.com/go-playground/validator/v10 v10.19.0 33 | github.com/google/uuid v1.6.0 34 | github.com/imdario/mergo v0.3.16 35 | github.com/peterbourgon/diskv/v3 v3.0.1 36 | github.com/pkg/errors v0.9.1 37 | github.com/segmentio/analytics-go/v3 v3.3.0 38 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 39 | github.com/stretchr/testify v1.9.0 40 | github.com/theckman/yacspin v0.13.12 41 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 42 | gopkg.in/yaml.v3 v3.0.1 43 | k8s.io/cli-runtime v0.31.3 44 | k8s.io/klog/v2 v2.130.1 45 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 46 | ) 47 | 48 | require ( 49 | aead.dev/minisign v0.2.1 // indirect 50 | dario.cat/mergo v1.0.1 // indirect 51 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect 52 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 53 | github.com/BurntSushi/toml v1.3.2 // indirect 54 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 55 | github.com/Masterminds/goutils v1.1.1 // indirect 56 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 57 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 58 | github.com/Masterminds/squirrel v1.5.4 // indirect 59 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 60 | github.com/beorn7/perks v1.0.1 // indirect 61 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect 62 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 63 | github.com/chai2010/gettext-go v1.0.2 // indirect 64 | github.com/containerd/errdefs v0.3.0 // indirect 65 | github.com/containerd/log v0.1.0 // indirect 66 | github.com/containerd/platforms v0.2.1 // indirect 67 | github.com/cyphar/filepath-securejoin v0.3.4 // indirect 68 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 69 | github.com/distribution/reference v0.6.0 // indirect 70 | github.com/docker/cli v25.0.3+incompatible // indirect 71 | github.com/docker/distribution v2.8.3+incompatible // indirect 72 | github.com/docker/docker v25.0.6+incompatible // indirect 73 | github.com/docker/docker-credential-helpers v0.8.1 // indirect 74 | github.com/docker/go-connections v0.5.0 // indirect 75 | github.com/docker/go-metrics v0.0.1 // indirect 76 | github.com/emicklei/go-restful/v3 v3.11.3 // indirect 77 | github.com/evanphx/json-patch v5.9.0+incompatible // indirect 78 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 79 | github.com/felixge/httpsnoop v1.0.4 // indirect 80 | github.com/fsnotify/fsnotify v1.7.0 // indirect 81 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 82 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 83 | github.com/go-errors/errors v1.5.1 // indirect 84 | github.com/go-gorp/gorp/v3 v3.1.0 // indirect 85 | github.com/go-logr/logr v1.4.2 // indirect 86 | github.com/go-logr/stdr v1.2.2 // indirect 87 | github.com/go-openapi/jsonpointer v0.20.2 // indirect 88 | github.com/go-openapi/jsonreference v0.20.4 // indirect 89 | github.com/go-openapi/swag v0.22.9 // indirect 90 | github.com/go-playground/locales v0.14.1 // indirect 91 | github.com/go-playground/universal-translator v0.18.1 // indirect 92 | github.com/gobwas/glob v0.2.3 // indirect 93 | github.com/gogo/protobuf v1.3.2 // indirect 94 | github.com/golang/protobuf v1.5.4 // indirect 95 | github.com/google/btree v1.1.2 // indirect 96 | github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect 97 | github.com/google/go-cmp v0.6.0 // indirect 98 | github.com/google/go-querystring v1.1.0 // indirect 99 | github.com/google/gofuzz v1.2.0 // indirect 100 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 101 | github.com/gorilla/mux v1.8.1 // indirect 102 | github.com/gorilla/websocket v1.5.1 // indirect 103 | github.com/gosuri/uitable v0.0.4 // indirect 104 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 105 | github.com/hashicorp/errwrap v1.1.0 // indirect 106 | github.com/hashicorp/go-multierror v1.1.1 // indirect 107 | github.com/hashicorp/hcl v1.0.0 // indirect 108 | github.com/huandu/xstrings v1.5.0 // indirect 109 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 110 | github.com/jmoiron/sqlx v1.4.0 // indirect 111 | github.com/josharian/intern v1.0.0 // indirect 112 | github.com/json-iterator/go v1.1.12 // indirect 113 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 114 | github.com/klauspost/compress v1.17.7 // indirect 115 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 116 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 117 | github.com/leodido/go-urn v1.4.0 // indirect 118 | github.com/lib/pq v1.10.9 // indirect 119 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 120 | github.com/magiconair/properties v1.8.7 // indirect 121 | github.com/mailru/easyjson v0.7.7 // indirect 122 | github.com/mattn/go-colorable v0.1.13 // indirect 123 | github.com/mattn/go-isatty v0.0.20 // indirect 124 | github.com/mattn/go-runewidth v0.0.15 // indirect 125 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 126 | github.com/mitchellh/copystructure v1.2.0 // indirect 127 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 128 | github.com/mitchellh/mapstructure v1.5.0 // indirect 129 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 130 | github.com/moby/locker v1.0.1 // indirect 131 | github.com/moby/spdystream v0.4.0 // indirect 132 | github.com/moby/term v0.5.0 // indirect 133 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 134 | github.com/modern-go/reflect2 v1.0.2 // indirect 135 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 136 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 137 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 138 | github.com/opencontainers/go-digest v1.0.0 // indirect 139 | github.com/opencontainers/image-spec v1.1.0 // indirect 140 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 141 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 142 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 143 | github.com/prometheus/client_golang v1.19.1 // indirect 144 | github.com/prometheus/client_model v0.6.1 // indirect 145 | github.com/prometheus/common v0.55.0 // indirect 146 | github.com/prometheus/procfs v0.15.1 // indirect 147 | github.com/rivo/uniseg v0.4.7 // indirect 148 | github.com/rubenv/sql-migrate v1.7.0 // indirect 149 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 150 | github.com/sagikazarmark/locafero v0.4.0 // indirect 151 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 152 | github.com/segmentio/backo-go v1.0.1 // indirect 153 | github.com/shopspring/decimal v1.4.0 // indirect 154 | github.com/sirupsen/logrus v1.9.3 // indirect 155 | github.com/sourcegraph/conc v0.3.0 // indirect 156 | github.com/spf13/afero v1.11.0 // indirect 157 | github.com/spf13/cast v1.7.0 // indirect 158 | github.com/spf13/pflag v1.0.5 // indirect 159 | github.com/subosito/gotenv v1.6.0 // indirect 160 | github.com/x448/float16 v0.8.4 // indirect 161 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 162 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 163 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 164 | github.com/xlab/treeprint v1.2.0 // indirect 165 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect 166 | go.opentelemetry.io/otel v1.28.0 // indirect 167 | go.opentelemetry.io/otel/metric v1.28.0 // indirect 168 | go.opentelemetry.io/otel/trace v1.28.0 // indirect 169 | go.starlark.net v0.0.0-20240123142251-f86470692795 // indirect 170 | go.uber.org/multierr v1.11.0 // indirect 171 | golang.org/x/crypto v0.31.0 // indirect 172 | golang.org/x/net v0.33.0 // indirect 173 | golang.org/x/oauth2 v0.21.0 // indirect 174 | golang.org/x/sync v0.10.0 // indirect 175 | golang.org/x/sys v0.28.0 // indirect 176 | golang.org/x/term v0.27.0 // indirect 177 | golang.org/x/text v0.21.0 // indirect 178 | golang.org/x/time v0.5.0 // indirect 179 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect 180 | google.golang.org/grpc v1.65.0 // indirect 181 | google.golang.org/protobuf v1.34.2 // indirect 182 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 183 | gopkg.in/inf.v0 v0.9.1 // indirect 184 | gopkg.in/ini.v1 v1.67.0 // indirect 185 | gopkg.in/yaml.v2 v2.4.0 // indirect 186 | k8s.io/apiextensions-apiserver v0.31.3 // indirect 187 | k8s.io/apiserver v0.31.3 // indirect 188 | k8s.io/component-base v0.31.3 // indirect 189 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 190 | k8s.io/kubectl v0.31.3 // indirect 191 | oras.land/oras-go v1.2.5 // indirect 192 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 193 | sigs.k8s.io/kustomize/api v0.17.2 // indirect 194 | sigs.k8s.io/kustomize/kyaml v0.17.1 // indirect 195 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 196 | sigs.k8s.io/yaml v1.4.0 // indirect 197 | ) 198 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright The groundcover Authors. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | : "${GITHUB_REPO:="cli"}" 18 | : "${GITHUB_OWNER:="groundcover-com"}" 19 | : "${BINARY_NAME:="groundcover"}" 20 | : "${INSTALL_DIR:="${HOME}/.groundcover/bin"}" 21 | 22 | 23 | BOLD="$(tput bold 2>/dev/null || printf '')" 24 | UNDERLINE="$(tput smul 2>/dev/null || printf '')" 25 | GREY="$(tput setaf 0 2>/dev/null || printf '')" 26 | REV_BG="$(tput rev 2>/dev/null || printf '')" 27 | RED="$(tput setaf 1 2>/dev/null || printf '')" 28 | GREEN="$(tput setaf 2 2>/dev/null || printf '')" 29 | BLUE="$(tput setaf 4 2>/dev/null || printf '')" 30 | YELLOW="$(tput setaf 3 2>/dev/null || printf '')" 31 | NO_COLOR="$(tput sgr0 2>/dev/null || printf '')" 32 | 33 | newline() { 34 | printf "\n" 35 | } 36 | 37 | info() { 38 | printf '%s\n' "${BOLD}${GREY}>${NO_COLOR} $*" 39 | } 40 | 41 | warn() { 42 | printf '%s\n' "${YELLOW}! $*${NO_COLOR}" 43 | } 44 | 45 | error() { 46 | printf '%s\n' "${RED}✕ $*${NO_COLOR}" >&2 47 | } 48 | 49 | completed() { 50 | printf '%s\n' "${GREEN}✔${NO_COLOR} $*" 51 | } 52 | 53 | printBanner() { 54 | cat << 'BANNER' 55 | _ 56 | __ _ _ __ ___ _ _ _ __ __| | ___ _____ _____ _ __ 57 | / _` | '__/ _ \| | | | '_ \ / _` |/ __/ _ \ \ / / _ \ '__| 58 | | (_| | | | (_) | |_| | | | | (_| | (_| (_) \ V / __/ | 59 | \__, |_| \___/ \__,_|_| |_|\__,_|\___\___/ \_/ \___|_| 60 | |___/ 61 | #NO TRADE-OFFS 62 | 63 | BANNER 64 | } 65 | 66 | parseArguments() { 67 | while [ "$#" -gt 0 ]; do 68 | case "$1" in 69 | -t | --token) 70 | token="$2" 71 | shift 2 72 | ;; 73 | -t=* | --token=*) 74 | token="${1#*=}" 75 | shift 1 76 | ;; 77 | *) 78 | error "Unknown option: $1" 79 | exit 1 80 | ;; 81 | esac 82 | done 83 | } 84 | 85 | # initArch discovers the architecture for this system. 86 | initArch() { 87 | ARCH=$(uname -m) 88 | case $ARCH in 89 | armv5*) ARCH="armv5";; 90 | armv6*) ARCH="armv6";; 91 | armv7*) ARCH="arm";; 92 | aarch64) ARCH="arm64";; 93 | x86) ARCH="386";; 94 | x86_64) ARCH="amd64";; 95 | i686) ARCH="386";; 96 | i386) ARCH="386";; 97 | esac 98 | } 99 | 100 | # initOS discovers the operating system for this system. 101 | initOS() { 102 | OS=$(uname |tr '[:upper:]' '[:lower:]') 103 | 104 | case "$OS" in 105 | # Minimalist GNU for Windows 106 | mingw*|cygwin*) OS='windows';; 107 | esac 108 | } 109 | 110 | # initLatestTag discovers latest version on GitHub releases. 111 | initLatestTag() { 112 | local latest_release_url="https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest" 113 | LATEST_TAG=$(curl -Ls "${latest_release_url}" | awk -F\" '/tag_name/{print $(NF-1)}') 114 | if [ -z "${LATEST_TAG}" ]; then 115 | error "Failed to fetch latest version from ${latest_release_url}" 116 | exit 1 117 | fi 118 | } 119 | 120 | # appendShellPath append our install bin directory to PATH on bash, zsh and fish shells 121 | appendShellPath() { 122 | local bashrc_file="${HOME}/.bashrc" 123 | if [ -f "${bashrc_file}" ]; then 124 | local export_path_expression="export PATH=${INSTALL_DIR}:\${PATH}" 125 | if ! grep -q "${export_path_expression}" "${bashrc_file}"; then 126 | printf "\n%s\n" "${export_path_expression}" >> "${bashrc_file}" 127 | completed "Added ${INSTALL_DIR} to \$PATH in ${bashrc_file}" 128 | fi 129 | fi 130 | 131 | local zshrc_file="${HOME}/.zshrc" 132 | if [ -f "${zshrc_file}" ] || [ "${OS}" = "darwin" ]; then 133 | local export_path_expression="export PATH=${INSTALL_DIR}:\${PATH}" 134 | if ! grep -q "${export_path_expression}" "${zshrc_file}"; then 135 | printf "\n%s\n" "${export_path_expression}" >> "${zshrc_file}" 136 | completed "Added ${INSTALL_DIR} to \$PATH in ${zshrc_file}" 137 | fi 138 | fi 139 | 140 | local fish_config_file="${HOME}/.config/fish/config.fish" 141 | if [ -f "${fish_config_file}" ]; then 142 | local export_path_expression="set -U fish_user_paths ${INSTALL_DIR} \$fish_user_paths" 143 | if ! grep -q "${export_path_expression}" "${fish_config_file}"; then 144 | printf "\n%s\n" "${export_path_expression}" >> "${fish_config_file}" 145 | completed "Added ${INSTALL_DIR} to \$PATH in ${fish_config_file}" 146 | fi 147 | fi 148 | } 149 | 150 | # verifySupported checks that the os/arch combination is supported for 151 | # binary builds, as well whether or not necessary tools are present. 152 | verifySupported() { 153 | local supported="darwin-amd64\ndarwin-arm64\nlinux-amd64\nlinux-arm64" 154 | if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then 155 | error "No prebuilt binary for ${OS}-${ARCH}." 156 | exit 1 157 | fi 158 | } 159 | 160 | # checkInstalledVersion checks which version of cli is installed and 161 | # if it needs to be changed. 162 | checkInstalledVersion() { 163 | if [ -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then 164 | local version 165 | version=$("${INSTALL_DIR}/${BINARY_NAME}" --skip-cli-update version) 166 | if [ "${version}" = "${LATEST_TAG#v}" ]; then 167 | completed "groundcover ${version} is already latest" 168 | return 0 169 | else 170 | info "groundcover ${LATEST_TAG} is available. Updating from version ${version}." 171 | return 1 172 | fi 173 | else 174 | return 1 175 | fi 176 | } 177 | 178 | # downloadFile downloads the latest binary package. 179 | downloadFile() { 180 | ARCHIVE_NAME="${BINARY_NAME}_${LATEST_TAG#v}_${OS}_${ARCH}.tar.gz" 181 | DOWNLOAD_URL="https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${LATEST_TAG}/${ARCHIVE_NAME}" 182 | TMP_ROOT="$(mktemp -dt groundcover-installer-XXXXXX)" 183 | ARCHIVE_TMP_PATH="${TMP_ROOT}/${ARCHIVE_NAME}" 184 | info "Downloading ${DOWNLOAD_URL}" 185 | curl -SsL "${DOWNLOAD_URL}" -o "${ARCHIVE_TMP_PATH}" 186 | } 187 | 188 | # installFile installs the cli binary. 189 | installFile() { 190 | tar xf "${ARCHIVE_TMP_PATH}" -C "${TMP_ROOT}" 191 | BIN_PATH="${INSTALL_DIR}/${BINARY_NAME}" 192 | BIN_TMP_PATH="${TMP_ROOT}/${BINARY_NAME}" 193 | info "Preparing to install ${BINARY_NAME} into ${INSTALL_DIR}" 194 | mkdir -p "${INSTALL_DIR}" 195 | cp "${BIN_TMP_PATH}" "${BIN_PATH}" 196 | chmod +x "${BIN_PATH}" 197 | completed "${BINARY_NAME} installed into ${BIN_PATH}" 198 | } 199 | 200 | # cleanup temporary files 201 | cleanup() { 202 | if [ -d "${TMP_ROOT:-}" ]; then 203 | rm -rf "${TMP_ROOT}" 204 | fi 205 | } 206 | 207 | printWhatNow() { 208 | printf "\n%s\ 209 | what now?\n\ 210 | * run ${GREEN}groundcover auth login${NO_COLOR}\n\ 211 | * then ${GREEN}groundcover deploy${NO_COLOR}\n\ 212 | * ${REV_BG}let the magic begin.${NO_COLOR}\n\n\ 213 | run ${BLUE}groundcover help${NO_COLOR}, or dive deeper with ${BLUE}${UNDERLINE}https://docs.groundcover.com/docs${NO_COLOR}.\n" 214 | } 215 | 216 | deployWithToken() { 217 | "${INSTALL_DIR}/${BINARY_NAME}" deploy --token "${token}" 218 | } 219 | 220 | # fail_trap is executed if an error occurs. 221 | fail_trap() { 222 | result=$? 223 | if [ "$result" != "0" ]; then 224 | error "Failed to install ${BINARY_NAME}" 225 | info "For support, go to ${BLUE}${UNDERLINE}https://github.com/groundcover-com/cli${NO_COLOR}" 226 | fi 227 | cleanup 228 | exit $result 229 | } 230 | 231 | # Execution 232 | 233 | #Stop execution on any error 234 | trap "fail_trap" EXIT 235 | set -e 236 | 237 | 238 | printBanner 239 | parseArguments "$@" 240 | initArch 241 | initOS 242 | initLatestTag 243 | if ! checkInstalledVersion; then 244 | downloadFile 245 | installFile 246 | fi 247 | appendShellPath 248 | completed "groundcover cli was successfully installed!" 249 | if [ -z "${token}" ] 250 | then 251 | printWhatNow 252 | cleanup 253 | exec "${SHELL}" # Reload shell 254 | else 255 | newline 256 | deployWithToken 257 | fi 258 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "os" 7 | "os/signal" 8 | 9 | "github.com/getsentry/sentry-go" 10 | "groundcover.com/cmd" 11 | "groundcover.com/pkg/segment" 12 | sentry_utils "groundcover.com/pkg/sentry" 13 | "groundcover.com/pkg/ui" 14 | "k8s.io/client-go/rest" 15 | "k8s.io/klog/v2" 16 | ) 17 | 18 | const APP_NAME = "cli" 19 | 20 | func main() { 21 | var err error 22 | 23 | klogFlagSet := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) 24 | klog.InitFlags(klogFlagSet) 25 | klogFlagSet.Set("logtostderr", "false") 26 | 27 | rest.SetDefaultWarningHandler(rest.NoWarnings{}) 28 | 29 | environment := "prod" 30 | if cmd.IsDevVersion() { 31 | environment = "dev" 32 | } 33 | 34 | sentryClientOptions := sentry_utils.GetSentryClientOptions(APP_NAME, environment, cmd.BinaryVersion) 35 | if err = sentry.Init(sentryClientOptions); err != nil { 36 | ui.GlobalWriter.PrintErrorMessageln(err.Error()) 37 | panic(err) 38 | } 39 | defer sentry.Flush(sentry_utils.FLUSH_TIMEOUT) 40 | 41 | segmentConfig := segment.GetConfig(APP_NAME, cmd.BinaryVersion) 42 | if err = segment.Init(segmentConfig); err != nil { 43 | ui.GlobalWriter.PrintErrorMessageln(err.Error()) 44 | panic(err) 45 | } 46 | defer segment.Close() 47 | 48 | ctx, cleanup := contextWithSignalInterrupt() 49 | defer cleanup() 50 | 51 | cmd.ExecuteContext(ctx) 52 | } 53 | 54 | func contextWithSignalInterrupt() (context.Context, func()) { 55 | signalChan := make(chan os.Signal, 1) 56 | signal.Notify(signalChan, os.Interrupt) 57 | 58 | ctx, cancel := context.WithCancel(context.Background()) 59 | 60 | cleanup := func() { 61 | signal.Stop(signalChan) 62 | cancel() 63 | } 64 | 65 | go func() { 66 | select { 67 | case <-signalChan: 68 | cancel() 69 | case <-ctx.Done(): 70 | } 71 | }() 72 | 73 | return ctx, cleanup 74 | } 75 | -------------------------------------------------------------------------------- /pkg/api/backend.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | ) 9 | 10 | const ( 11 | BACKEND_LIST_ENDPOINT = "backends/list" 12 | BACKEND_POLLING_RETRIES = 18 13 | BACKEND_POLLING_TIMEOUT = time.Minute * 3 14 | BACKEND_POLLING_INTERVAL = time.Second * 10 15 | ) 16 | 17 | type BackendInfo struct { 18 | Name string `json:"name"` 19 | Online bool `json:"online"` 20 | Licensed bool `json:"licensed"` 21 | Status string `json:"status"` 22 | InCloud bool `json:"inCloud"` 23 | } 24 | 25 | func (client *Client) BackendsList(tenantUUID string) ([]BackendInfo, error) { 26 | var err error 27 | 28 | var url *url.URL 29 | if url, err = client.JoinPath(BACKEND_LIST_ENDPOINT); err != nil { 30 | return nil, err 31 | } 32 | 33 | var request *http.Request 34 | if request, err = http.NewRequest(http.MethodGet, url.String(), nil); err != nil { 35 | return nil, err 36 | } 37 | 38 | request.Header.Add(TenantUUIDHeader, tenantUUID) 39 | 40 | var body []byte 41 | if body, err = client.do(request); err != nil { 42 | return nil, err 43 | } 44 | 45 | var backendList []BackendInfo 46 | if err = json.Unmarshal(body, &backendList); err != nil { 47 | return nil, err 48 | } 49 | 50 | return backendList, nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/url" 7 | 8 | "groundcover.com/pkg/auth" 9 | ) 10 | 11 | type TransportWithAuth0Token struct { 12 | http.RoundTripper 13 | auth0Token *auth.Auth0Token 14 | } 15 | 16 | func (transport *TransportWithAuth0Token) RoundTrip(request *http.Request) (*http.Response, error) { 17 | var err error 18 | 19 | var bearerToken string 20 | if bearerToken, err = transport.auth0Token.BearerToken(); err != nil { 21 | return nil, err 22 | } 23 | 24 | request.Header.Add("Authorization", bearerToken) 25 | return transport.RoundTripper.RoundTrip(request) 26 | } 27 | 28 | type Client struct { 29 | baseUrl *url.URL 30 | httpClient *http.Client 31 | } 32 | 33 | func NewClient(auth0Token *auth.Auth0Token) *Client { 34 | return &Client{ 35 | httpClient: &http.Client{ 36 | Transport: &TransportWithAuth0Token{ 37 | auth0Token: auth0Token, 38 | RoundTripper: http.DefaultTransport, 39 | }, 40 | }, 41 | baseUrl: &url.URL{ 42 | Scheme: "https", 43 | Path: "/api/", 44 | Host: "app.groundcover.com", 45 | }, 46 | } 47 | } 48 | 49 | func (client *Client) ApiKey(tenantUUID string) (*auth.ApiKey, error) { 50 | var err error 51 | 52 | var url *url.URL 53 | if url, err = client.JoinPath(auth.GenerateAPIKeyEndpoint); err != nil { 54 | return nil, err 55 | } 56 | 57 | var request *http.Request 58 | if request, err = http.NewRequest(http.MethodPost, url.String(), nil); err != nil { 59 | return nil, err 60 | } 61 | 62 | request.Header.Add(TenantUUIDHeader, tenantUUID) 63 | 64 | var body []byte 65 | if body, err = client.do(request); err != nil { 66 | return nil, err 67 | } 68 | 69 | apiKey := &auth.ApiKey{} 70 | if err = apiKey.ParseBody(body); err != nil { 71 | return nil, err 72 | } 73 | 74 | return apiKey, nil 75 | } 76 | 77 | func (client *Client) ServiceAccountToken(tenantUUID string) (*auth.SAToken, error) { 78 | var err error 79 | 80 | var url *url.URL 81 | if url, err = client.JoinPath(auth.GENERATE_SERVICE_ACCOUNT_TOKEN_ENDPOINT); err != nil { 82 | return nil, err 83 | } 84 | 85 | var request *http.Request 86 | if request, err = http.NewRequest(http.MethodPost, url.String(), nil); err != nil { 87 | return nil, err 88 | } 89 | 90 | request.Header.Add(TenantUUIDHeader, tenantUUID) 91 | 92 | var body []byte 93 | if body, err = client.do(request); err != nil { 94 | return nil, err 95 | } 96 | 97 | saToken := &auth.SAToken{} 98 | if err = saToken.ParseBody(body); err != nil { 99 | return nil, err 100 | } 101 | 102 | return saToken, nil 103 | } 104 | func (client *Client) GetDatasourcesAPIKey(tenant *TenantInfo, backendName string) (*auth.ApiKey, error) { 105 | var err error 106 | 107 | var url *url.URL 108 | if url, err = client.JoinPath(auth.GetDatasourcesAPIKeyEndpoint); err != nil { 109 | return nil, err 110 | } 111 | 112 | var request *http.Request 113 | if request, err = http.NewRequest(http.MethodPost, url.String(), nil); err != nil { 114 | return nil, err 115 | } 116 | 117 | request.Header.Add(TenantUUIDHeader, tenant.UUID) 118 | request.Header.Add(BackendIDHeader, backendName) 119 | 120 | var body []byte 121 | if body, err = client.do(request); err != nil { 122 | return nil, err 123 | } 124 | 125 | key := &auth.ApiKey{} 126 | if err = key.ParseBody(body); err != nil { 127 | return nil, err 128 | } 129 | 130 | return key, nil 131 | } 132 | 133 | func (client *Client) GetOrCreateClientToken(tenant *TenantInfo) (*auth.ApiKey, error) { 134 | var err error 135 | 136 | var url *url.URL 137 | if url, err = client.JoinPath(auth.GenerateClientTokenAPIKeyEndpoint); err != nil { 138 | return nil, err 139 | } 140 | 141 | var request *http.Request 142 | if request, err = http.NewRequest(http.MethodPost, url.String(), nil); err != nil { 143 | return nil, err 144 | } 145 | 146 | request.Header.Add(TenantUUIDHeader, tenant.UUID) 147 | 148 | var body []byte 149 | if body, err = client.do(request); err != nil { 150 | return nil, err 151 | } 152 | 153 | clientToken := &auth.ApiKey{} 154 | if err = clientToken.ParseBody(body); err != nil { 155 | return nil, err 156 | } 157 | 158 | return clientToken, nil 159 | } 160 | 161 | func (client *Client) JoinPath(endpoint string) (*url.URL, error) { 162 | return client.baseUrl.Parse(endpoint) 163 | } 164 | 165 | func (client *Client) do(request *http.Request) ([]byte, error) { 166 | var err error 167 | var response *http.Response 168 | 169 | if response, err = client.httpClient.Do(request); err != nil { 170 | return nil, err 171 | } 172 | defer response.Body.Close() 173 | 174 | if response.StatusCode != http.StatusOK { 175 | return nil, NewResponseError(response) 176 | } 177 | 178 | return io.ReadAll(io.Reader(response.Body)) 179 | } 180 | 181 | func (client *Client) get(endpoint string) ([]byte, error) { 182 | var err error 183 | 184 | var url *url.URL 185 | if url, err = client.JoinPath(endpoint); err != nil { 186 | return nil, err 187 | } 188 | 189 | var response *http.Response 190 | if response, err = client.httpClient.Get(url.String()); err != nil { 191 | return nil, err 192 | } 193 | defer response.Body.Close() 194 | 195 | if response.StatusCode != http.StatusOK { 196 | return nil, NewResponseError(response) 197 | } 198 | 199 | return io.ReadAll(io.Reader(response.Body)) 200 | } 201 | -------------------------------------------------------------------------------- /pkg/api/cluster.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "groundcover.com/pkg/ui" 13 | ) 14 | 15 | const ( 16 | SOURCES_LIST_ENDPOINT = "sources/list" 17 | CLUSTER_POLLING_RETRIES = 18 18 | CLUSTER_POLLING_TIMEOUT = time.Minute * 3 19 | CLUSTER_POLLING_INTERVAL = time.Second * 10 20 | ) 21 | 22 | type Conditions struct { 23 | Conditions []interface{} `json:"conditions"` 24 | } 25 | 26 | type SourceList struct { 27 | Env []string `json:"env"` 28 | Cluster []string `json:"cluster"` 29 | } 30 | 31 | func (client *Client) PollIsClusterExist(ctx context.Context, tenantUUID, backendName, clusterName string) error { 32 | var err error 33 | 34 | spinner := ui.GlobalWriter.NewSpinner("Waiting until groundcover is connected to cloud platform") 35 | spinner.SetStopMessage("groundcover is connected to cloud platform") 36 | spinner.SetStopFailMessage("groundcover is yet connected to cloud platform") 37 | 38 | spinner.Start() 39 | defer spinner.WriteStop() 40 | 41 | isClusterExistInSassFunc := func() error { 42 | var backendList []BackendInfo 43 | if backendList, err = client.BackendsList(tenantUUID); err != nil { 44 | return err 45 | } 46 | 47 | for _, backend := range backendList { 48 | if backend.Name == backendName && backend.Online { 49 | return nil 50 | } 51 | 52 | if backend.InCloud { 53 | var clusterNames []string 54 | if clusterNames, err = client.clusterList(tenantUUID, backend.Name); err != nil { 55 | return err 56 | } 57 | 58 | for _, name := range clusterNames { 59 | if name == clusterName { 60 | return nil 61 | } 62 | } 63 | } 64 | } 65 | 66 | return ui.RetryableError(err) 67 | } 68 | 69 | if err = spinner.Poll(ctx, isClusterExistInSassFunc, CLUSTER_POLLING_INTERVAL, CLUSTER_POLLING_TIMEOUT, CLUSTER_POLLING_RETRIES); err == nil { 70 | return nil 71 | } 72 | 73 | if err == nil { 74 | return nil 75 | } 76 | 77 | spinner.WriteStopFail() 78 | 79 | if errors.Is(err, ui.ErrSpinnerTimeout) { 80 | return errors.New("timeout waiting for groundcover to connect cloud platform") 81 | } 82 | 83 | return err 84 | } 85 | 86 | func (client *Client) clusterList(tenantUUID, backendName string) ([]string, error) { 87 | var err error 88 | 89 | var url *url.URL 90 | if url, err = client.JoinPath(SOURCES_LIST_ENDPOINT); err != nil { 91 | return nil, err 92 | } 93 | 94 | var payload []byte 95 | if payload, err = json.Marshal(Conditions{}); err != nil { 96 | return nil, err 97 | } 98 | 99 | var request *http.Request 100 | if request, err = http.NewRequest(http.MethodPost, url.String(), bytes.NewBuffer(payload)); err != nil { 101 | return nil, err 102 | } 103 | 104 | request.Header.Add(TenantUUIDHeader, tenantUUID) 105 | request.Header.Add(BackendIDHeader, backendName) 106 | request.Header.Add(ContentTypeHeader, ContentTypeJSON) 107 | 108 | var body []byte 109 | if body, err = client.do(request); err != nil { 110 | return nil, err 111 | } 112 | 113 | var sourceList SourceList 114 | if err = json.Unmarshal(body, &sourceList); err != nil { 115 | return nil, err 116 | } 117 | 118 | return sourceList.Cluster, nil 119 | } 120 | -------------------------------------------------------------------------------- /pkg/api/errors.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | type ResponseError struct { 9 | error 10 | statusCode int 11 | } 12 | 13 | func NewResponseError(response *http.Response) error { 14 | return ResponseError{ 15 | error: fmt.Errorf("%s - %s", response.Request.URL, response.Status), 16 | statusCode: response.StatusCode, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pkg/api/tenant.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | const ( 8 | TenantUUIDHeader = "X-Tenant-UUID" 9 | BackendIDHeader = "X-Backend-Id" 10 | ClusterIDHeader = "X-Cluster-Id" 11 | ContentTypeHeader = "Content-Type" 12 | ContentTypeJSON = "application/json" 13 | TenantListEndpoint = "rbac/member/tenants" 14 | ) 15 | 16 | type TenantListResponse struct { 17 | Tenants []*TenantInfo `json:"tenants"` 18 | } 19 | 20 | type TenantInfo struct { 21 | UUID string `json:"UUID"` 22 | OrgName string `json:"OrgName"` 23 | TenantName string `json:"TenantName"` 24 | } 25 | 26 | func (client *Client) TenantList() ([]*TenantInfo, error) { 27 | var err error 28 | 29 | var body []byte 30 | if body, err = client.get(TenantListEndpoint); err != nil { 31 | return nil, err 32 | } 33 | 34 | var tenantListResponse TenantListResponse 35 | if err = json.Unmarshal(body, &tenantListResponse); err != nil { 36 | return nil, err 37 | } 38 | 39 | return tenantListResponse.Tenants, nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/auth/api_key.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | const ( 8 | GenerateAPIKeyEndpoint = "system/generate-api-key" 9 | GetDatasourcesAPIKeyEndpoint = "system/get-datasources-api-key" 10 | GenerateClientTokenAPIKeyEndpoint = "system/generate-client-token" 11 | ) 12 | 13 | type ApiKey struct { 14 | ApiKey string `json:"apiKey" validate:"required"` 15 | } 16 | 17 | func (apiKey *ApiKey) ParseBody(body []byte) error { 18 | var err error 19 | 20 | if err = json.Unmarshal(body, &apiKey); err != nil { 21 | return err 22 | } 23 | 24 | if err = validate.Struct(apiKey); err != nil { 25 | return err 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/auth/auth0.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | 9 | "github.com/MicahParks/keyfunc" 10 | "github.com/golang-jwt/jwt/v4" 11 | "groundcover.com/pkg/utils" 12 | ) 13 | 14 | const ( 15 | TOKEN_ENDPOINT = "token" 16 | TOKEN_STORAGE_KEY = "auth.json" 17 | JWKS_ENDPOINT = "/.well-known/jwks.json" 18 | ) 19 | 20 | type Auth0Token struct { 21 | Claims Claims `json:"-"` 22 | ExpiresIn int64 `json:"expires_in" validate:"required"` 23 | AccessToken string `json:"access_token" validate:"required"` 24 | RefreshToken string `json:"refresh_token" validate:"required"` 25 | } 26 | 27 | type Claims struct { 28 | jwt.RegisteredClaims 29 | Scope string `json:"scope" validate:"required"` 30 | Org string `json:"https://client.info/org" validate:"required"` 31 | Email string `json:"https://client.info/email" validate:"required"` 32 | } 33 | 34 | func (c Claims) Valid() error { 35 | c.IssuedAt = nil 36 | return c.RegisteredClaims.Valid() 37 | } 38 | 39 | func LoadAuth0Token() (*Auth0Token, error) { 40 | var err error 41 | 42 | var data []byte 43 | if data, err = utils.PresistentStorage.Read(TOKEN_STORAGE_KEY); err != nil { 44 | return nil, err 45 | } 46 | 47 | auth0Token := &Auth0Token{} 48 | err = auth0Token.parseBody(data) 49 | 50 | if errors.Is(err, jwt.ErrTokenExpired) { 51 | err = auth0Token.RefreshAndSave() 52 | } 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return auth0Token, nil 59 | } 60 | 61 | func (auth0Token *Auth0Token) Save() error { 62 | var err error 63 | 64 | var data []byte 65 | if data, err = json.Marshal(auth0Token); err != nil { 66 | return err 67 | } 68 | 69 | return utils.PresistentStorage.Write(TOKEN_STORAGE_KEY, data) 70 | } 71 | 72 | func (auth0Token *Auth0Token) BearerToken() (string, error) { 73 | var err error 74 | 75 | err = auth0Token.Claims.Valid() 76 | 77 | if errors.Is(err, jwt.ErrTokenExpired) { 78 | err = auth0Token.RefreshAndSave() 79 | } 80 | 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | return fmt.Sprintf("Bearer %s", auth0Token.AccessToken), nil 86 | } 87 | 88 | func (auth0Token *Auth0Token) Fetch(data url.Values) error { 89 | var err error 90 | 91 | var body []byte 92 | if body, err = DefaultClient.PostForm(TOKEN_ENDPOINT, data); err != nil { 93 | return err 94 | } 95 | 96 | return auth0Token.parseBody(body) 97 | } 98 | 99 | func (auth0Token *Auth0Token) RefreshAndSave() error { 100 | var err error 101 | 102 | data := url.Values{} 103 | data.Set("grant_type", "refresh_token") 104 | data.Set("client_id", DefaultClient.ClientId) 105 | data.Set("refresh_token", auth0Token.RefreshToken) 106 | 107 | var body []byte 108 | if body, err = DefaultClient.PostForm(TOKEN_ENDPOINT, data); err != nil { 109 | return err 110 | } 111 | 112 | if err = auth0Token.parseBody(body); err != nil { 113 | return err 114 | } 115 | 116 | return auth0Token.Save() 117 | } 118 | 119 | func (auth0Token *Auth0Token) parseBody(body []byte) error { 120 | var err error 121 | 122 | if err = json.Unmarshal(body, auth0Token); err != nil { 123 | return err 124 | } 125 | 126 | if err = auth0Token.loadClaims(); err != nil { 127 | return err 128 | } 129 | 130 | if err = validate.Struct(auth0Token); err != nil { 131 | return err 132 | } 133 | 134 | return nil 135 | } 136 | 137 | func (auth0Token Auth0Token) GetId() string { 138 | return "" 139 | } 140 | 141 | func (auth0Token Auth0Token) GetOrg() string { 142 | return auth0Token.Claims.Org 143 | } 144 | 145 | func (auth0Token Auth0Token) GetEmail() string { 146 | return auth0Token.Claims.Email 147 | } 148 | 149 | func (auth0Token Auth0Token) GetSessionId() string { 150 | return "" 151 | } 152 | 153 | func (auth0Token *Auth0Token) loadClaims() error { 154 | var err error 155 | 156 | var jwksUrl *url.URL 157 | if jwksUrl, err = DefaultClient.JoinPath(JWKS_ENDPOINT); err != nil { 158 | return err 159 | } 160 | 161 | var jwks *keyfunc.JWKS 162 | if jwks, err = keyfunc.Get(jwksUrl.String(), keyfunc.Options{}); err != nil { 163 | return err 164 | } 165 | 166 | if _, err = jwt.ParseWithClaims(auth0Token.AccessToken, &auth0Token.Claims, jwks.Keyfunc); err != nil { 167 | return err 168 | } 169 | 170 | return nil 171 | } 172 | -------------------------------------------------------------------------------- /pkg/auth/client.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/url" 7 | ) 8 | 9 | var DefaultClient *Client = &Client{ 10 | httpClient: http.DefaultClient, 11 | Audience: "https://groundcover", 12 | Scope: "access:router offline_access", 13 | ClientId: "UkQmsxoqC8OzajqptiADtAZD6GS2mG9U", 14 | baseUrl: &url.URL{ 15 | Scheme: "https", 16 | Path: "/oauth/", 17 | Host: "auth.groundcover.com", 18 | }, 19 | } 20 | 21 | type Client struct { 22 | Scope string 23 | Audience string 24 | ClientId string 25 | baseUrl *url.URL 26 | httpClient *http.Client 27 | } 28 | 29 | func (client *Client) JoinPath(endpoint string) (*url.URL, error) { 30 | return client.baseUrl.Parse(endpoint) 31 | } 32 | 33 | func (client *Client) PostForm(endpoint string, data url.Values) ([]byte, error) { 34 | var err error 35 | 36 | var url *url.URL 37 | if url, err = client.JoinPath(endpoint); err != nil { 38 | return nil, err 39 | } 40 | 41 | var response *http.Response 42 | if response, err = client.httpClient.PostForm(url.String(), data); err != nil { 43 | return nil, err 44 | } 45 | defer response.Body.Close() 46 | 47 | var body []byte 48 | if body, err = io.ReadAll(response.Body); err != nil { 49 | return nil, err 50 | } 51 | 52 | if response.StatusCode != http.StatusOK { 53 | return nil, NewAuth0Error(body) 54 | } 55 | 56 | return body, nil 57 | } 58 | -------------------------------------------------------------------------------- /pkg/auth/device_code.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/url" 9 | "time" 10 | 11 | "groundcover.com/pkg/ui" 12 | ) 13 | 14 | const ( 15 | DEVICE_CODE_ENDPOINT = "device/code" 16 | DEVICE_CODE_POLLING_RETRIES = 10 17 | DEVICE_CODE_POLLING_TIMEOUT = time.Minute * 1 18 | DEVICE_CODE_POLLING_INTERVAL = time.Second * 7 19 | AUTH0_ACCOUNT_NOT_INVITED_ERROR = "access_denied: User has yet to receive an invitation." 20 | ) 21 | 22 | type DeviceCode struct { 23 | Interval int `json:"interval" validate:"required"` 24 | UserCode string `json:"user_code" validate:"required"` 25 | ExpiresIn int `json:"expires_in" validate:"required"` 26 | DeviceCode string `json:"device_code" validate:"required"` 27 | VerificationURI string `json:"verification_uri" validate:"required"` 28 | VerificationURIComplete string `json:"verification_uri_complete" validate:"required"` 29 | } 30 | 31 | func NewDeviceCode() (*DeviceCode, error) { 32 | var err error 33 | 34 | data := url.Values{} 35 | data.Set("scope", DefaultClient.Scope) 36 | data.Set("audience", DefaultClient.Audience) 37 | data.Set("client_id", DefaultClient.ClientId) 38 | 39 | var body []byte 40 | if body, err = DefaultClient.PostForm(DEVICE_CODE_ENDPOINT, data); err != nil { 41 | return nil, err 42 | } 43 | 44 | deviceCode := &DeviceCode{} 45 | if err = json.Unmarshal(body, &deviceCode); err != nil { 46 | return nil, err 47 | } 48 | 49 | if err = validate.Struct(deviceCode); err != nil { 50 | return nil, err 51 | } 52 | 53 | return deviceCode, nil 54 | } 55 | 56 | func (deviceCode *DeviceCode) PollToken(ctx context.Context, auth0Token *Auth0Token) error { 57 | var err error 58 | 59 | spinnerMessage := fmt.Sprintf("Waiting for device confirmation for: %s", deviceCode.UserCode) 60 | spinner := ui.GlobalWriter.NewSpinner(spinnerMessage) 61 | spinner.SetStopMessage("Device authentication confirmed by auth0") 62 | spinner.SetStopFailMessage("Device authentication failed") 63 | 64 | spinner.Start() 65 | defer spinner.WriteStop() 66 | 67 | data := url.Values{} 68 | data.Set("client_id", DefaultClient.ClientId) 69 | data.Set("device_code", deviceCode.DeviceCode) 70 | data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") 71 | 72 | fetchTokenFunc := func() error { 73 | err = auth0Token.Fetch(data) 74 | if err == nil { 75 | return nil 76 | } 77 | 78 | var auth0Err *Auth0Error 79 | if errors.As(err, &auth0Err) { 80 | if auth0Err.Type == "authorization_pending" { 81 | return ui.RetryableError(err) 82 | } 83 | } 84 | 85 | return err 86 | } 87 | 88 | err = spinner.Poll(ctx, fetchTokenFunc, DEVICE_CODE_POLLING_INTERVAL, DEVICE_CODE_POLLING_TIMEOUT, DEVICE_CODE_POLLING_RETRIES) 89 | 90 | if err == nil { 91 | return nil 92 | } 93 | 94 | spinner.WriteStopFail() 95 | 96 | if errors.Is(err, ui.ErrSpinnerTimeout) { 97 | return fmt.Errorf("timed out while waiting for your login in browser") 98 | } 99 | 100 | if err.Error() == AUTH0_ACCOUNT_NOT_INVITED_ERROR { 101 | return errors.New("sorry, we don't support private emails, please try again with your company email") 102 | } 103 | 104 | return err 105 | } 106 | -------------------------------------------------------------------------------- /pkg/auth/errors.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | type Auth0Error struct { 11 | error 12 | Type string `json:"error"` 13 | Description string `json:"error_description"` 14 | } 15 | 16 | func NewAuth0Error(body []byte) error { 17 | var auth0Error *Auth0Error 18 | 19 | if err := json.Unmarshal(body, &auth0Error); err != nil { 20 | return errors.Wrap(err, "failed to decode Auth0 error response") 21 | } 22 | 23 | auth0Error.error = fmt.Errorf("%s: %s", auth0Error.Type, auth0Error.Description) 24 | 25 | return auth0Error 26 | } 27 | -------------------------------------------------------------------------------- /pkg/auth/sa_token.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | const ( 8 | GENERATE_SERVICE_ACCOUNT_TOKEN_ENDPOINT = "system/generate-service-account-token" 9 | ) 10 | 11 | type SAToken struct { 12 | Token string `json:"token" validate:"required"` 13 | } 14 | 15 | func (sa *SAToken) ParseBody(body []byte) error { 16 | var err error 17 | 18 | if err = json.Unmarshal(body, &sa); err != nil { 19 | return err 20 | } 21 | 22 | if err = validate.Struct(sa); err != nil { 23 | return err 24 | } 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/auth/token.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/go-playground/validator/v10" 9 | ) 10 | 11 | var validate = validator.New() 12 | 13 | type Token interface { 14 | GetId() string 15 | GetOrg() string 16 | GetEmail() string 17 | GetSessionId() string 18 | } 19 | 20 | type InstallationToken struct { 21 | *ApiKey `validate:"required"` 22 | Id string `json:"id" validate:"required"` 23 | Org string `json:"org" validate:"required"` 24 | Email string `json:"email" validate:"required"` 25 | SessionId string `json:"sessionId" validate:"required"` 26 | Tenant string `json:"tenant"` 27 | TenantUUID string `json:"tenantUUID"` 28 | } 29 | 30 | func NewInstallationToken(encodedToken string) (*InstallationToken, error) { 31 | var err error 32 | 33 | if encodedToken == "" { 34 | return nil, fmt.Errorf("empty input token") 35 | } 36 | 37 | var data []byte 38 | if data, err = base64.StdEncoding.DecodeString(encodedToken); err != nil { 39 | return nil, err 40 | } 41 | 42 | token := &InstallationToken{} 43 | if err = json.Unmarshal(data, token); err != nil { 44 | return nil, err 45 | } 46 | 47 | if err = validate.Struct(token); err != nil { 48 | return nil, err 49 | } 50 | 51 | return token, nil 52 | } 53 | 54 | func (installationToken InstallationToken) GetId() string { 55 | return installationToken.Id 56 | } 57 | 58 | func (installationToken InstallationToken) GetOrg() string { 59 | return installationToken.Org 60 | } 61 | 62 | func (installationToken InstallationToken) GetEmail() string { 63 | return installationToken.Email 64 | } 65 | 66 | func (installationToken InstallationToken) GetSessionId() string { 67 | return installationToken.SessionId 68 | } 69 | -------------------------------------------------------------------------------- /pkg/auth/token_test.go: -------------------------------------------------------------------------------- 1 | package auth_test 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/go-playground/validator/v10" 9 | "github.com/google/uuid" 10 | "github.com/stretchr/testify/suite" 11 | "groundcover.com/pkg/auth" 12 | ) 13 | 14 | type AuthTokenTestSuite struct { 15 | suite.Suite 16 | } 17 | 18 | func (suite *AuthTokenTestSuite) SetupSuite() {} 19 | 20 | func (suite *AuthTokenTestSuite) TearDownSuite() {} 21 | 22 | func TestAuthTokenTestSuiteTestSuite(t *testing.T) { 23 | suite.Run(t, &AuthTokenTestSuite{}) 24 | } 25 | 26 | func (suite *AuthTokenTestSuite) TestParseInstallationTokenSuccess() { 27 | //prepare 28 | var err error 29 | 30 | token := map[string]string{ 31 | "id": "myid", 32 | "apiKey": "testApiKey", 33 | "org": "example.com", 34 | "email": "user@example.com", 35 | "sessionId": uuid.NewString(), 36 | "tenant": "example.com", 37 | "tenantUUID": uuid.NewString(), 38 | } 39 | 40 | var data []byte 41 | data, err = json.Marshal(token) 42 | suite.NoError(err) 43 | 44 | encodedToken := base64.StdEncoding.EncodeToString(data) 45 | 46 | //act 47 | var installationToken *auth.InstallationToken 48 | installationToken, err = auth.NewInstallationToken(encodedToken) 49 | 50 | // assert 51 | 52 | expected := &auth.InstallationToken{ 53 | Id: token["id"], 54 | Org: token["org"], 55 | Email: token["email"], 56 | SessionId: token["sessionId"], 57 | Tenant: token["tenant"], 58 | TenantUUID: token["tenantUUID"], 59 | ApiKey: &auth.ApiKey{ApiKey: token["apiKey"]}, 60 | } 61 | 62 | suite.NoError(err) 63 | 64 | suite.Equal(expected, installationToken) 65 | } 66 | 67 | func (suite *AuthTokenTestSuite) TestParseInstallationTokenValidationError() { 68 | //prepare 69 | var err error 70 | 71 | token := map[string]string{ 72 | "id-bad": "myid", 73 | "apiKey-bad": "testApiKey", 74 | "org-bad": "example.com", 75 | "email-bad": "user@example.com", 76 | "sessionId-bad": uuid.NewString(), 77 | "tenant-bad": "example.com", 78 | "tenantUUID-bad": uuid.NewString(), 79 | } 80 | 81 | var data []byte 82 | data, err = json.Marshal(token) 83 | suite.NoError(err) 84 | 85 | encodedToken := base64.StdEncoding.EncodeToString(data) 86 | 87 | //act 88 | _, err = auth.NewInstallationToken(encodedToken) 89 | 90 | // assert 91 | expected := []string{ 92 | "Key: 'InstallationToken.ApiKey' Error:Field validation for 'ApiKey' failed on the 'required' tag", 93 | "Key: 'InstallationToken.Id' Error:Field validation for 'Id' failed on the 'required' tag", 94 | "Key: 'InstallationToken.Org' Error:Field validation for 'Org' failed on the 'required' tag", 95 | "Key: 'InstallationToken.Email' Error:Field validation for 'Email' failed on the 'required' tag", 96 | "Key: 'InstallationToken.SessionId' Error:Field validation for 'SessionId' failed on the 'required' tag", 97 | } 98 | 99 | validationErrors, _ := err.(validator.ValidationErrors) 100 | suite.Len(validationErrors, 5) 101 | 102 | var errs []string 103 | for _, validationError := range validationErrors { 104 | errs = append(errs, validationError.Error()) 105 | } 106 | 107 | suite.Equal(expected, errs) 108 | } 109 | -------------------------------------------------------------------------------- /pkg/helm/chart.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "github.com/blang/semver/v4" 5 | "helm.sh/helm/v3/pkg/action" 6 | "helm.sh/helm/v3/pkg/chart" 7 | "helm.sh/helm/v3/pkg/chart/loader" 8 | ) 9 | 10 | type Chart struct { 11 | *chart.Chart 12 | } 13 | 14 | func (chart *Chart) Version() semver.Version { 15 | version, _ := semver.Parse(chart.Metadata.Version) 16 | return version 17 | } 18 | 19 | func (helmClient *Client) GetChart(name, version string) (*Chart, error) { 20 | var err error 21 | var chartPath string 22 | var chart *chart.Chart 23 | 24 | client := action.NewShowWithConfig(action.ShowChart, helmClient.cfg) 25 | client.ChartPathOptions.Version = version 26 | 27 | if chartPath, err = client.ChartPathOptions.LocateChart(name, helmClient.settings); err != nil { 28 | return nil, err 29 | } 30 | 31 | if chart, err = loader.Load(chartPath); err != nil { 32 | return nil, err 33 | } 34 | 35 | return &Chart{Chart: chart}, nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/helm/client.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/containerd/containerd/log" 9 | "groundcover.com/pkg/k8s" 10 | "groundcover.com/pkg/utils" 11 | "helm.sh/helm/v3/pkg/action" 12 | "helm.sh/helm/v3/pkg/cli" 13 | "k8s.io/cli-runtime/pkg/genericclioptions" 14 | "k8s.io/client-go/rest" 15 | ) 16 | 17 | type Client struct { 18 | settings *cli.EnvSettings 19 | cfg *action.Configuration 20 | } 21 | 22 | func NewHelmClient(namespace, kubecontext string) (*Client, error) { 23 | var err error 24 | 25 | helmPath := filepath.Join(utils.PresistentStorage.BasePath, "helm") 26 | 27 | os.Setenv("HELM_DATA_HOME", helmPath) 28 | os.Setenv("HELM_CACHE_HOME", helmPath) 29 | os.Setenv("HELM_CONFIG_HOME", helmPath) 30 | 31 | helmClient := &Client{ 32 | settings: cli.New(), 33 | cfg: new(action.Configuration), 34 | } 35 | 36 | helmClient.settings.Debug = true 37 | helmClient.settings.SetNamespace(namespace) 38 | helmClient.settings.KubeContext = kubecontext 39 | 40 | getter, ok := helmClient.settings.RESTClientGetter().(*genericclioptions.ConfigFlags) 41 | if !ok { 42 | return nil, errors.New("failed to cast helm rest client getter") 43 | } 44 | 45 | getter.WrapConfigFn = func(restConfig *rest.Config) *rest.Config { 46 | k8s.OverrideDepartedAuthenticationApiVersion(restConfig) 47 | return restConfig 48 | } 49 | 50 | if err = helmClient.cfg.Init(getter, helmClient.settings.Namespace(), os.Getenv("HELM_DRIVER"), log.L.Debugf); err != nil { 51 | return nil, err 52 | } 53 | 54 | return helmClient, nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/helm/presets/agent/kernel-5-11.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | agent: 3 | sensor: 4 | resources: 5 | requests: 6 | memory: 1Gi 7 | limits: 8 | memory: 1Gi 9 | -------------------------------------------------------------------------------- /pkg/helm/presets/agent/low-resources.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | agent: 3 | sensor: 4 | resources: 5 | requests: 6 | cpu: 100m 7 | memory: 128Mi 8 | limits: 9 | cpu: 500m 10 | memory: 512Mi 11 | -------------------------------------------------------------------------------- /pkg/helm/presets/backend/custom-metrics.yaml: -------------------------------------------------------------------------------- 1 | custom-metrics: 2 | enabled: true -------------------------------------------------------------------------------- /pkg/helm/presets/backend/high-resources.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | k8sWatcher: 3 | resources: 4 | requests: 5 | cpu: 50m 6 | memory: 300Mi 7 | limits: 8 | cpu: 500m 9 | memory: 1024Mi 10 | 11 | portal: 12 | resources: 13 | requests: 14 | cpu: 50m 15 | memory: 100Mi 16 | limits: 17 | memory: 256Mi 18 | 19 | clickhouse: 20 | resources: 21 | requests: 22 | cpu: 600m 23 | memory: 4096Mi 24 | limits: 25 | memory: 6000Mi 26 | 27 | opentelemetry-collector: 28 | replicaCount: 2 29 | resources: 30 | requests: 31 | cpu: 500m 32 | memory: 1024Mi 33 | limits: 34 | memory: 2048Mi 35 | 36 | victoria-metrics-agent: 37 | resources: 38 | requests: 39 | cpu: 100m 40 | memory: 128Mi 41 | limits: 42 | memory: 512Mi 43 | 44 | metrics-ingester: 45 | resources: 46 | limits: 47 | cpu: 750m 48 | memory: 512Mi 49 | requests: 50 | memory: 256Mi 51 | 52 | custom-metrics: 53 | extraArgs: 54 | remoteWrite.maxHourlySeries: "1000000" 55 | remoteWrite.maxDailySeries: "10000000" 56 | resources: 57 | requests: 58 | cpu: 500m 59 | memory: 512Mi 60 | limits: 61 | memory: 1Gi 62 | 63 | victoria-metrics-single: 64 | server: 65 | resources: 66 | requests: 67 | cpu: 1000m 68 | memory: 5000Mi 69 | limits: 70 | memory: 5000Mi 71 | 72 | monitors-manager: 73 | resources: 74 | requests: 75 | cpu: 40m 76 | memory: 160Mi 77 | limits: 78 | memory: 512Mi 79 | 80 | 81 | backend: 82 | postgresql: 83 | primary: 84 | resources: 85 | requests: 86 | cpu: 20m 87 | memory: 80Mi 88 | limits: 89 | memory: 256Mi 90 | -------------------------------------------------------------------------------- /pkg/helm/presets/backend/huge-resources.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | k8sWatcher: 3 | resources: 4 | requests: 5 | cpu: 200m 6 | memory: 300Mi 7 | limits: 8 | cpu: 1000m 9 | memory: 1024Mi 10 | 11 | portal: 12 | resources: 13 | requests: 14 | cpu: 50m 15 | memory: 100Mi 16 | limits: 17 | memory: 256Mi 18 | 19 | clickhouse: 20 | resources: 21 | requests: 22 | cpu: 1000m 23 | memory: 4Gi 24 | limits: 25 | memory: 12Gi 26 | 27 | opentelemetry-collector: 28 | replicaCount: 3 29 | resources: 30 | requests: 31 | cpu: 1000m 32 | memory: 1024Mi 33 | limits: 34 | memory: 2048Mi 35 | 36 | victoria-metrics-agent: 37 | resources: 38 | requests: 39 | cpu: 200m 40 | memory: 256Mi 41 | limits: 42 | memory: 1Gi 43 | 44 | metrics-ingester: 45 | resources: 46 | limits: 47 | cpu: 1000m 48 | memory: 2Gi 49 | requests: 50 | memory: 1Gi 51 | 52 | custom-metrics: 53 | extraArgs: 54 | remoteWrite.maxHourlySeries: "10000000" 55 | remoteWrite.maxDailySeries: "100000000" 56 | resources: 57 | requests: 58 | cpu: 500m 59 | memory: 1Gi 60 | limits: 61 | memory: 2Gi 62 | 63 | victoria-metrics-single: 64 | server: 65 | resources: 66 | requests: 67 | cpu: 1000m 68 | memory: 5000Mi 69 | limits: 70 | memory: 6Gi 71 | 72 | monitors-manager: 73 | resources: 74 | requests: 75 | cpu: 100m 76 | memory: 256Mi 77 | limits: 78 | memory: 512Mi 79 | 80 | 81 | backend: 82 | postgresql: 83 | primary: 84 | resources: 85 | requests: 86 | cpu: 90m 87 | memory: 200Mi 88 | limits: 89 | memory: 400Mi -------------------------------------------------------------------------------- /pkg/helm/presets/backend/kube-state-metrics.yaml: -------------------------------------------------------------------------------- 1 | kube-state-metrics: 2 | enabled: true -------------------------------------------------------------------------------- /pkg/helm/presets/backend/low-resources.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | k8sWatcher: 3 | resources: 4 | requests: 5 | cpu: 10m 6 | memory: 256Mi 7 | limits: 8 | cpu: 500m 9 | memory: 1024Mi 10 | 11 | portal: 12 | resources: 13 | requests: 14 | cpu: 5m 15 | memory: 64Mi 16 | limits: 17 | memory: 128Mi 18 | 19 | opentelemetry-collector: 20 | resources: 21 | requests: 22 | cpu: 50m 23 | memory: 256Mi 24 | limits: 25 | memory: 2048Mi 26 | 27 | victoria-metrics-agent: 28 | resources: 29 | requests: 30 | cpu: 16m 31 | memory: 32Mi 32 | limits: 33 | memory: 256Mi 34 | 35 | metrics-ingester: 36 | resources: 37 | requests: 38 | cpu: 50m 39 | memory: 256Mi 40 | limits: 41 | memory: 512Mi 42 | 43 | custom-metrics: 44 | resources: 45 | requests: 46 | cpu: 50m 47 | memory: 64Mi 48 | limits: 49 | memory: 256Mi 50 | 51 | victoria-metrics-single: 52 | server: 53 | resources: 54 | requests: 55 | cpu: 20m 56 | memory: 128Mi 57 | limits: 58 | memory: 1024Mi 59 | 60 | monitors-manager: 61 | resources: 62 | requests: 63 | cpu: 5m 64 | memory: 110Mi 65 | limits: 66 | memory: 150Mi 67 | 68 | backend: 69 | postgresql: 70 | primary: 71 | resources: 72 | requests: 73 | cpu: 5m 74 | memory: 40Mi 75 | limits: 76 | memory: 60Mi 77 | keep: 78 | backend: 79 | resources: 80 | requests: 81 | cpu: 100m 82 | memory: 128Mi 83 | 84 | vector: 85 | replicas: 1 86 | resources: 87 | requests: 88 | cpu: 50m 89 | memory: 256Mi 90 | limits: 91 | memory: 1024Mi 92 | -------------------------------------------------------------------------------- /pkg/helm/presets/quay.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | origin: 3 | registry: quay.io/groundcover 4 | 5 | clickhouse: 6 | image: 7 | registry: quay.io/groundcover 8 | volumePermissions: 9 | image: 10 | registry: quay.io/groundcover 11 | 12 | opentelemetry-collector: 13 | image: 14 | repository: quay.io/groundcover/otel/opentelemetry-collector-contrib 15 | 16 | victoria-metrics-single: 17 | server: 18 | image: 19 | repository: quay.io/groundcover/victoria-metrics 20 | -------------------------------------------------------------------------------- /pkg/helm/release.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/blang/semver/v4" 8 | "helm.sh/helm/v3/pkg/action" 9 | "helm.sh/helm/v3/pkg/release" 10 | "helm.sh/helm/v3/pkg/storage/driver" 11 | ) 12 | 13 | type Release struct { 14 | *release.Release 15 | } 16 | 17 | func (release *Release) Version() semver.Version { 18 | version, _ := semver.Parse(release.Chart.Metadata.Version) 19 | return version 20 | } 21 | 22 | func (helmClient *Client) IsReleaseInstalled(name string) (*Release, bool, error) { 23 | var err error 24 | 25 | client := action.NewStatus(helmClient.cfg) 26 | 27 | var release Release 28 | release.Release, err = client.Run(name) 29 | 30 | switch { 31 | case errors.Is(err, driver.ErrReleaseNotFound): 32 | return nil, false, nil 33 | case err != nil: 34 | return nil, false, err 35 | default: 36 | return &release, true, nil 37 | } 38 | } 39 | 40 | func (helmClient *Client) GetCurrentRelease(name string) (*Release, error) { 41 | var err error 42 | 43 | client := action.NewStatus(helmClient.cfg) 44 | 45 | var release Release 46 | if release.Release, err = client.Run(name); err != nil { 47 | return nil, err 48 | } 49 | 50 | return &release, nil 51 | } 52 | 53 | func (helmClient *Client) Install(ctx context.Context, name string, chart *Chart, values map[string]interface{}) (*Release, error) { 54 | var err error 55 | 56 | client := action.NewInstall(helmClient.cfg) 57 | client.Wait = false 58 | client.ReleaseName = name 59 | client.CreateNamespace = true 60 | client.Namespace = helmClient.settings.Namespace() 61 | 62 | var release Release 63 | if release.Release, err = client.RunWithContext(ctx, chart.Chart, values); err != nil { 64 | return nil, err 65 | } 66 | 67 | return &release, nil 68 | } 69 | 70 | func (helmClient *Client) Upgrade(ctx context.Context, name string, chart *Chart, values map[string]interface{}) (*Release, error) { 71 | var err error 72 | 73 | client := action.NewUpgrade(helmClient.cfg) 74 | client.Wait = false 75 | client.ReuseValues = false 76 | client.Namespace = helmClient.settings.Namespace() 77 | 78 | var release Release 79 | release.Release, err = client.RunWithContext(ctx, name, chart.Chart, values) 80 | 81 | switch { 82 | case err == nil: 83 | return &release, nil 84 | case errors.Is(err, driver.ErrNoDeployedReleases), errors.Is(err, driver.ErrReleaseNotFound): 85 | return helmClient.Install(ctx, name, chart, values) 86 | default: 87 | return nil, err 88 | } 89 | } 90 | 91 | func (helmClient *Client) Uninstall(name string) error { 92 | var err error 93 | 94 | client := action.NewUninstall(helmClient.cfg) 95 | client.Wait = false 96 | 97 | if _, err = client.Run(name); err != nil { 98 | return err 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/helm/repo.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "helm.sh/helm/v3/pkg/getter" 6 | "helm.sh/helm/v3/pkg/repo" 7 | ) 8 | 9 | const ( 10 | REPOSITORY_CONFIG_FILE_MODE = 0644 11 | ) 12 | 13 | func (helmClient *Client) AddRepo(name, url string) error { 14 | var err error 15 | 16 | repoEntry := &repo.Entry{ 17 | URL: url, 18 | Name: name, 19 | } 20 | 21 | var chartRepo *repo.ChartRepository 22 | if chartRepo, err = repo.NewChartRepository(repoEntry, getter.All(helmClient.settings)); err != nil { 23 | return err 24 | } 25 | 26 | if _, err = chartRepo.DownloadIndexFile(); err != nil { 27 | return errors.Wrap(err, "couldn't connect to helm repo, please make sure you are connected to the internet") 28 | } 29 | 30 | repoFile := repo.NewFile() 31 | repoFile.Add(repoEntry) 32 | 33 | return repoFile.WriteFile(helmClient.settings.RepositoryConfig, REPOSITORY_CONFIG_FILE_MODE) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/helm/templates/backend/storage-class.yaml: -------------------------------------------------------------------------------- 1 | clickhouse: 2 | persistence: 3 | storageClass: "{{ .StorageClassName }}" 4 | 5 | victoria-metrics-single: 6 | server: 7 | persistentVolume: 8 | storageClass: "{{ .StorageClassName }}" 9 | -------------------------------------------------------------------------------- /pkg/helm/tune.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "embed" 5 | 6 | "github.com/blang/semver/v4" 7 | "groundcover.com/pkg/k8s" 8 | "k8s.io/apimachinery/pkg/api/resource" 9 | ) 10 | 11 | const ( 12 | DEFAULT_PRESET = "" 13 | 14 | HIGH_RESOURCES_CLUSTER_NODE_COUNT = 30 15 | HUGE_RESOURCES_CLUSTER_NODE_COUNT = 100 16 | 17 | AGENT_DEFAULT_CPU_THRESHOLD = "1000m" 18 | AGENT_DEFAULT_MEMORY_THRESHOLD = "1024Mi" 19 | AGENT_LOW_RESOURCES_PATH = "presets/agent/low-resources.yaml" 20 | 21 | // Starting from Linux kernel version 5.11, eBPF maps are accounted for in the memory cgroup 22 | // of the process that created them. For this reason we need to increase the memory limit for 23 | // the agent. 24 | // https://github.com/cilium/ebpf/blob/v0.16.0/docs/ebpf/concepts/rlimit.md#resource-limits 25 | AGENT_KERNEL_5_11_PRESET_PATH = "presets/agent/kernel-5-11.yaml" 26 | KERNEL_5_11_SEMVER_EXPRESSION = ">=5.11.0" 27 | 28 | EMPTYDIR_STORAGE_PATH = "presets/backend/emptydir-storage.yaml" 29 | 30 | BACKEND_DEFAULT_TOTAL_CPU_THRESHOLD = "12000m" 31 | BACKEND_DEFAULT_TOTAL_MEMORY_THRESHOLD = "20000Mi" 32 | BACKEND_HIGH_TOTAL_CPU_THRESHOLD = "30000m" 33 | BACKEND_HIGH_TOTAL_MEMORY_THRESHOLD = "60000Mi" 34 | BACKEND_LOW_RESOURCES_PATH = "presets/backend/low-resources.yaml" 35 | BACKEND_HIGH_RESOURCES_PATH = "presets/backend/high-resources.yaml" 36 | BACKEND_HUGE_RESOURCES_PATH = "presets/backend/huge-resources.yaml" 37 | ) 38 | 39 | //go:embed presets/* 40 | var presetsFS embed.FS 41 | 42 | type AllocatableResources struct { 43 | MinCpu *resource.Quantity 44 | MinMemory *resource.Quantity 45 | TotalCpu *resource.Quantity 46 | TotalMemory *resource.Quantity 47 | NodeCount int 48 | } 49 | 50 | func GetAgentResourcePresetPath(allocatableResources *AllocatableResources, maxKernelVersion semver.Version) string { 51 | defaultCpuThreshold := resource.MustParse(AGENT_DEFAULT_CPU_THRESHOLD) 52 | defaultMemoryThreshold := resource.MustParse(AGENT_DEFAULT_MEMORY_THRESHOLD) 53 | 54 | minAllocatableCpu := allocatableResources.MinCpu.AsApproximateFloat64() 55 | minAllocatableMemory := allocatableResources.MinMemory.AsApproximateFloat64() 56 | 57 | if minAllocatableCpu <= defaultCpuThreshold.AsApproximateFloat64() || minAllocatableMemory <= defaultMemoryThreshold.AsApproximateFloat64() { 58 | return AGENT_LOW_RESOURCES_PATH 59 | } 60 | 61 | if semver.MustParseRange(KERNEL_5_11_SEMVER_EXPRESSION)(maxKernelVersion) { 62 | return AGENT_KERNEL_5_11_PRESET_PATH 63 | } 64 | 65 | return DEFAULT_PRESET 66 | } 67 | 68 | func GetBackendResourcePresetPath(allocatableResources *AllocatableResources) string { 69 | defaultCpuThreshold := resource.MustParse(BACKEND_DEFAULT_TOTAL_CPU_THRESHOLD) 70 | defaultMemoryThreshold := resource.MustParse(BACKEND_DEFAULT_TOTAL_MEMORY_THRESHOLD) 71 | 72 | highCpuThreshold := resource.MustParse(BACKEND_HIGH_TOTAL_CPU_THRESHOLD) 73 | highMemoryThreshold := resource.MustParse(BACKEND_HIGH_TOTAL_MEMORY_THRESHOLD) 74 | 75 | totalAllocatableCpu := allocatableResources.TotalCpu.AsApproximateFloat64() 76 | totalAllocatableMemory := allocatableResources.TotalMemory.AsApproximateFloat64() 77 | 78 | var presetPath string 79 | switch { 80 | case totalAllocatableCpu <= defaultCpuThreshold.AsApproximateFloat64(), totalAllocatableMemory <= defaultMemoryThreshold.AsApproximateFloat64(): 81 | presetPath = BACKEND_LOW_RESOURCES_PATH 82 | case totalAllocatableCpu <= highCpuThreshold.AsApproximateFloat64(), totalAllocatableMemory <= highMemoryThreshold.AsApproximateFloat64(): 83 | presetPath = DEFAULT_PRESET 84 | case allocatableResources.NodeCount < HUGE_RESOURCES_CLUSTER_NODE_COUNT: 85 | presetPath = BACKEND_HIGH_RESOURCES_PATH 86 | default: 87 | return BACKEND_HUGE_RESOURCES_PATH 88 | } 89 | 90 | return presetPath 91 | } 92 | 93 | func CalcAllocatableResources(nodesSummaries []*k8s.NodeSummary) *AllocatableResources { 94 | allocatableResources := &AllocatableResources{ 95 | MinCpu: nodesSummaries[0].CPU, 96 | MinMemory: nodesSummaries[0].Memory, 97 | TotalCpu: &resource.Quantity{}, 98 | TotalMemory: &resource.Quantity{}, 99 | NodeCount: len(nodesSummaries), 100 | } 101 | 102 | for _, nodeSummary := range nodesSummaries { 103 | if len(nodeSummary.Taints) > 0 || nodeSummary.IsArm64() { 104 | continue 105 | } 106 | 107 | allocatableResources.TotalCpu.Add(*nodeSummary.CPU) 108 | allocatableResources.TotalMemory.Add(*nodeSummary.Memory) 109 | 110 | if allocatableResources.MinCpu.Cmp(*nodeSummary.CPU) > 0 { 111 | allocatableResources.MinCpu = nodeSummary.CPU 112 | } 113 | 114 | if allocatableResources.MinMemory.Cmp(*nodeSummary.Memory) > 0 { 115 | allocatableResources.MinMemory = nodeSummary.Memory 116 | } 117 | } 118 | 119 | return allocatableResources 120 | } 121 | -------------------------------------------------------------------------------- /pkg/helm/tune_test.go: -------------------------------------------------------------------------------- 1 | package helm_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/blang/semver/v4" 7 | "github.com/stretchr/testify/assert" 8 | "groundcover.com/pkg/helm" 9 | "groundcover.com/pkg/k8s" 10 | v1 "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/api/resource" 12 | ) 13 | 14 | var ( 15 | Kernel510Semver = semver.MustParse("5.10.0") 16 | Kernel511Semver = semver.MustParse("5.11.0") 17 | ) 18 | 19 | func TestTuneResourcesValuesAgentLow(t *testing.T) { 20 | // arrange 21 | agentLowCpu := resource.MustParse(helm.AGENT_DEFAULT_CPU_THRESHOLD) 22 | agentLowCpu.Sub(*resource.NewMilliQuantity(1, resource.DecimalSI)) 23 | 24 | agentLowMemory := resource.MustParse(helm.AGENT_DEFAULT_MEMORY_THRESHOLD) 25 | agentLowMemory.Sub(*resource.NewQuantity(1, resource.BinarySI)) 26 | 27 | lowNodeReport := []*k8s.NodeSummary{ 28 | { 29 | CPU: &agentLowCpu, 30 | Memory: &agentLowMemory, 31 | }, 32 | } 33 | 34 | resources := helm.CalcAllocatableResources(lowNodeReport) 35 | 36 | // act 37 | cpu := helm.GetAgentResourcePresetPath(resources, Kernel510Semver) 38 | 39 | // assert 40 | assert.Equal(t, helm.AGENT_LOW_RESOURCES_PATH, cpu) 41 | } 42 | 43 | func TestTuneResourcesValuesAgentDefault(t *testing.T) { 44 | // arrange 45 | agentDefaultCpu := resource.MustParse(helm.AGENT_DEFAULT_CPU_THRESHOLD) 46 | agentDefaultCpu.Add(*resource.NewMilliQuantity(1, resource.DecimalSI)) 47 | 48 | agentDefaultMemory := resource.MustParse(helm.AGENT_DEFAULT_MEMORY_THRESHOLD) 49 | agentDefaultMemory.Add(*resource.NewQuantity(1, resource.BinarySI)) 50 | 51 | defaultNodeReport := []*k8s.NodeSummary{ 52 | { 53 | CPU: &agentDefaultCpu, 54 | Memory: &agentDefaultMemory, 55 | }, 56 | } 57 | 58 | resources := helm.CalcAllocatableResources(defaultNodeReport) 59 | 60 | // act 61 | cpu := helm.GetAgentResourcePresetPath(resources, Kernel510Semver) 62 | 63 | // assert 64 | assert.Equal(t, helm.DEFAULT_PRESET, cpu) 65 | } 66 | 67 | func TestTuneResourcesValuesAgentNewKernel(t *testing.T) { 68 | // arrange 69 | agentDefaultCpu := resource.MustParse(helm.AGENT_DEFAULT_CPU_THRESHOLD) 70 | agentDefaultCpu.Add(*resource.NewMilliQuantity(1, resource.DecimalSI)) 71 | 72 | agentDefaultMemory := resource.MustParse(helm.AGENT_DEFAULT_MEMORY_THRESHOLD) 73 | agentDefaultMemory.Add(*resource.NewQuantity(1, resource.BinarySI)) 74 | 75 | defaultNodeReport := []*k8s.NodeSummary{ 76 | { 77 | CPU: &agentDefaultCpu, 78 | Memory: &agentDefaultMemory, 79 | }, 80 | } 81 | 82 | resources := helm.CalcAllocatableResources(defaultNodeReport) 83 | 84 | // act 85 | cpu := helm.GetAgentResourcePresetPath(resources, Kernel511Semver) 86 | 87 | // assert 88 | assert.Equal(t, helm.AGENT_KERNEL_5_11_PRESET_PATH, cpu) 89 | } 90 | 91 | func TestTuneResourcesValuesBackendLow(t *testing.T) { 92 | // arrange 93 | backendLowCpu := resource.MustParse(helm.BACKEND_DEFAULT_TOTAL_CPU_THRESHOLD) 94 | backendLowCpu.Sub(*resource.NewMilliQuantity(1, resource.DecimalSI)) 95 | 96 | backendLowMemory := resource.MustParse(helm.BACKEND_DEFAULT_TOTAL_MEMORY_THRESHOLD) 97 | backendLowMemory.Sub(*resource.NewQuantity(1, resource.BinarySI)) 98 | 99 | lowNodeReport := []*k8s.NodeSummary{ 100 | { 101 | CPU: &backendLowCpu, 102 | Memory: &backendLowMemory, 103 | }, 104 | } 105 | 106 | resources := helm.CalcAllocatableResources(lowNodeReport) 107 | 108 | // act 109 | cpu := helm.GetBackendResourcePresetPath(resources) 110 | 111 | // assert 112 | assert.Equal(t, helm.BACKEND_LOW_RESOURCES_PATH, cpu) 113 | } 114 | 115 | func TestTuneResourcesValuesBackendDefault(t *testing.T) { 116 | // arrange 117 | backendDefaultCpu := resource.MustParse(helm.BACKEND_DEFAULT_TOTAL_CPU_THRESHOLD) 118 | backendDefaultCpu.Add(*resource.NewMilliQuantity(1, resource.DecimalSI)) 119 | 120 | backendDefaultMemory := resource.MustParse(helm.BACKEND_DEFAULT_TOTAL_MEMORY_THRESHOLD) 121 | backendDefaultMemory.Add(*resource.NewQuantity(1, resource.BinarySI)) 122 | 123 | defaultNodeReport := []*k8s.NodeSummary{ 124 | { 125 | CPU: &backendDefaultCpu, 126 | Memory: &backendDefaultMemory, 127 | }, 128 | } 129 | 130 | resources := helm.CalcAllocatableResources(defaultNodeReport) 131 | 132 | // act 133 | cpu := helm.GetBackendResourcePresetPath(resources) 134 | 135 | // assert 136 | assert.Equal(t, helm.DEFAULT_PRESET, cpu) 137 | } 138 | 139 | func TestTuneResourcesValuesBackendHigh(t *testing.T) { 140 | // arrange 141 | backendHighCpu := resource.MustParse(helm.BACKEND_HIGH_TOTAL_CPU_THRESHOLD) 142 | backendHighCpu.Add(*resource.NewMilliQuantity(1, resource.DecimalSI)) 143 | 144 | backendHighMemory := resource.MustParse(helm.BACKEND_HIGH_TOTAL_MEMORY_THRESHOLD) 145 | backendHighMemory.Add(*resource.NewQuantity(1, resource.BinarySI)) 146 | 147 | nodes := []*k8s.NodeSummary{} 148 | 149 | for i := 0; i < helm.HUGE_RESOURCES_CLUSTER_NODE_COUNT-1; i++ { 150 | nodes = append(nodes, &k8s.NodeSummary{ 151 | CPU: &backendHighCpu, 152 | Memory: &backendHighMemory, 153 | }) 154 | } 155 | 156 | resources := helm.CalcAllocatableResources(nodes) 157 | 158 | // act 159 | cpu := helm.GetBackendResourcePresetPath(resources) 160 | 161 | // assert 162 | assert.Equal(t, helm.BACKEND_HIGH_RESOURCES_PATH, cpu) 163 | } 164 | 165 | func TestTuneResourcesValuesBackendHuge(t *testing.T) { 166 | // arrange 167 | backendHighCpu := resource.MustParse(helm.BACKEND_HIGH_TOTAL_CPU_THRESHOLD) 168 | backendHighCpu.Add(*resource.NewMilliQuantity(1, resource.DecimalSI)) 169 | 170 | backendHighMemory := resource.MustParse(helm.BACKEND_HIGH_TOTAL_MEMORY_THRESHOLD) 171 | backendHighMemory.Add(*resource.NewQuantity(1, resource.BinarySI)) 172 | 173 | nodes := []*k8s.NodeSummary{} 174 | 175 | for i := 0; i <= helm.HUGE_RESOURCES_CLUSTER_NODE_COUNT; i++ { 176 | nodes = append(nodes, &k8s.NodeSummary{ 177 | CPU: &backendHighCpu, 178 | Memory: &backendHighMemory, 179 | }) 180 | } 181 | 182 | resources := helm.CalcAllocatableResources(nodes) 183 | 184 | // act 185 | cpu := helm.GetBackendResourcePresetPath(resources) 186 | 187 | // assert 188 | assert.Equal(t, helm.BACKEND_HUGE_RESOURCES_PATH, cpu) 189 | } 190 | 191 | func TestCalcAllocatableResourcesSingleNode(t *testing.T) { 192 | // arrange 193 | nodes := []*k8s.NodeSummary{ 194 | { 195 | CPU: resource.NewMilliQuantity(1000, resource.DecimalSI), 196 | Memory: resource.NewQuantity(1000, resource.BinarySI), 197 | }, 198 | } 199 | 200 | // act 201 | resources := helm.CalcAllocatableResources(nodes) 202 | 203 | // assert 204 | assert.Equal(t, resource.NewMilliQuantity(1000, resource.DecimalSI), resources.MinCpu) 205 | assert.Equal(t, resource.NewQuantity(1000, resource.BinarySI), resources.MinMemory) 206 | assert.Equal(t, resource.NewMilliQuantity(1000, resource.DecimalSI), resources.TotalCpu) 207 | assert.Equal(t, resource.NewQuantity(1000, resource.BinarySI), resources.TotalMemory) 208 | } 209 | 210 | func TestCalcAllocatableResourcesMultiNode(t *testing.T) { 211 | // arrange 212 | nodes := []*k8s.NodeSummary{ 213 | { 214 | CPU: resource.NewMilliQuantity(2000, resource.DecimalSI), 215 | Memory: resource.NewQuantity(2000, resource.BinarySI), 216 | }, 217 | { 218 | CPU: resource.NewMilliQuantity(1000, resource.DecimalSI), 219 | Memory: resource.NewQuantity(1000, resource.BinarySI), 220 | }, 221 | } 222 | 223 | // act 224 | resources := helm.CalcAllocatableResources(nodes) 225 | 226 | // assert 227 | assert.Equal(t, resource.NewMilliQuantity(1000, resource.DecimalSI), resources.MinCpu) 228 | assert.Equal(t, resource.NewQuantity(1000, resource.BinarySI), resources.MinMemory) 229 | assert.Equal(t, resource.NewMilliQuantity(3000, resource.DecimalSI), resources.TotalCpu) 230 | assert.Equal(t, resource.NewQuantity(3000, resource.BinarySI), resources.TotalMemory) 231 | } 232 | 233 | func TestCalcAllocatableResourcesMultiNodeWithTaints(t *testing.T) { 234 | // arrange 235 | nodes := []*k8s.NodeSummary{ 236 | { 237 | CPU: resource.NewMilliQuantity(2000, resource.DecimalSI), 238 | Memory: resource.NewQuantity(2000, resource.BinarySI), 239 | }, 240 | { 241 | CPU: resource.NewMilliQuantity(1000, resource.DecimalSI), 242 | Memory: resource.NewQuantity(1000, resource.BinarySI), 243 | Taints: []v1.Taint{ 244 | { 245 | Key: "key", 246 | }, 247 | }, 248 | }, 249 | } 250 | 251 | // act 252 | resources := helm.CalcAllocatableResources(nodes) 253 | 254 | // assert 255 | assert.Equal(t, resource.NewMilliQuantity(2000, resource.DecimalSI), resources.MinCpu) 256 | assert.Equal(t, resource.NewQuantity(2000, resource.BinarySI), resources.MinMemory) 257 | assert.Equal(t, resource.NewMilliQuantity(2000, resource.DecimalSI), resources.TotalCpu) 258 | assert.Equal(t, resource.NewQuantity(2000, resource.BinarySI), resources.TotalMemory) 259 | } 260 | 261 | func TestCalcAllocatableResourcesMultiNodeWithArmArch(t *testing.T) { 262 | // arrange 263 | nodes := []*k8s.NodeSummary{ 264 | { 265 | CPU: resource.NewMilliQuantity(2000, resource.DecimalSI), 266 | Memory: resource.NewQuantity(2000, resource.BinarySI), 267 | }, 268 | { 269 | Architecture: "arm64", 270 | CPU: resource.NewMilliQuantity(1000, resource.DecimalSI), 271 | Memory: resource.NewQuantity(1000, resource.BinarySI), 272 | }, 273 | } 274 | 275 | // act 276 | resources := helm.CalcAllocatableResources(nodes) 277 | 278 | // assert 279 | assert.Equal(t, resource.NewMilliQuantity(2000, resource.DecimalSI), resources.MinCpu) 280 | assert.Equal(t, resource.NewQuantity(2000, resource.BinarySI), resources.MinMemory) 281 | assert.Equal(t, resource.NewMilliQuantity(2000, resource.DecimalSI), resources.TotalCpu) 282 | assert.Equal(t, resource.NewQuantity(2000, resource.BinarySI), resources.TotalMemory) 283 | } 284 | -------------------------------------------------------------------------------- /pkg/helm/values.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | textTemplate "text/template" 13 | 14 | "github.com/imdario/mergo" 15 | "gopkg.in/yaml.v3" 16 | ) 17 | 18 | //go:embed templates/* 19 | var templatesFS embed.FS 20 | 21 | type TemplateValues struct { 22 | StorageClassName string 23 | } 24 | 25 | func GetChartValuesOverrides(paths []string, templateValues *TemplateValues) (map[string]interface{}, error) { 26 | var err error 27 | 28 | valuesOverride := make(map[string]interface{}) 29 | 30 | for _, path := range paths { 31 | if path == "" { 32 | continue 33 | } 34 | 35 | var data []byte 36 | if data, err = readValuesOverride(path, templateValues); err != nil { 37 | return nil, err 38 | } 39 | 40 | var currentValuesOverrides map[string]interface{} 41 | if err = yaml.Unmarshal(data, ¤tValuesOverrides); err != nil { 42 | return nil, err 43 | } 44 | 45 | if err = mergo.Merge(&valuesOverride, currentValuesOverrides, mergo.WithOverride); err != nil { 46 | return nil, err 47 | } 48 | } 49 | 50 | return valuesOverride, nil 51 | } 52 | 53 | func readTemplateOverride(path string, templateValues *TemplateValues) ([]byte, error) { 54 | var err error 55 | var template *textTemplate.Template 56 | if template, err = textTemplate.ParseFS(templatesFS, path); err != nil { 57 | return nil, err 58 | } 59 | 60 | buffer := new(bytes.Buffer) 61 | if err = template.Execute(buffer, templateValues); err != nil { 62 | return nil, err 63 | } 64 | 65 | return buffer.Bytes(), nil 66 | } 67 | 68 | func readValuesOverride(path string, templateValues *TemplateValues) ([]byte, error) { 69 | var err error 70 | 71 | if strings.HasPrefix(path, "templates") { 72 | return readTemplateOverride(path, templateValues) 73 | } 74 | 75 | if strings.HasPrefix(path, "presets") { 76 | return presetsFS.ReadFile(path) 77 | } 78 | 79 | overrideUrl, err := url.ParseRequestURI(path) 80 | if err == nil && overrideUrl.IsAbs() { 81 | var response *http.Response 82 | if response, err = http.Get(path); err != nil { 83 | return nil, err 84 | } 85 | defer response.Body.Close() 86 | 87 | if response.StatusCode != http.StatusOK { 88 | return nil, fmt.Errorf("[%d] %s download failed", response.StatusCode, path) 89 | } 90 | 91 | return io.ReadAll(response.Body) 92 | } 93 | 94 | return os.ReadFile(path) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/helm/values_test.go: -------------------------------------------------------------------------------- 1 | package helm_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/suite" 11 | "gopkg.in/yaml.v3" 12 | "groundcover.com/pkg/helm" 13 | ) 14 | 15 | type HelmValuesTestSuite struct { 16 | suite.Suite 17 | ValuesUrl string 18 | ValuesFile string 19 | OverrideUrl string 20 | file *os.File 21 | server *httptest.Server 22 | } 23 | 24 | func (suite *HelmValuesTestSuite) SetupSuite() { 25 | var err error 26 | 27 | var urlData []byte 28 | if urlData, err = yaml.Marshal(map[string]interface{}{"url": "value"}); err != nil { 29 | suite.T().Fatal(err) 30 | } 31 | 32 | suite.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | if r.RequestURI == "/values.yaml" { 34 | _, err = w.Write(urlData) 35 | suite.NoError(err) 36 | } 37 | })) 38 | 39 | suite.ValuesUrl = fmt.Sprintf("%s/values.yaml", suite.server.URL) 40 | suite.OverrideUrl = fmt.Sprintf("%s/override.yaml", suite.server.URL) 41 | 42 | var fileData []byte 43 | if fileData, err = yaml.Marshal(map[string]interface{}{"file": "value"}); err != nil { 44 | suite.T().Fatal(err) 45 | } 46 | 47 | if suite.file, err = os.CreateTemp("", "values"); err != nil { 48 | suite.T().Fatal(err) 49 | } 50 | defer suite.file.Close() 51 | 52 | if _, err = suite.file.Write(fileData); err != nil { 53 | suite.T().Fatal(err) 54 | } 55 | 56 | suite.ValuesFile = suite.file.Name() 57 | } 58 | 59 | func (suite *HelmValuesTestSuite) TearDownSuite() { 60 | suite.server.Close() 61 | os.Remove(suite.file.Name()) 62 | } 63 | 64 | func TestHelmValuesTestSuite(t *testing.T) { 65 | suite.Run(t, &HelmValuesTestSuite{}) 66 | } 67 | 68 | func (suite *HelmValuesTestSuite) TestOrderChartValuesOverrideSuccess() { 69 | //prepare 70 | templateValues := helm.TemplateValues{} 71 | fileData, err := yaml.Marshal(map[string]interface{}{"file": "override"}) 72 | suite.NoError(err) 73 | 74 | file, err := os.CreateTemp("", "values") 75 | suite.NoError(err) 76 | defer file.Close() 77 | defer os.Remove(file.Name()) 78 | 79 | _, err = file.Write(fileData) 80 | suite.NoError(err) 81 | 82 | overridePaths := []string{suite.ValuesFile, file.Name()} 83 | 84 | //act 85 | chartValues, err := helm.GetChartValuesOverrides(overridePaths, &templateValues) 86 | suite.NoError(err) 87 | 88 | // assert 89 | 90 | expected := map[string]interface{}{ 91 | "file": "override", 92 | } 93 | 94 | suite.Equal(expected, chartValues) 95 | } 96 | 97 | func (suite *HelmValuesTestSuite) TestMultiPathsChartValuesOverrideSuccess() { 98 | //prepare 99 | templateValues := helm.TemplateValues{} 100 | overridePaths := []string{suite.ValuesUrl, suite.ValuesFile} 101 | 102 | //act 103 | chartValues, err := helm.GetChartValuesOverrides(overridePaths, &templateValues) 104 | suite.NoError(err) 105 | 106 | // assert 107 | 108 | expected := map[string]interface{}{ 109 | "file": "value", 110 | "url": "value", 111 | } 112 | 113 | suite.Equal(expected, chartValues) 114 | } 115 | 116 | func (suite *HelmValuesTestSuite) TestUrlChartValuesOverrideSuccess() { 117 | //prepare 118 | templateValues := helm.TemplateValues{} 119 | overridePaths := []string{suite.ValuesUrl} 120 | 121 | //act 122 | chartValues, err := helm.GetChartValuesOverrides(overridePaths, &templateValues) 123 | suite.NoError(err) 124 | 125 | // assert 126 | 127 | expected := map[string]interface{}{ 128 | "url": "value", 129 | } 130 | 131 | suite.Equal(expected, chartValues) 132 | } 133 | 134 | func (suite *HelmValuesTestSuite) TestFileChartValuesOverrideSuccess() { 135 | //prepare 136 | templateValues := helm.TemplateValues{} 137 | overridePaths := []string{suite.ValuesFile} 138 | 139 | //act 140 | chartValues, err := helm.GetChartValuesOverrides(overridePaths, &templateValues) 141 | suite.NoError(err) 142 | 143 | // assert 144 | expected := map[string]interface{}{ 145 | "file": "value", 146 | } 147 | 148 | suite.Equal(expected, chartValues) 149 | } 150 | -------------------------------------------------------------------------------- /pkg/k8s/auth.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | "regexp" 8 | 9 | "github.com/blang/semver/v4" 10 | "github.com/pkg/errors" 11 | "groundcover.com/pkg/ui" 12 | authv1 "k8s.io/api/authorization/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | restclient "k8s.io/client-go/rest" 15 | ) 16 | 17 | const ( 18 | GKE_GCLOUD_AUTH_PLUGIN_MISSING = "no Auth Provider found for name \"gcp\"" 19 | HINT_GKE_GCLOUD_AUTH_PLUGIN_INSTALL = `Hint: 20 | * Install gke-gcloud-auth-plugin by following https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke 21 | ` 22 | 23 | EKS_AUTH_PLUGIN_OUTDATED = "exec plugin: invalid apiVersion \"client.authentication.k8s.io/v1alpha1\"" 24 | HINT_EKS_AUTH_PLUGIN_UPGRADE = `Hint: 25 | * Upgrade AWS CLI by following https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html 26 | * Update your kubeconfig by following https://docs.aws.amazon.com/eks/latest/userguide/create-kubeconfig.html` 27 | HINT_INSTALL_AWS_CLI = `Hint: 28 | * Install aws cli by following https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html` 29 | ) 30 | 31 | var ( 32 | DefaultAwsCliVersionValidator = &AwsCliVersionValidator{ 33 | Regexp: regexp.MustCompile(`^aws-cli/(\d+\.\d+\.\d+)`), 34 | MinimumSupportedV1Version: semver.Version{Major: 1, Minor: 23, Patch: 9}, 35 | MinimumSupportedV2Version: semver.Version{Major: 2, Minor: 7, Patch: 0}, 36 | } 37 | ) 38 | 39 | type AwsCliVersionValidator struct { 40 | Regexp *regexp.Regexp 41 | MinimumSupportedV1Version semver.Version 42 | MinimumSupportedV2Version semver.Version 43 | } 44 | 45 | func (validator *AwsCliVersionValidator) wrapError(err error) error { 46 | return errors.Wrapf(err, "failed getting aws cli version (required v%s+/v%s+), got", validator.MinimumSupportedV1Version, validator.MinimumSupportedV2Version) 47 | } 48 | 49 | func (validator *AwsCliVersionValidator) Fetch(ctx context.Context) (semver.Version, error) { 50 | var err error 51 | var version semver.Version 52 | 53 | var versionByte []byte 54 | if versionByte, err = exec.CommandContext(ctx, "aws", "--version").Output(); err != nil { 55 | return version, validator.wrapError(err) 56 | } 57 | 58 | return validator.Parse(string(versionByte)) 59 | } 60 | 61 | func (validator *AwsCliVersionValidator) Parse(versionString string) (semver.Version, error) { 62 | var version semver.Version 63 | 64 | matches := validator.Regexp.FindStringSubmatch(versionString) 65 | if len(matches) != 2 { 66 | return version, validator.wrapError(fmt.Errorf("unknown aws cli version: %q", versionString)) 67 | } 68 | 69 | return semver.Parse(matches[1]) 70 | } 71 | 72 | func (validator *AwsCliVersionValidator) Validate(version semver.Version) error { 73 | switch version.Major { 74 | case 1: 75 | if version.LT(validator.MinimumSupportedV1Version) { 76 | return fmt.Errorf("aws-cli version is unsupported (%s < %s)", version, validator.MinimumSupportedV1Version) 77 | } 78 | case 2: 79 | if version.LT(validator.MinimumSupportedV2Version) { 80 | return fmt.Errorf("aws-cli version is unsupported (%s < %s)", version, validator.MinimumSupportedV2Version) 81 | } 82 | default: 83 | return fmt.Errorf("aws-cli version %s is unsupported", version) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func OverrideDepartedAuthenticationApiVersion(restConfig *restclient.Config) { 90 | if restConfig.ExecProvider == nil { 91 | return 92 | } 93 | 94 | if restConfig.ExecProvider.APIVersion == "client.authentication.k8s.io/v1alpha1" { 95 | restConfig.ExecProvider.APIVersion = "client.authentication.k8s.io/v1beta1" 96 | } 97 | 98 | } 99 | 100 | func (kubeClient *Client) isActionPermitted(ctx context.Context, action *authv1.ResourceAttributes) (bool, error) { 101 | var err error 102 | 103 | accessReview := &authv1.SelfSubjectAccessReview{ 104 | Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: action}, 105 | } 106 | 107 | if accessReview, err = kubeClient.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, accessReview, metav1.CreateOptions{}); err != nil { 108 | return false, errors.Wrapf(err, "api error on resource: %s", action.Resource) 109 | } 110 | 111 | if accessReview.Status.Denied || !accessReview.Status.Allowed { 112 | return false, nil 113 | } 114 | 115 | return true, nil 116 | } 117 | 118 | func (kubeClient *Client) printHintIfAuthError(err error) error { 119 | switch err.Error() { 120 | case EKS_AUTH_PLUGIN_OUTDATED: 121 | ui.GlobalWriter.PrintWarningMessage(fmt.Sprintf("%s\n%s", err, HINT_EKS_AUTH_PLUGIN_UPGRADE)) 122 | case GKE_GCLOUD_AUTH_PLUGIN_MISSING: 123 | ui.GlobalWriter.PrintWarningMessage(fmt.Sprintf("%s\n%s", err, HINT_GKE_GCLOUD_AUTH_PLUGIN_INSTALL)) 124 | default: 125 | return err 126 | } 127 | 128 | var clusterName string 129 | if clusterName, err = kubeClient.GetClusterName(); err != nil { 130 | clusterName = "cluster" 131 | } 132 | 133 | return fmt.Errorf("authentication failure to %s", clusterName) 134 | } 135 | -------------------------------------------------------------------------------- /pkg/k8s/auth_test.go: -------------------------------------------------------------------------------- 1 | package k8s_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/suite" 9 | "groundcover.com/pkg/k8s" 10 | "k8s.io/client-go/kubernetes/fake" 11 | ) 12 | 13 | type KubeAuthTestSuite struct { 14 | suite.Suite 15 | KubeClient k8s.Client 16 | } 17 | 18 | func (suite *KubeAuthTestSuite) SetupTest() { 19 | suite.KubeClient = k8s.Client{ 20 | Interface: fake.NewSimpleClientset(), 21 | } 22 | } 23 | 24 | func (suite *KubeAuthTestSuite) TearDownSuite() {} 25 | 26 | func TestKubeAuthTestSuite(t *testing.T) { 27 | suite.Run(t, &KubeAuthTestSuite{}) 28 | } 29 | 30 | func TestValidateAwsCliVersionSupported(t *testing.T) { 31 | testCases := []struct { 32 | name string 33 | version string 34 | expected error 35 | }{ 36 | { 37 | name: "aws cli version 1.18.0", 38 | version: "aws-cli/1.18.0 Python/3.7.4 Darwin/19.4.0 botocore/1.17.0", 39 | expected: errors.New("aws-cli version is unsupported (1.18.0 < 1.23.9)"), 40 | }, 41 | { 42 | name: "aws cli version 1.23.9", 43 | version: "aws-cli/1.23.9 Python/3.7.4 Darwin/19.4.0 botocore/1.23.9", 44 | expected: nil, 45 | }, 46 | { 47 | name: "aws cli version 1.23.10", 48 | version: "aws-cli/1.23.10 Python/3.7.4 Darwin/19.4.0 botocore/1.23.10", 49 | expected: nil, 50 | }, 51 | { 52 | name: "aws cli version 2.0.0", 53 | version: "aws-cli/2.0.0 Python/3.7.4 Darwin/19.4.0 botocore/2.0.0dev0", 54 | expected: errors.New("aws-cli version is unsupported (2.0.0 < 2.7.0)"), 55 | }, 56 | { 57 | name: "aws cli version 2.7.0", 58 | version: "aws-cli/2.7.0 Python/3.7.4 Darwin/19.4.0 botocore/2.7.0", 59 | expected: nil, 60 | }, 61 | { 62 | name: "aws cli version 2.7.1", 63 | version: "aws-cli/2.7.1 Python/3.7.4 Darwin/19.4.0 botocore/2.7.1", 64 | expected: nil, 65 | }, 66 | { 67 | name: "aws cli version 3.0.0", 68 | version: "aws-cli/3.0.0 Python/3.7.4 Darwin/19.4.0 botocore/3.0.0", 69 | expected: errors.New("aws-cli version 3.0.0 is unsupported"), 70 | }, 71 | { 72 | name: "aws cli version 0.9.0", 73 | version: "aws-cli/0.9.0 Python/3.7.4 Darwin/19.4.0 botocore/0.9.0", 74 | expected: errors.New("aws-cli version 0.9.0 is unsupported"), 75 | }, 76 | } 77 | 78 | for _, tc := range testCases { 79 | t.Run(tc.name, func(t *testing.T) { 80 | // act 81 | version, err := k8s.DefaultAwsCliVersionValidator.Parse(tc.version) 82 | 83 | if err == nil { 84 | err = k8s.DefaultAwsCliVersionValidator.Validate(version) 85 | } 86 | 87 | // assert 88 | assert.Equal(t, tc.expected, err) 89 | }) 90 | } 91 | } 92 | 93 | func (suite *KubeAuthTestSuite) TestAwsCliVersionValidatorParseError() { 94 | // act 95 | version, err := k8s.DefaultAwsCliVersionValidator.Parse("aws-cli-bad/5.4.2") 96 | 97 | if err == nil { 98 | err = k8s.DefaultAwsCliVersionValidator.Validate(version) 99 | } 100 | 101 | // assert 102 | suite.ErrorContains(err, "failed getting aws cli version (required v1.23.9+/v2.7.0+), got: unknown aws cli version: \"aws-cli-bad/5.4.2\"") 103 | } 104 | -------------------------------------------------------------------------------- /pkg/k8s/client.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | 8 | "k8s.io/client-go/kubernetes" 9 | _ "k8s.io/client-go/plugin/pkg/client/auth" 10 | restclient "k8s.io/client-go/rest" 11 | "k8s.io/client-go/tools/clientcmd" 12 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api" 13 | ) 14 | 15 | type Client struct { 16 | kubernetes.Interface 17 | clientcmd.ClientConfig 18 | kubecontext string 19 | } 20 | 21 | func NewKubeClient(kubeconfig, kubecontext string) (*Client, error) { 22 | var err error 23 | 24 | kubeClient := new(Client) 25 | 26 | if err = kubeClient.loadConfig(kubeconfig, kubecontext); err != nil { 27 | return nil, err 28 | } 29 | 30 | if err = kubeClient.loadClient(); err != nil { 31 | return nil, err 32 | } 33 | 34 | if err = kubeClient.validateClusterConnectivity(); err != nil { 35 | return nil, fmt.Errorf("couldn't connect to context: %s. maybe do you need to connect via VPN?", kubeClient.kubecontext) 36 | } 37 | 38 | return kubeClient, nil 39 | } 40 | 41 | func (kubeClient *Client) loadConfig(kubeconfig, kubecontext string) error { 42 | var err error 43 | 44 | configOverrides := &clientcmd.ConfigOverrides{CurrentContext: kubecontext} 45 | configLoader := &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig} 46 | kubeClient.ClientConfig = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(configLoader, configOverrides) 47 | 48 | if kubecontext != "" { 49 | kubeClient.kubecontext = kubecontext 50 | return nil 51 | } 52 | 53 | if kubeClient.kubecontext, err = kubeClient.defaultContext(); err != nil { 54 | return err 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (kubeClient *Client) defaultContext() (string, error) { 61 | var err error 62 | var rawConfig clientcmdapi.Config 63 | 64 | if rawConfig, err = kubeClient.RawConfig(); err == nil { 65 | return rawConfig.CurrentContext, nil 66 | } 67 | 68 | var pathErr *fs.PathError 69 | if errors.As(err, &pathErr) { 70 | return "", fmt.Errorf("kubeconfig not found in %s, you can override the path with --kubeconfig flag", pathErr.Path) 71 | } 72 | 73 | return "", err 74 | } 75 | 76 | func (kubeClient *Client) loadClient() error { 77 | var err error 78 | var restConfig *restclient.Config 79 | 80 | if restConfig, err = kubeClient.ClientConfig.ClientConfig(); err != nil { 81 | return err 82 | } 83 | 84 | OverrideDepartedAuthenticationApiVersion(restConfig) 85 | 86 | if kubeClient.Interface, err = kubernetes.NewForConfig(restConfig); err != nil { 87 | return kubeClient.printHintIfAuthError(err) 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func (kubeClient *Client) validateClusterConnectivity() error { 94 | _, err := kubeClient.Discovery().ServerVersion() 95 | return err 96 | } 97 | -------------------------------------------------------------------------------- /pkg/k8s/cluster.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/blang/semver/v4" 10 | "github.com/pkg/errors" 11 | authv1 "k8s.io/api/authorization/v1" 12 | v1 "k8s.io/api/storage/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/version" 15 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api" 16 | ) 17 | 18 | const ( 19 | CLUSTER_TYPE_REPORT_MESSAGE_FORMAT = "K8s cluster type supported" 20 | CLUSTER_VERSION_REPORT_MESSAGE_FORMAT = "K8s server version >= %s" 21 | CLUSTER_AUTHORIZATION_REPORT_MESSAGE_FORMAT = "K8s user authorized for groundcover installation" 22 | CLUSTER_CLI_AUTH_SUPPORTED = "K8s CLI auth supported" 23 | CLUSTER_STORAGE_SUPPORTED = "K8s storage provision supported" 24 | ) 25 | 26 | var ( 27 | gkeClusterRegex = regexp.MustCompile("^gke_(?P.+)_(?P.+)_(?P.+)$") 28 | eksClusterRegex = regexp.MustCompile("^arn:aws:eks:(?P.+):(?P.+):cluster/(?P.+)$") 29 | 30 | LocalClusterTypes = []string{ 31 | "k3d", 32 | "kind", 33 | "minikube", 34 | "docker-desktop", 35 | } 36 | 37 | MinimumServerVersionSupport = semver.Version{Major: 1, Minor: 12} 38 | DefaultClusterRequirements = &ClusterRequirements{ 39 | ServerVersion: MinimumServerVersionSupport, 40 | BlockedTypes: []string{ 41 | "docker-desktop", 42 | }, 43 | Actions: []*authv1.ResourceAttributes{ 44 | { 45 | Verb: "*", 46 | Resource: "clusterroles", 47 | }, 48 | { 49 | Verb: "*", 50 | Resource: "configmaps", 51 | }, 52 | { 53 | Verb: "*", 54 | Resource: "daemonsets", 55 | }, 56 | { 57 | Verb: "*", 58 | Resource: "deployments", 59 | }, 60 | { 61 | Verb: "*", 62 | Resource: "ingresses", 63 | }, 64 | { 65 | Verb: "*", 66 | Resource: "namespaces", 67 | }, 68 | { 69 | Verb: "*", 70 | Resource: "nodes", 71 | }, 72 | { 73 | Verb: "*", 74 | Resource: "pods", 75 | }, 76 | { 77 | Verb: "*", 78 | Resource: "secrets", 79 | }, 80 | { 81 | Verb: "*", 82 | Resource: "services", 83 | }, 84 | { 85 | Verb: "*", 86 | Resource: "statefulsets", 87 | }, 88 | { 89 | Verb: "*", 90 | Resource: "persistentvolumeclaims", 91 | }, 92 | { 93 | Verb: "*", 94 | Resource: "persistentvolumes", 95 | }, 96 | }, 97 | } 98 | ) 99 | 100 | type ClusterRequirements struct { 101 | Actions []*authv1.ResourceAttributes 102 | ServerVersion semver.Version 103 | BlockedTypes []string 104 | } 105 | 106 | type ClusterSummary struct { 107 | Namespace string 108 | ClusterName string 109 | ServerVersion semver.Version 110 | StorageClass *v1.StorageClass 111 | } 112 | 113 | func (kubeClient *Client) GetClusterSummary(ctx context.Context, namespace, storageClassName string) (*ClusterSummary, error) { 114 | var err error 115 | 116 | clusterSummary := &ClusterSummary{ 117 | Namespace: namespace, 118 | } 119 | 120 | if clusterSummary.ClusterName, err = kubeClient.GetClusterName(); err != nil { 121 | return clusterSummary, err 122 | } 123 | 124 | if clusterSummary.ServerVersion, err = kubeClient.GetServerVersion(); err != nil { 125 | return clusterSummary, err 126 | } 127 | 128 | if storageClassName == "" { 129 | if clusterSummary.StorageClass, err = kubeClient.GetDefaultStorageClass(ctx); err != ErrNoDefaultStorageClass { 130 | return clusterSummary, err 131 | } 132 | } else { 133 | if clusterSummary.StorageClass, err = kubeClient.StorageV1().StorageClasses().Get(ctx, storageClassName, metav1.GetOptions{}); err != nil { 134 | return clusterSummary, err 135 | } 136 | } 137 | 138 | return clusterSummary, nil 139 | } 140 | 141 | type ClusterReport struct { 142 | *ClusterSummary 143 | IsCompatible bool 144 | UserAuthorized Requirement 145 | CliAuthSupported Requirement 146 | ServerVersionAllowed Requirement 147 | ClusterTypeAllowed Requirement 148 | StroageProvisional Requirement 149 | } 150 | 151 | func (clusterReport *ClusterReport) IsLocalCluster() bool { 152 | for _, localCluster := range LocalClusterTypes { 153 | if strings.HasPrefix(clusterReport.ClusterName, localCluster) { 154 | return true 155 | } 156 | } 157 | 158 | return false 159 | } 160 | 161 | func (clusterReport *ClusterReport) PrintStatus() { 162 | clusterReport.ClusterTypeAllowed.PrintStatus() 163 | if clusterReport.ClusterTypeAllowed.IsNonCompatible { 164 | return 165 | } 166 | 167 | clusterReport.CliAuthSupported.PrintStatus() 168 | if clusterReport.CliAuthSupported.IsNonCompatible { 169 | return 170 | } 171 | 172 | clusterReport.ServerVersionAllowed.PrintStatus() 173 | if clusterReport.ServerVersionAllowed.IsNonCompatible { 174 | return 175 | } 176 | 177 | clusterReport.UserAuthorized.PrintStatus() 178 | if clusterReport.UserAuthorized.IsNonCompatible { 179 | return 180 | } 181 | 182 | clusterReport.StroageProvisional.PrintStatus() 183 | if clusterReport.StroageProvisional.IsNonCompatible { 184 | return 185 | } 186 | } 187 | 188 | func (clusterRequirements ClusterRequirements) Validate(ctx context.Context, client *Client, clusterSummary *ClusterSummary) *ClusterReport { 189 | clusterReport := &ClusterReport{ 190 | ClusterSummary: clusterSummary, 191 | UserAuthorized: clusterRequirements.validateAuthorization(ctx, client, clusterSummary.Namespace), 192 | CliAuthSupported: clusterRequirements.validateCliAuthSupported(ctx, clusterSummary.ClusterName), 193 | ServerVersionAllowed: clusterRequirements.validateServerVersion(clusterSummary.ServerVersion), 194 | ClusterTypeAllowed: clusterRequirements.validateClusterType(clusterSummary.ClusterName), 195 | StroageProvisional: clusterRequirements.validateStorage(ctx, client, clusterSummary), 196 | } 197 | 198 | clusterReport.IsCompatible = clusterReport.ServerVersionAllowed.IsCompatible && 199 | clusterReport.UserAuthorized.IsCompatible && 200 | clusterReport.ClusterTypeAllowed.IsCompatible && 201 | clusterReport.CliAuthSupported.IsCompatible && 202 | !clusterReport.StroageProvisional.IsNonCompatible 203 | 204 | return clusterReport 205 | } 206 | 207 | func (clusterRequirements ClusterRequirements) validateClusterType(clusterName string) Requirement { 208 | var requirement Requirement 209 | requirement.Message = CLUSTER_TYPE_REPORT_MESSAGE_FORMAT 210 | 211 | for _, blockedType := range clusterRequirements.BlockedTypes { 212 | if strings.HasPrefix(clusterName, blockedType) { 213 | requirement.ErrorMessages = append(requirement.ErrorMessages, fmt.Sprintf("%s is unsupported cluster type", blockedType)) 214 | } 215 | } 216 | 217 | requirement.IsCompatible = len(requirement.ErrorMessages) == 0 218 | requirement.IsNonCompatible = len(requirement.ErrorMessages) > 0 219 | 220 | return requirement 221 | } 222 | 223 | func (clusterRequirements ClusterRequirements) validateServerVersion(serverVersion semver.Version) Requirement { 224 | var requirement Requirement 225 | requirement.Message = fmt.Sprintf(CLUSTER_VERSION_REPORT_MESSAGE_FORMAT, clusterRequirements.ServerVersion) 226 | 227 | if serverVersion.LT(clusterRequirements.ServerVersion) { 228 | requirement.ErrorMessages = append(requirement.ErrorMessages, fmt.Sprintf("%s is unsupported K8s version", serverVersion)) 229 | } 230 | 231 | requirement.IsCompatible = len(requirement.ErrorMessages) == 0 232 | requirement.IsNonCompatible = len(requirement.ErrorMessages) > 0 233 | 234 | return requirement 235 | } 236 | 237 | func (clusterRequirements ClusterRequirements) validateAuthorization(ctx context.Context, client *Client, namespace string) Requirement { 238 | var err error 239 | var permitted bool 240 | 241 | var requirement Requirement 242 | requirement.Message = CLUSTER_AUTHORIZATION_REPORT_MESSAGE_FORMAT 243 | 244 | for _, action := range clusterRequirements.Actions { 245 | action.Namespace = namespace 246 | if permitted, err = client.isActionPermitted(ctx, action); err != nil { 247 | requirement.ErrorMessages = append(requirement.ErrorMessages, err.Error()) 248 | continue 249 | } 250 | 251 | if !permitted { 252 | requirement.ErrorMessages = append(requirement.ErrorMessages, fmt.Sprintf("denied permissions on resource: %s", action.Resource)) 253 | } 254 | } 255 | 256 | requirement.IsCompatible = len(requirement.ErrorMessages) == 0 257 | requirement.IsNonCompatible = len(requirement.ErrorMessages) > 0 258 | 259 | return requirement 260 | } 261 | 262 | func (clusterRequirements ClusterRequirements) validateCliAuthSupported(ctx context.Context, clusterName string) Requirement { 263 | var err error 264 | 265 | var requirement Requirement 266 | requirement.Message = CLUSTER_CLI_AUTH_SUPPORTED 267 | 268 | if !IsEksCluster(clusterName) { 269 | requirement.IsCompatible = true 270 | return requirement 271 | } 272 | 273 | var awsCliVersion semver.Version 274 | if awsCliVersion, err = DefaultAwsCliVersionValidator.Fetch(ctx); err != nil { 275 | requirement.IsCompatible = true 276 | requirement.IsNonCompatible = false 277 | requirement.ErrorMessages = []string{ 278 | err.Error(), 279 | HINT_INSTALL_AWS_CLI, 280 | } 281 | return requirement 282 | } 283 | 284 | if err = DefaultAwsCliVersionValidator.Validate(awsCliVersion); err != nil { 285 | requirement.ErrorMessages = []string{ 286 | err.Error(), 287 | HINT_EKS_AUTH_PLUGIN_UPGRADE, 288 | } 289 | } 290 | 291 | requirement.IsCompatible = len(requirement.ErrorMessages) == 0 292 | requirement.IsNonCompatible = len(requirement.ErrorMessages) > 0 293 | 294 | return requirement 295 | } 296 | 297 | func (kubeClient *Client) GetClusterName() (string, error) { 298 | var err error 299 | var rawConfig clientcmdapi.Config 300 | 301 | if rawConfig, err = kubeClient.RawConfig(); err != nil { 302 | return "", err 303 | } 304 | 305 | return rawConfig.Contexts[kubeClient.kubecontext].Cluster, nil 306 | } 307 | 308 | func (kubeClient *Client) GetClusterShortName() (string, error) { 309 | var err error 310 | var clusterName string 311 | 312 | if clusterName, err = kubeClient.GetClusterName(); err != nil { 313 | return "", err 314 | } 315 | 316 | switch { 317 | case IsEksCluster(clusterName): 318 | return extractRegexClusterName(eksClusterRegex, clusterName) 319 | case IsGkeCluster(clusterName): 320 | return extractRegexClusterName(gkeClusterRegex, clusterName) 321 | default: 322 | return clusterName, nil 323 | } 324 | } 325 | 326 | func extractRegexClusterName(regex *regexp.Regexp, clusterName string) (string, error) { 327 | var subIndex int 328 | 329 | subMatch := regex.FindStringSubmatch(clusterName) 330 | 331 | if subIndex = regex.SubexpIndex("name"); subIndex == -1 { 332 | return "", fmt.Errorf("failed to extract cluster name from: %s", clusterName) 333 | } 334 | 335 | return subMatch[subIndex], nil 336 | } 337 | 338 | func (kubeClient Client) GetServerVersion() (semver.Version, error) { 339 | var err error 340 | var serverVersion semver.Version 341 | 342 | var versionInfo *version.Info 343 | if versionInfo, err = kubeClient.Discovery().ServerVersion(); err != nil { 344 | return serverVersion, err 345 | } 346 | 347 | if serverVersion, err = semver.ParseTolerant(versionInfo.GitVersion); err != nil { 348 | return serverVersion, errors.Wrapf(err, "unknown server version %s", versionInfo.GitVersion) 349 | } 350 | 351 | return serverVersion, nil 352 | } 353 | 354 | func IsEksCluster(clusterName string) bool { 355 | return eksClusterRegex.MatchString(clusterName) 356 | } 357 | 358 | func IsGkeCluster(clusterName string) bool { 359 | return gkeClusterRegex.MatchString(clusterName) 360 | } 361 | -------------------------------------------------------------------------------- /pkg/k8s/node_test.go: -------------------------------------------------------------------------------- 1 | package k8s_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/blang/semver/v4" 9 | "github.com/stretchr/testify/suite" 10 | "groundcover.com/pkg/k8s" 11 | v1 "k8s.io/api/core/v1" 12 | "k8s.io/apimachinery/pkg/api/resource" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/client-go/kubernetes/fake" 15 | ) 16 | 17 | const DEFAULT_CONTEXT_TIMEOUT = time.Duration(time.Minute * 1) 18 | 19 | type KubeNodeTestSuite struct { 20 | suite.Suite 21 | KubeClient k8s.Client 22 | } 23 | 24 | func (suite *KubeNodeTestSuite) SetupSuite() { 25 | nodeList := &v1.NodeList{ 26 | Items: []v1.Node{ 27 | { 28 | ObjectMeta: metav1.ObjectMeta{ 29 | Name: "compatible", 30 | }, 31 | Spec: v1.NodeSpec{ 32 | ProviderID: "aws://eu-west-3/i-53df4efedd", 33 | }, 34 | Status: v1.NodeStatus{ 35 | Allocatable: v1.ResourceList{ 36 | v1.ResourceCPU: *resource.NewScaledQuantity(2000, resource.Milli), 37 | v1.ResourceMemory: *resource.NewScaledQuantity(4000, resource.Mega), 38 | }, 39 | NodeInfo: v1.NodeSystemInfo{ 40 | Architecture: "amd64", 41 | OperatingSystem: "linux", 42 | KernelVersion: "5.3.0", 43 | OSImage: "amazon linux", 44 | }, 45 | }, 46 | }, 47 | { 48 | ObjectMeta: metav1.ObjectMeta{ 49 | Name: "incompatible", 50 | }, 51 | Spec: v1.NodeSpec{ 52 | ProviderID: "aws://eu-west-3/fargate-i-53df4efedd", 53 | }, 54 | Status: v1.NodeStatus{ 55 | Allocatable: v1.ResourceList{ 56 | v1.ResourceCPU: *resource.NewScaledQuantity(500, resource.Milli), 57 | v1.ResourceMemory: *resource.NewScaledQuantity(1000, resource.Mega), 58 | }, 59 | NodeInfo: v1.NodeSystemInfo{ 60 | Architecture: "arm", 61 | OperatingSystem: "windows", 62 | KernelVersion: "4.13.0", 63 | OSImage: "amazon linux", 64 | }, 65 | }, 66 | }, 67 | { 68 | ObjectMeta: metav1.ObjectMeta{ 69 | Name: "pending", 70 | }, 71 | Spec: v1.NodeSpec{ 72 | ProviderID: "aws://eu-west-3/i-53df4efedg", 73 | Taints: []v1.Taint{ 74 | { 75 | Key: "test", 76 | Value: "test", 77 | Effect: "NoSchedule", 78 | }, 79 | }, 80 | }, 81 | Status: v1.NodeStatus{ 82 | Allocatable: v1.ResourceList{ 83 | v1.ResourceCPU: *resource.NewScaledQuantity(2000, resource.Milli), 84 | v1.ResourceMemory: *resource.NewScaledQuantity(4000, resource.Mega), 85 | }, 86 | NodeInfo: v1.NodeSystemInfo{ 87 | Architecture: "amd64", 88 | OperatingSystem: "linux", 89 | KernelVersion: "5.2.0", 90 | OSImage: "amazon linux", 91 | }, 92 | }, 93 | }, 94 | }, 95 | } 96 | 97 | suite.KubeClient = k8s.Client{ 98 | Interface: fake.NewSimpleClientset(nodeList), 99 | } 100 | } 101 | 102 | func (suite *KubeNodeTestSuite) TearDownSuite() {} 103 | 104 | func TestKubeNodeTestSuite(t *testing.T) { 105 | suite.Run(t, &KubeNodeTestSuite{}) 106 | } 107 | 108 | func (suite *KubeNodeTestSuite) TestGetNodesSummariesSuccess() { 109 | // prepare 110 | ctx, cancel := context.WithTimeout(context.Background(), DEFAULT_CONTEXT_TIMEOUT) 111 | defer cancel() 112 | 113 | // act 114 | nodesSummaries, err := suite.KubeClient.GetNodesSummaries(ctx) 115 | suite.NoError(err) 116 | 117 | // assert 118 | expected := []*k8s.NodeSummary{ 119 | { 120 | CPU: resource.NewScaledQuantity(2000, resource.Milli), 121 | Memory: resource.NewScaledQuantity(4000, resource.Mega), 122 | Name: "compatible", 123 | Architecture: "amd64", 124 | OperatingSystem: "linux", 125 | Kernel: "5.3.0", 126 | OSImage: "amazon linux", 127 | Provider: "aws://eu-west-3/i-53df4efedd", 128 | }, 129 | { 130 | CPU: resource.NewScaledQuantity(500, resource.Milli), 131 | Memory: resource.NewScaledQuantity(1000, resource.Mega), 132 | Name: "incompatible", 133 | Architecture: "arm", 134 | OperatingSystem: "windows", 135 | Kernel: "4.13.0", 136 | OSImage: "amazon linux", 137 | Provider: "aws://eu-west-3/fargate-i-53df4efedd", 138 | }, 139 | { 140 | CPU: resource.NewScaledQuantity(2000, resource.Milli), 141 | Memory: resource.NewScaledQuantity(4000, resource.Mega), 142 | Name: "pending", 143 | Architecture: "amd64", 144 | OperatingSystem: "linux", 145 | Kernel: "5.2.0", 146 | OSImage: "amazon linux", 147 | Provider: "aws://eu-west-3/i-53df4efedg", 148 | Taints: []v1.Taint{ 149 | { 150 | Key: "test", 151 | Value: "test", 152 | Effect: "NoSchedule", 153 | }, 154 | }, 155 | }, 156 | } 157 | 158 | suite.Equal(expected, nodesSummaries) 159 | } 160 | 161 | func (suite *KubeNodeTestSuite) TestGenerateNodeReportSuccess() { 162 | // prepare 163 | ctx, cancel := context.WithTimeout(context.Background(), DEFAULT_CONTEXT_TIMEOUT) 164 | defer cancel() 165 | 166 | nodesSummaries, err := suite.KubeClient.GetNodesSummaries(ctx) 167 | suite.NoError(err) 168 | 169 | // act 170 | nodesReport := k8s.DefaultNodeRequirements.GenerateNodeReport(nodesSummaries) 171 | 172 | // assert 173 | 174 | expected := &k8s.NodesReport{ 175 | KernelVersions: semver.Versions{ 176 | semver.Version{Major: 5, Minor: 2, Patch: 0}, 177 | semver.Version{Major: 5, Minor: 3, Patch: 0}, 178 | }, 179 | CompatibleNodes: nodesSummaries[:1], 180 | TaintedNodes: []*k8s.IncompatibleNode{ 181 | { 182 | NodeSummary: nodesSummaries[2], 183 | RequirementErrors: []string{ 184 | "taints are set", 185 | }, 186 | }, 187 | }, 188 | IncompatibleNodes: []*k8s.IncompatibleNode{ 189 | { 190 | NodeSummary: nodesSummaries[1], 191 | RequirementErrors: []string{ 192 | "fargate is unsupported provider", 193 | "4.13.0 is unsupported kernel version", 194 | "arm is unspported architecture", 195 | "windows is unspported operating system", 196 | }, 197 | }, 198 | }, 199 | KernelVersionAllowed: k8s.Requirement{ 200 | IsCompatible: false, 201 | Message: "Kernel version >=4.14.0 (2/3 Nodes)", 202 | ErrorMessages: []string{"node: incompatible - 4.13.0 is unsupported kernel version"}, 203 | }, 204 | ProviderAllowed: k8s.Requirement{ 205 | IsCompatible: false, 206 | Message: "Cloud provider supported (2/3 Nodes)", 207 | ErrorMessages: []string{"node: incompatible - fargate is unsupported provider"}, 208 | }, 209 | ArchitectureAllowed: k8s.Requirement{ 210 | IsCompatible: false, 211 | Message: "Node architecture supported (2/3 Nodes)", 212 | ErrorMessages: []string{"node: incompatible - arm is unspported architecture"}, 213 | }, 214 | OperatingSystemAllowed: k8s.Requirement{ 215 | IsCompatible: false, 216 | Message: "Node operating system supported (2/3 Nodes)", 217 | ErrorMessages: []string{"node: incompatible - windows is unspported operating system"}, 218 | }, 219 | Schedulable: k8s.Requirement{ 220 | IsCompatible: false, 221 | Message: "Node is schedulable (2/3 Nodes)", 222 | ErrorMessages: []string{"node: pending - taints are set"}, 223 | }, 224 | } 225 | 226 | suite.Equal(expected, nodesReport) 227 | } 228 | -------------------------------------------------------------------------------- /pkg/k8s/pod.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | v1 "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | type ContainerStatus struct { 9 | Name string `json:"name,omitempty"` 10 | State v1.ContainerState `json:"state,omitempty"` 11 | Ready bool `json:"ready,omitempty"` 12 | RestartCount int32 `json:"restartCount,omitempty"` 13 | Started *bool `json:"started,omitempty"` 14 | } 15 | 16 | type PodStatus struct { 17 | Phase string 18 | Conditions []v1.PodCondition `json:"conditions,omitempty"` 19 | Message string `json:"message,omitempty"` 20 | Reason string `json:"reason,omitempty"` 21 | StartTime *metav1.Time `json:"startTime,omitempty"` 22 | InitContainersStatuses []ContainerStatus `json:"initContainersStatuses,omitempty"` 23 | ContainerStatuses []ContainerStatus `json:"containerStatuses,omitempty"` 24 | } 25 | 26 | func BuildPodStatus(pod v1.Pod) PodStatus { 27 | initContainerStatuses := make([]ContainerStatus, 0, len(pod.Status.InitContainerStatuses)) 28 | for _, initContainerStatus := range pod.Status.InitContainerStatuses { 29 | initContainerStatuses = append(initContainerStatuses, ContainerStatus{ 30 | Name: initContainerStatus.Name, 31 | State: initContainerStatus.State, 32 | Ready: initContainerStatus.Ready, 33 | RestartCount: initContainerStatus.RestartCount, 34 | Started: initContainerStatus.Started, 35 | }) 36 | } 37 | 38 | containerStatuses := make([]ContainerStatus, 0, len(pod.Status.ContainerStatuses)) 39 | for _, containerStatus := range pod.Status.ContainerStatuses { 40 | containerStatuses = append(containerStatuses, ContainerStatus{ 41 | Name: containerStatus.Name, 42 | State: containerStatus.State, 43 | Ready: containerStatus.Ready, 44 | RestartCount: containerStatus.RestartCount, 45 | Started: containerStatus.Started, 46 | }) 47 | } 48 | 49 | return PodStatus{ 50 | Phase: string(pod.Status.Phase), 51 | Conditions: pod.Status.Conditions, 52 | Message: pod.Status.Message, 53 | Reason: pod.Status.Reason, 54 | StartTime: pod.Status.StartTime, 55 | InitContainersStatuses: initContainerStatuses, 56 | ContainerStatuses: containerStatuses, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/k8s/requirement.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/fatih/color" 7 | "groundcover.com/pkg/ui" 8 | ) 9 | 10 | type Requirement struct { 11 | IsCompatible bool 12 | IsNonCompatible bool 13 | Message string `json:"-"` 14 | ErrorMessages []string `json:"-"` 15 | } 16 | 17 | func (requirement Requirement) PrintStatus() { 18 | var messageBuffer strings.Builder 19 | 20 | messageBuffer.WriteString(requirement.Message) 21 | messageBuffer.WriteString("\n") 22 | 23 | for _, errorMessage := range requirement.ErrorMessages { 24 | messageBuffer.WriteString(color.RedString(ui.Bullet)) 25 | messageBuffer.WriteString(" ") 26 | messageBuffer.WriteString(errorMessage) 27 | messageBuffer.WriteString("\n") 28 | } 29 | 30 | switch { 31 | case requirement.IsCompatible: 32 | ui.GlobalWriter.PrintSuccessMessage(messageBuffer.String()) 33 | case requirement.IsNonCompatible: 34 | ui.GlobalWriter.PrintErrorMessage(messageBuffer.String()) 35 | default: 36 | ui.GlobalWriter.PrintWarningMessage(messageBuffer.String()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/k8s/requirement_test.go: -------------------------------------------------------------------------------- 1 | package k8s_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/suite" 10 | "groundcover.com/pkg/k8s" 11 | ) 12 | 13 | type KubeRequirementTestSuite struct { 14 | suite.Suite 15 | Stdout *os.File 16 | ReadPipe *os.File 17 | WritePipe *os.File 18 | } 19 | 20 | func (suite *KubeRequirementTestSuite) SetupSuite() { 21 | suite.Stdout = os.Stdout 22 | } 23 | 24 | func (suite *KubeRequirementTestSuite) SetupTest() { 25 | suite.ReadPipe, suite.WritePipe, _ = os.Pipe() 26 | os.Stdout = suite.WritePipe 27 | } 28 | 29 | func (suite *KubeRequirementTestSuite) TearDownSuite() { 30 | os.Stdout = suite.Stdout 31 | } 32 | 33 | func TestKubeRequirementTestSuite(t *testing.T) { 34 | suite.Run(t, &KubeRequirementTestSuite{}) 35 | } 36 | 37 | func (suite *KubeRequirementTestSuite) TestRequirementPrintStatusNonCompatible() { 38 | // prepare 39 | requirement := k8s.Requirement{ 40 | IsCompatible: false, 41 | IsNonCompatible: true, 42 | Message: "message", 43 | ErrorMessages: []string{ 44 | "error-1", 45 | "error-2", 46 | }, 47 | } 48 | 49 | // act 50 | requirement.PrintStatus() 51 | suite.WritePipe.Close() 52 | 53 | var buf bytes.Buffer 54 | _, err := io.Copy(&buf, suite.ReadPipe) 55 | suite.NoError(err) 56 | 57 | // assert 58 | expected := "✕ message\n• error-1\n• error-2\n" 59 | 60 | suite.Equal(expected, buf.String()) 61 | } 62 | 63 | func (suite *KubeRequirementTestSuite) TestRequirementPrintStatusCompatible() { 64 | // prepare 65 | requirement := k8s.Requirement{ 66 | IsCompatible: true, 67 | IsNonCompatible: true, 68 | Message: "message", 69 | ErrorMessages: []string{}, 70 | } 71 | 72 | // act 73 | requirement.PrintStatus() 74 | suite.WritePipe.Close() 75 | 76 | var buf bytes.Buffer 77 | _, err := io.Copy(&buf, suite.ReadPipe) 78 | suite.NoError(err) 79 | 80 | // assert 81 | expected := "✔ message\n" 82 | 83 | suite.Equal(expected, buf.String()) 84 | } 85 | 86 | func (suite *KubeRequirementTestSuite) TestRequirementPrintStatusPartial() { 87 | // prepare 88 | requirement := k8s.Requirement{ 89 | IsCompatible: false, 90 | IsNonCompatible: false, 91 | Message: "message", 92 | ErrorMessages: []string{ 93 | "error-1", 94 | }, 95 | } 96 | 97 | // act 98 | requirement.PrintStatus() 99 | suite.WritePipe.Close() 100 | 101 | var buf bytes.Buffer 102 | _, err := io.Copy(&buf, suite.ReadPipe) 103 | suite.NoError(err) 104 | 105 | // assert 106 | expected := "✋ message\n• error-1\n" 107 | 108 | suite.Equal(expected, buf.String()) 109 | } 110 | -------------------------------------------------------------------------------- /pkg/k8s/storage.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/blang/semver/v4" 8 | v1 "k8s.io/api/storage/v1" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | ) 11 | 12 | const ( 13 | AWS_EBS_CSI_DRIVER_NAME = "ebs.csi.aws.com" 14 | AWS_EBS_STORAGE_CLASS_NOT_DEFAULT = "found default storage class without aws-ebs provisioner" 15 | 16 | HINT_INSTALL_AWS_EBS_CSI_DRIVER = `Hint: 17 | * Install Amazon EBS CSI driver: https://docs.aws.amazon.com/eks/latest/userguide/ebs-csi.html` 18 | HINT_DEFINE_DEFAULT_STORAGE_CLASS = `Hint: 19 | * Define default StorageClass: https://kubernetes.io/docs/concepts/storage/storage-classes/#the-storageclass-resource` 20 | ) 21 | 22 | var ( 23 | ErrNoDefaultStorageClass = errors.New("cluster has no default storage class") 24 | DEFAULT_STORAGE_CLASS_ANNOTATION_NAMES = []string{"storageclass.kubernetes.io/is-default-class", "storageclass.beta.kubernetes.io/is-default-class"} 25 | ) 26 | 27 | func (clusterRequirements ClusterRequirements) validateStorage(ctx context.Context, client *Client, clusterSummary *ClusterSummary) Requirement { 28 | var err error 29 | 30 | var requirement Requirement 31 | requirement.Message = CLUSTER_STORAGE_SUPPORTED 32 | 33 | if clusterSummary.StorageClass == nil { 34 | requirement.IsCompatible = false 35 | requirement.IsNonCompatible = true 36 | requirement.ErrorMessages = append(requirement.ErrorMessages, ErrNoDefaultStorageClass.Error(), HINT_DEFINE_DEFAULT_STORAGE_CLASS) 37 | return requirement 38 | } 39 | 40 | if IsEksCluster(clusterSummary.ClusterName) { 41 | if semver.MustParseRange("<1.23.0")(clusterSummary.ServerVersion) { 42 | requirement.IsCompatible = len(requirement.ErrorMessages) == 0 43 | return requirement 44 | } 45 | 46 | if err = hasEbsCsiDriver(ctx, client); err != nil { 47 | requirement.IsCompatible = false 48 | requirement.IsNonCompatible = true 49 | requirement.ErrorMessages = append(requirement.ErrorMessages, err.Error(), HINT_INSTALL_AWS_EBS_CSI_DRIVER) 50 | return requirement 51 | } 52 | } 53 | 54 | requirement.IsCompatible = len(requirement.ErrorMessages) == 0 55 | 56 | return requirement 57 | } 58 | 59 | func (kubeClient *Client) GetDefaultStorageClass(ctx context.Context) (*v1.StorageClass, error) { 60 | storageClassList, err := kubeClient.StorageV1().StorageClasses().List(ctx, metav1.ListOptions{}) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | for _, storageClass := range storageClassList.Items { 66 | for _, annotation := range DEFAULT_STORAGE_CLASS_ANNOTATION_NAMES { 67 | if value, ok := storageClass.Annotations[annotation]; ok && value == "true" { 68 | return &storageClass, nil 69 | } 70 | } 71 | } 72 | 73 | return nil, ErrNoDefaultStorageClass 74 | } 75 | 76 | func hasEbsCsiDriver(ctx context.Context, client *Client) error { 77 | _, err := client.StorageV1().CSIDrivers().Get(ctx, AWS_EBS_CSI_DRIVER_NAME, metav1.GetOptions{}) 78 | return err 79 | } 80 | -------------------------------------------------------------------------------- /pkg/k8s/taint.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "golang.org/x/exp/maps" 8 | "golang.org/x/exp/slices" 9 | 10 | v1 "k8s.io/api/core/v1" 11 | ) 12 | 13 | const ( 14 | BUILTIN_TAINTS_PREFIX = "node.kubernetes.io" 15 | ) 16 | 17 | type TolerationManager struct { 18 | TaintedNodes []*IncompatibleNode 19 | } 20 | 21 | func (manager TolerationManager) GetTaints() ([]string, error) { 22 | var err error 23 | 24 | taintsSet := make(map[string]struct{}) 25 | 26 | for _, taintedNode := range manager.TaintedNodes { 27 | for _, taint := range taintedNode.Taints { 28 | if isBuiltinTaint(taint) { 29 | continue 30 | } 31 | 32 | var taintMarshaled string 33 | if taintMarshaled, err = manager.marshalTaint(taint); err != nil { 34 | return nil, err 35 | } 36 | 37 | if _, exists := taintsSet[taintMarshaled]; !exists { 38 | taintsSet[taintMarshaled] = struct{}{} 39 | } 40 | } 41 | } 42 | 43 | return maps.Keys(taintsSet), nil 44 | } 45 | 46 | func (manager TolerationManager) GetTolerationsMap(allowedTaints []string) ([]map[string]interface{}, error) { 47 | tolerations := make([]map[string]interface{}, 0, len(allowedTaints)) 48 | 49 | for _, taintMarshaled := range allowedTaints { 50 | toleration := v1.Toleration{ 51 | Operator: "Equal", 52 | } 53 | 54 | if err := json.Unmarshal([]byte(taintMarshaled), &toleration); err != nil { 55 | return nil, err 56 | } 57 | 58 | tolerationsMap := map[string]interface{}{ 59 | "key": toleration.Key, 60 | "operator": toleration.Operator, 61 | "value": toleration.Value, 62 | "effect": toleration.Effect, 63 | "tolerationSeconds": toleration.TolerationSeconds, 64 | } 65 | 66 | tolerations = append(tolerations, tolerationsMap) 67 | } 68 | 69 | return tolerations, nil 70 | } 71 | 72 | func (manager TolerationManager) GetTolerableNodes(allowedTaints []string) ([]*NodeSummary, error) { 73 | var err error 74 | var tolerableNodes []*NodeSummary 75 | 76 | if len(allowedTaints) == 0 { 77 | return tolerableNodes, nil 78 | } 79 | 80 | for _, taintedNode := range manager.TaintedNodes { 81 | var incompatibleNode bool 82 | for _, taint := range taintedNode.Taints { 83 | if isBuiltinTaint(taint) { 84 | continue 85 | } 86 | 87 | var taintMarshaled string 88 | if taintMarshaled, err = manager.marshalTaint(taint); err != nil { 89 | return nil, err 90 | } 91 | 92 | if !slices.Contains(allowedTaints, taintMarshaled) { 93 | incompatibleNode = true 94 | break 95 | } 96 | } 97 | 98 | if incompatibleNode { 99 | continue 100 | } 101 | 102 | tolerableNodes = append(tolerableNodes, taintedNode.NodeSummary) 103 | } 104 | 105 | return tolerableNodes, nil 106 | } 107 | 108 | func (validator TolerationManager) marshalTaint(taint v1.Taint) (string, error) { 109 | var err error 110 | 111 | var jsonByte []byte 112 | if jsonByte, err = json.Marshal(taint); err != nil { 113 | return "", err 114 | } 115 | 116 | return string(jsonByte), nil 117 | } 118 | 119 | func isBuiltinTaint(taint v1.Taint) bool { 120 | return strings.HasPrefix(taint.Key, BUILTIN_TAINTS_PREFIX) 121 | } 122 | -------------------------------------------------------------------------------- /pkg/k8s/taint_test.go: -------------------------------------------------------------------------------- 1 | package k8s_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/suite" 7 | "groundcover.com/pkg/k8s" 8 | v1 "k8s.io/api/core/v1" 9 | ) 10 | 11 | type KubeTaintTestSuite struct { 12 | suite.Suite 13 | TaintedNodes []*k8s.IncompatibleNode 14 | } 15 | 16 | func (suite *KubeTaintTestSuite) SetupSuite() { 17 | suite.TaintedNodes = []*k8s.IncompatibleNode{ 18 | { 19 | NodeSummary: &k8s.NodeSummary{ 20 | Taints: []v1.Taint{ 21 | { 22 | Key: "test", 23 | Value: "test", 24 | Effect: "NoSchedule", 25 | }, 26 | { 27 | Key: "good", 28 | Value: "good", 29 | Effect: "NoSchedule", 30 | }, 31 | }, 32 | }, 33 | }, 34 | { 35 | NodeSummary: &k8s.NodeSummary{ 36 | Taints: []v1.Taint{ 37 | { 38 | Key: "bad", 39 | Value: "bad", 40 | Effect: "NoSchedule", 41 | }, 42 | }, 43 | }, 44 | }, 45 | { 46 | NodeSummary: &k8s.NodeSummary{ 47 | Taints: []v1.Taint{ 48 | { 49 | Key: "bad", 50 | Value: "bad", 51 | Effect: "NoSchedule", 52 | }, 53 | }, 54 | }, 55 | }, 56 | } 57 | } 58 | 59 | func (suite *KubeTaintTestSuite) TearDownSuite() {} 60 | 61 | func TestKubeTaintTestSuite(t *testing.T) { 62 | suite.Run(t, &KubeTaintTestSuite{}) 63 | } 64 | 65 | func (suite *KubeTaintTestSuite) TestGetTaintsSuccess() { 66 | // prepare 67 | tolerationManager := &k8s.TolerationManager{ 68 | TaintedNodes: suite.TaintedNodes, 69 | } 70 | 71 | // act 72 | taints, err := tolerationManager.GetTaints() 73 | suite.NoError(err) 74 | 75 | // assert 76 | 77 | expected := []string{ 78 | "{\"key\":\"test\",\"value\":\"test\",\"effect\":\"NoSchedule\"}", 79 | "{\"key\":\"good\",\"value\":\"good\",\"effect\":\"NoSchedule\"}", 80 | "{\"key\":\"bad\",\"value\":\"bad\",\"effect\":\"NoSchedule\"}", 81 | } 82 | 83 | suite.ElementsMatch(expected, taints) 84 | } 85 | 86 | func (suite *KubeTaintTestSuite) TestGetTolerationsSuccess() { 87 | // prepare 88 | tolerationManager := &k8s.TolerationManager{ 89 | TaintedNodes: suite.TaintedNodes, 90 | } 91 | 92 | // act 93 | tolerations, err := tolerationManager.GetTolerationsMap([]string{"{\"key\":\"test\",\"value\":\"test\",\"effect\":\"NoSchedule\"}"}) 94 | suite.NoError(err) 95 | 96 | // assert 97 | var tolerationSeconds *int64 98 | expected := []map[string]interface{}{ 99 | { 100 | "key": "test", 101 | "value": "test", 102 | "operator": v1.TolerationOpEqual, 103 | "effect": v1.TaintEffectNoSchedule, 104 | "tolerationSeconds": tolerationSeconds, 105 | }, 106 | } 107 | 108 | suite.Equal(expected, tolerations) 109 | } 110 | 111 | func (suite *KubeTaintTestSuite) TestGetTolerableNodesSuccess() { 112 | // prepare 113 | tolerationManager := &k8s.TolerationManager{ 114 | TaintedNodes: suite.TaintedNodes, 115 | } 116 | 117 | // act 118 | allowedTaints := []string{ 119 | "{\"key\":\"test\",\"value\":\"test\",\"effect\":\"NoSchedule\"}", 120 | "{\"key\":\"good\",\"value\":\"good\",\"effect\":\"NoSchedule\"}", 121 | } 122 | 123 | nodes, err := tolerationManager.GetTolerableNodes(allowedTaints) 124 | suite.NoError(err) 125 | 126 | // assert 127 | 128 | expected := []*k8s.NodeSummary{ 129 | suite.TaintedNodes[0].NodeSummary, 130 | } 131 | 132 | suite.Equal(expected, nodes) 133 | } 134 | -------------------------------------------------------------------------------- /pkg/segment/client.go: -------------------------------------------------------------------------------- 1 | package segment 2 | 3 | import ( 4 | "io" 5 | "log" 6 | 7 | "github.com/segmentio/analytics-go/v3" 8 | ) 9 | 10 | var ( 11 | client analytics.Client 12 | WriteKey string = "FPPzr8mdiYq9Ry2YOEVFN751DvSdwwUZ" 13 | ) 14 | 15 | func GetConfig(appName, version string) analytics.Config { 16 | devNullLogger := log.New(io.Discard, "", log.LstdFlags) 17 | return analytics.Config{ 18 | BatchSize: 1, 19 | Logger: analytics.StdLogger(devNullLogger), 20 | DefaultContext: &analytics.Context{ 21 | App: analytics.AppInfo{ 22 | Name: appName, 23 | Version: version, 24 | }, 25 | }, 26 | } 27 | } 28 | 29 | func Init(config analytics.Config) error { 30 | var err error 31 | 32 | if client, err = analytics.NewWithConfig(WriteKey, config); err != nil { 33 | return err 34 | } 35 | 36 | return nil 37 | } 38 | 39 | func Close() error { 40 | return client.Close() 41 | } 42 | -------------------------------------------------------------------------------- /pkg/segment/event.go: -------------------------------------------------------------------------------- 1 | package segment 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/uuid" 7 | "github.com/segmentio/analytics-go/v3" 8 | ) 9 | 10 | const ( 11 | ABORT_STATUS = "abort" 12 | START_STATUS = "start" 13 | FAILURE_STATUS = "failure" 14 | SUCCESS_STATUS = "success" 15 | PARTIAL_SUCCESS_STATUS = "partial-success" 16 | SCOPE_PROPERTY_NAME = "scope" 17 | ERROR_PROPERTY_NAME = "error" 18 | STATUS_PROPERTY_NAME = "status" 19 | SESSION_ID_PROPERTY_NAME = "sessionId" 20 | EVENT_WITH_STATUS_FORMAT = "%s_%s" 21 | ) 22 | 23 | var ( 24 | scope string 25 | sessionId = uuid.NewString() 26 | ) 27 | 28 | func SetScope(name string) { 29 | scope = name 30 | } 31 | 32 | func GetScope() string { 33 | return scope 34 | } 35 | 36 | func SetSessionId(id string) { 37 | sessionId = id 38 | } 39 | 40 | type EventHandler struct { 41 | analytics.Track 42 | name string 43 | } 44 | 45 | func NewEvent(name string) *EventHandler { 46 | event := &EventHandler{ 47 | name: name, 48 | } 49 | 50 | event.Event = name 51 | event.UserId = userId 52 | if userId == "" { 53 | event.AnonymousId = uuid.NewString() 54 | } 55 | 56 | event.Properties = analytics.NewProperties() 57 | 58 | return event 59 | } 60 | 61 | func (event *EventHandler) Set(name string, value interface{}) analytics.Properties { 62 | event.Properties.Set(name, value) 63 | return event.Properties 64 | } 65 | 66 | func (event *EventHandler) Start() error { 67 | return event.enqueueWithStatus(START_STATUS) 68 | } 69 | 70 | func (event *EventHandler) Abort() error { 71 | return event.enqueueWithStatus(ABORT_STATUS) 72 | } 73 | 74 | func (event *EventHandler) Failure(err error) error { 75 | event.Set(ERROR_PROPERTY_NAME, err.Error()) 76 | return event.enqueueWithStatus(FAILURE_STATUS) 77 | } 78 | 79 | func (event *EventHandler) Success() error { 80 | return event.enqueueWithStatus(SUCCESS_STATUS) 81 | } 82 | 83 | func (event *EventHandler) PartialSuccess() error { 84 | return event.enqueueWithStatus(PARTIAL_SUCCESS_STATUS) 85 | } 86 | 87 | func (event *EventHandler) StatusByError(err error) error { 88 | if err != nil { 89 | return event.Failure(err) 90 | } 91 | 92 | return event.Success() 93 | } 94 | 95 | func (event *EventHandler) enqueueWithStatus(status string) error { 96 | event.Properties.Set(STATUS_PROPERTY_NAME, status) 97 | event.Properties.Set(SCOPE_PROPERTY_NAME, scope) 98 | event.Properties.Set(SESSION_ID_PROPERTY_NAME, sessionId) 99 | event.Event = fmt.Sprintf(EVENT_WITH_STATUS_FORMAT, event.name, status) 100 | return client.Enqueue(event.Track) 101 | } 102 | -------------------------------------------------------------------------------- /pkg/segment/user.go: -------------------------------------------------------------------------------- 1 | package segment 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | 7 | "github.com/segmentio/analytics-go/v3" 8 | ) 9 | 10 | const ORG_TRAIT_NAME = "orgName" 11 | 12 | var userId string 13 | 14 | func NewUser(email string, org string) error { 15 | var err error 16 | 17 | SetUserId(email) 18 | 19 | user := analytics.Identify{ 20 | UserId: userId, 21 | Traits: analytics.NewTraits().SetEmail(email).Set(ORG_TRAIT_NAME, org), 22 | } 23 | 24 | tenantUniqueId := fmt.Sprintf("%s@%s", org, org) 25 | orgGroup := analytics.Group{ 26 | GroupId: tenantUniqueId, 27 | UserId: userId, 28 | Traits: analytics.NewTraits().SetEmail(email).SetName(tenantUniqueId), 29 | } 30 | 31 | if err = client.Enqueue(user); err != nil { 32 | return err 33 | } 34 | 35 | if err = client.Enqueue(orgGroup); err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func SetUserId(email string) { 43 | userId = GenerateUserId(email) 44 | } 45 | 46 | func GenerateUserId(email string) string { 47 | return fmt.Sprintf("%x", sha256.Sum256([]byte(email))) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/selfupdate/selfupdate.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "runtime" 14 | "strings" 15 | "time" 16 | 17 | "github.com/blang/semver/v4" 18 | "github.com/google/go-github/github" 19 | "github.com/minio/selfupdate" 20 | "groundcover.com/pkg/ui" 21 | ) 22 | 23 | const ( 24 | APPLY_POLLING_RETRIES = 30 25 | APPLY_POLLING_TIMEOUT = time.Minute * 1 26 | APPLY_POLLING_INTERVAL = time.Second * 2 27 | ) 28 | 29 | var ( 30 | devVersion = semver.MustParse("0.0.0-dev") 31 | ) 32 | 33 | type SelfUpdater struct { 34 | assetId int64 35 | githubOwner string 36 | githubRepo string 37 | assetUrl string 38 | Version semver.Version 39 | } 40 | 41 | func NewSelfUpdater(ctx context.Context, githubOwner, githubRepo string) (*SelfUpdater, error) { 42 | var err error 43 | var githubRelease *github.RepositoryRelease 44 | 45 | selfUpdater := new(SelfUpdater) 46 | selfUpdater.githubOwner = githubOwner 47 | selfUpdater.githubRepo = githubRepo 48 | client := github.NewClient(nil) 49 | 50 | if githubRelease, _, err = client.Repositories.GetLatestRelease(ctx, githubOwner, githubRepo); err != nil { 51 | return nil, err 52 | } 53 | 54 | if err = selfUpdater.fetchVersion(githubRelease); err != nil { 55 | return nil, err 56 | } 57 | 58 | if err = selfUpdater.fetchAsset(ctx, client, githubRelease); err != nil { 59 | return nil, err 60 | } 61 | 62 | return selfUpdater, nil 63 | } 64 | 65 | func (selfUpdater *SelfUpdater) fetchVersion(githubRelease *github.RepositoryRelease) error { 66 | var err error 67 | var version semver.Version 68 | 69 | if version, err = semver.ParseTolerant(githubRelease.GetTagName()); err != nil { 70 | return err 71 | } 72 | 73 | selfUpdater.Version = version 74 | return nil 75 | } 76 | 77 | func (selfUpdater *SelfUpdater) fetchAsset(ctx context.Context, client *github.Client, githubRelease *github.RepositoryRelease) error { 78 | var err error 79 | var assetUrl string 80 | 81 | assetSuffix := fmt.Sprintf("%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH) 82 | 83 | for _, asset := range githubRelease.Assets { 84 | if strings.HasSuffix(asset.GetName(), assetSuffix) { 85 | selfUpdater.assetId = asset.GetID() 86 | if _, assetUrl, err = client.Repositories.DownloadReleaseAsset(ctx, selfUpdater.githubOwner, selfUpdater.githubRepo, selfUpdater.assetId); err != nil { 87 | return err 88 | } 89 | selfUpdater.assetUrl = assetUrl 90 | return nil 91 | } 92 | } 93 | 94 | return fmt.Errorf("failed to find asset for %s", assetSuffix) 95 | } 96 | 97 | func (selfUpdater *SelfUpdater) IsLatestNewer(currentVersion semver.Version) bool { 98 | return selfUpdater.Version.GT(currentVersion) 99 | } 100 | 101 | func (selfUpdater *SelfUpdater) IsDevVersion(currentVersion semver.Version) bool { 102 | return currentVersion.Equals(devVersion) 103 | } 104 | 105 | func (selfUpdater *SelfUpdater) Apply(ctx context.Context) error { 106 | var err error 107 | 108 | spinner := ui.GlobalWriter.NewSpinner(fmt.Sprintf("Downloading cli version: %s", selfUpdater.Version)) 109 | spinner.SetStopMessage("cli update was successfully") 110 | spinner.SetStopFailMessage("cli update has failed") 111 | 112 | spinner.Start() 113 | defer spinner.WriteStop() 114 | 115 | err = spinner.Poll(ctx, selfUpdater.apply, APPLY_POLLING_INTERVAL, APPLY_POLLING_TIMEOUT, APPLY_POLLING_RETRIES) 116 | 117 | if err == nil { 118 | return nil 119 | } 120 | 121 | spinner.WriteStopFail() 122 | 123 | if errors.Is(err, ui.ErrSpinnerTimeout) { 124 | return errors.New("timeout waiting for cli download") 125 | } 126 | 127 | return err 128 | } 129 | 130 | func (selfUpdater *SelfUpdater) apply() error { 131 | var err error 132 | 133 | var assetResponse *http.Response 134 | if assetResponse, err = http.Get(selfUpdater.assetUrl); err != nil { 135 | return ui.RetryableError(err) 136 | } 137 | defer assetResponse.Body.Close() 138 | 139 | var assetReader io.Reader 140 | if assetReader, err = selfUpdater.untarAsset(assetResponse.Body); err != nil { 141 | return ui.RetryableError(err) 142 | } 143 | 144 | if err = selfupdate.Apply(assetReader, selfupdate.Options{}); err != nil { 145 | return ui.RetryableError(err) 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func (selfUpdater *SelfUpdater) untarAsset(assetReader io.ReadCloser) (*tar.Reader, error) { 152 | var err error 153 | var exectuablePath string 154 | var tarHeader *tar.Header 155 | var tarReader *tar.Reader 156 | var gzipReader *gzip.Reader 157 | 158 | if exectuablePath, err = os.Executable(); err != nil { 159 | return nil, err 160 | } 161 | exectuableName := filepath.Base(exectuablePath) 162 | 163 | if gzipReader, err = gzip.NewReader(assetReader); err != nil { 164 | return nil, err 165 | } 166 | defer gzipReader.Close() 167 | 168 | tarReader = tar.NewReader(gzipReader) 169 | for { 170 | tarHeader, err = tarReader.Next() 171 | if err == io.EOF { 172 | break 173 | } 174 | if err != nil { 175 | return nil, err 176 | } 177 | if tarHeader.Name == exectuableName { 178 | return tarReader, nil 179 | } 180 | } 181 | 182 | return nil, fmt.Errorf("failed to find %s in archive", exectuableName) 183 | } 184 | -------------------------------------------------------------------------------- /pkg/sentry/client.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/getsentry/sentry-go" 8 | ) 9 | 10 | const ( 11 | MODE_TAG = "mode" 12 | TAINTED_TAG = "tainted" 13 | ERASE_DATA_TAG = "erase" 14 | UPGRADE_TAG = "upgrade" 15 | TOKEN_ID_TAG = "token.id" 16 | ORGANIZATION_TAG = "organization" 17 | CHART_VERSION_TAG = "chart.version" 18 | DEFAULT_RESOURCES_PRESET_TAG = "resources.presets.default" 19 | PERSISTENT_STORAGE_TAG = "storage.persistent" 20 | CLUSTER_NAME_TAG = "cluster.name" 21 | NODES_COUNT_TAG = "nodes.count" 22 | EXPECTED_NODES_COUNT_TAG = "nodes.expected_count" 23 | RUNNING_SENSORS_TAG = "nodes.running_sensors" 24 | FLUSH_TIMEOUT = time.Second * 2 25 | ) 26 | 27 | var Dsn string = "https://6420be38b4544852a61df1d7ec56f442@o1295881.ingest.sentry.io/6521982" 28 | 29 | func GetSentryClientOptions(appName, environment, version string) sentry.ClientOptions { 30 | return sentry.ClientOptions{ 31 | MaxBreadcrumbs: 10, 32 | Dsn: Dsn, 33 | Environment: environment, 34 | Release: fmt.Sprintf("%s@%s", appName, version), 35 | } 36 | } 37 | 38 | func SetTagOnCurrentScope(key, value string) { 39 | sentry.CurrentHub().Scope().SetTag(key, value) 40 | } 41 | 42 | func SetUserOnCurrentScope(user sentry.User) { 43 | sentry.CurrentHub().Scope().SetUser(user) 44 | } 45 | 46 | func SetLevelOnCurrentScope(level sentry.Level) { 47 | sentry.CurrentHub().Scope().SetLevel(level) 48 | } 49 | 50 | func SetTransactionOnCurrentScope(name string) { 51 | sentry.CurrentHub().Scope().SetTransaction(name) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/sentry/client_test.go: -------------------------------------------------------------------------------- 1 | package sentry_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/getsentry/sentry-go" 8 | "github.com/google/uuid" 9 | "github.com/stretchr/testify/suite" 10 | sentry_utils "groundcover.com/pkg/sentry" 11 | ) 12 | 13 | type SentryClientTestSuite struct { 14 | suite.Suite 15 | Transport *TransportMock 16 | } 17 | 18 | func (suite *SentryClientTestSuite) SetupSuite() { 19 | suite.Transport = &TransportMock{} 20 | 21 | clientOptions := sentry.ClientOptions{ 22 | Dsn: "http://whatever@really.com/1337", 23 | Transport: suite.Transport, 24 | Integrations: func(i []sentry.Integration) []sentry.Integration { return []sentry.Integration{} }, 25 | } 26 | 27 | client, _ := sentry.NewClient(clientOptions) 28 | sentry.CurrentHub().BindClient(client) 29 | } 30 | 31 | func (suite *SentryClientTestSuite) TearDownSuite() {} 32 | 33 | func TestSentryClientSuite(t *testing.T) { 34 | suite.Run(t, &SentryClientTestSuite{}) 35 | } 36 | 37 | func (suite *SentryClientTestSuite) TestGetSentryClientOptionsSuccess() { 38 | //prepare 39 | appName := "cli" 40 | version := "1.0.0" 41 | environment := "prod" 42 | 43 | //act 44 | clientOptions := sentry_utils.GetSentryClientOptions(appName, environment, version) 45 | 46 | // assert 47 | expect := sentry.ClientOptions{ 48 | MaxBreadcrumbs: 10, 49 | Environment: environment, 50 | Dsn: sentry_utils.Dsn, 51 | Release: fmt.Sprintf("%s@%s", appName, version), 52 | } 53 | 54 | suite.Equal(expect, clientOptions) 55 | } 56 | 57 | func (suite *SentryClientTestSuite) TestSetOnCurrentScopeSuccess() { 58 | //prepare 59 | level := sentry.LevelWarning 60 | tagName := uuid.New().String() 61 | tagValue := uuid.New().String() 62 | transaction := uuid.New().String() 63 | 64 | user := sentry.User{ 65 | Email: uuid.New().String(), 66 | Username: uuid.New().String(), 67 | } 68 | 69 | //act 70 | sentry_utils.SetUserOnCurrentScope(user) 71 | sentry_utils.SetLevelOnCurrentScope(level) 72 | sentry_utils.SetTagOnCurrentScope(tagName, tagValue) 73 | sentry_utils.SetTransactionOnCurrentScope(transaction) 74 | sentry.CaptureMessage("set on scope") 75 | 76 | // assert 77 | event := suite.Transport.lastEvent 78 | 79 | suite.Equal(user, event.User) 80 | suite.Equal(transaction, event.Transaction) 81 | suite.Equal(map[string]string{tagName: tagValue}, event.Tags) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/sentry/context.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/blang/semver/v4" 7 | "github.com/getsentry/sentry-go" 8 | "groundcover.com/pkg/helm" 9 | "groundcover.com/pkg/k8s" 10 | "groundcover.com/pkg/ui" 11 | ) 12 | 13 | const ( 14 | MAX_NODE_REPORT_SAMPLES = 10 15 | COMMAND_CONTEXT_NAME = "command" 16 | HELM_CONTEXT_NAME = "helm" 17 | KUBE_CONTEXT_NAME = "kubernetes" 18 | SELF_UPDATE_CONTEXT_NAME = "cli-update" 19 | ) 20 | 21 | type SentryContext interface { 22 | SetOnCurrentScope() 23 | } 24 | 25 | type CommandContext struct { 26 | Name string `json:",omitempty"` 27 | Took string `json:",omitempty"` 28 | Log *ui.Writer `json:",omitempty"` 29 | } 30 | 31 | func NewCommandContext(start time.Time) *CommandContext { 32 | return &CommandContext{ 33 | Name: sentry.CurrentHub().Scope().Transaction(), 34 | Took: time.Since(start).Round(time.Second).String(), 35 | Log: ui.GlobalWriter, 36 | } 37 | } 38 | 39 | func (context CommandContext) SetOnCurrentScope() { 40 | sentry.CurrentHub().Scope().SetContext(COMMAND_CONTEXT_NAME, &context) 41 | } 42 | 43 | type KubeContext struct { 44 | NodesCount int `json:",omitempty"` 45 | Kubeconfig string `json:",omitempty"` 46 | Kubecontext string `json:",omitempty"` 47 | TolerationsAndTaintsRatio string `json:",omitempty"` 48 | ClusterReport *k8s.ClusterReport `json:",omitempty"` 49 | CompatibleNodeSamples []*k8s.NodeSummary `json:",omitempty"` 50 | IncompatibleNodeSamples []*k8s.IncompatibleNode `json:",omitempty"` 51 | TaintedNodeSamples []*k8s.IncompatibleNode `json:",omitempty"` 52 | } 53 | 54 | func NewKubeContext(kubeconfig, kubecontext string) *KubeContext { 55 | return &KubeContext{ 56 | Kubeconfig: kubeconfig, 57 | Kubecontext: kubecontext, 58 | } 59 | } 60 | 61 | func (context *KubeContext) SetNodesSamples(nodesReport *k8s.NodesReport) { 62 | compatibleSamplesSize := len(nodesReport.CompatibleNodes) 63 | if compatibleSamplesSize > MAX_NODE_REPORT_SAMPLES { 64 | compatibleSamplesSize = MAX_NODE_REPORT_SAMPLES 65 | } 66 | 67 | context.CompatibleNodeSamples = make([]*k8s.NodeSummary, compatibleSamplesSize) 68 | copy(context.CompatibleNodeSamples, nodesReport.CompatibleNodes) 69 | 70 | incompatibleSamplesSize := len(nodesReport.IncompatibleNodes) 71 | if incompatibleSamplesSize > MAX_NODE_REPORT_SAMPLES { 72 | incompatibleSamplesSize = MAX_NODE_REPORT_SAMPLES 73 | } 74 | 75 | context.IncompatibleNodeSamples = make([]*k8s.IncompatibleNode, incompatibleSamplesSize) 76 | copy(context.IncompatibleNodeSamples, nodesReport.IncompatibleNodes) 77 | 78 | incompatibleTaintsSize := len(nodesReport.TaintedNodes) 79 | if incompatibleTaintsSize > MAX_NODE_REPORT_SAMPLES { 80 | incompatibleTaintsSize = MAX_NODE_REPORT_SAMPLES 81 | } 82 | 83 | context.TaintedNodeSamples = make([]*k8s.IncompatibleNode, incompatibleTaintsSize) 84 | copy(context.TaintedNodeSamples, nodesReport.TaintedNodes) 85 | } 86 | 87 | func (context KubeContext) SetOnCurrentScope() { 88 | sentry.CurrentHub().Scope().SetContext(KUBE_CONTEXT_NAME, &context) 89 | } 90 | 91 | type HelmContext struct { 92 | Upgrade bool `json:",omitempty"` 93 | RepoUrl string `json:",omitempty"` 94 | ChartName string `json:",omitempty"` 95 | ReleaseName string `json:",omitempty"` 96 | ChartVersion string `json:",omitempty"` 97 | RunningSensors string `json:",omitempty"` 98 | PreviousChartVersion string `json:",omitempty"` 99 | ResourcesPresets []string `json:",omitempty"` 100 | ValuesOverride map[string]interface{} `json:",omitempty"` 101 | AgentStatus map[string]k8s.PodStatus `json:",omitempty"` 102 | BackendStatus map[string]k8s.PodStatus `json:",omitempty"` 103 | BoundPvcs []string `json:",omitempty"` 104 | AllocatableResources *helm.AllocatableResources `json:",omitempty"` 105 | } 106 | 107 | func NewHelmContext(releaseName, chartName, repoUrl string) *HelmContext { 108 | return &HelmContext{ 109 | RepoUrl: repoUrl, 110 | ChartName: chartName, 111 | ReleaseName: releaseName, 112 | } 113 | } 114 | 115 | func (context HelmContext) SetOnCurrentScope() { 116 | sentry.CurrentHub().Scope().SetContext(HELM_CONTEXT_NAME, &context) 117 | } 118 | 119 | type SelfUpdateContext struct { 120 | CurrentVersion semver.Version `json:",omitempty"` 121 | LatestVersion semver.Version `json:",omitempty"` 122 | } 123 | 124 | func NewSelfUpdateContext(currentVersion, latestVersion semver.Version) *SelfUpdateContext { 125 | return &SelfUpdateContext{ 126 | CurrentVersion: currentVersion, 127 | LatestVersion: latestVersion, 128 | } 129 | } 130 | 131 | func (context SelfUpdateContext) SetOnCurrentScope() { 132 | sentry.CurrentHub().Scope().SetContext(SELF_UPDATE_CONTEXT_NAME, &context) 133 | } 134 | -------------------------------------------------------------------------------- /pkg/sentry/context_test.go: -------------------------------------------------------------------------------- 1 | package sentry_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | 8 | "github.com/blang/semver/v4" 9 | "github.com/getsentry/sentry-go" 10 | "github.com/google/uuid" 11 | "github.com/stretchr/testify/suite" 12 | "groundcover.com/pkg/k8s" 13 | sentry_utils "groundcover.com/pkg/sentry" 14 | "groundcover.com/pkg/ui" 15 | v1 "k8s.io/api/core/v1" 16 | ) 17 | 18 | type SentryContextTestSuite struct { 19 | suite.Suite 20 | Transport *TransportMock 21 | } 22 | 23 | func (suite *SentryContextTestSuite) SetupSuite() { 24 | suite.Transport = &TransportMock{} 25 | 26 | clientOptions := sentry.ClientOptions{ 27 | Dsn: "http://whatever@really.com/1337", 28 | Transport: suite.Transport, 29 | Integrations: func(i []sentry.Integration) []sentry.Integration { return []sentry.Integration{} }, 30 | } 31 | 32 | client, _ := sentry.NewClient(clientOptions) 33 | sentry.CurrentHub().BindClient(client) 34 | } 35 | 36 | func (suite *SentryContextTestSuite) TearDownSuite() {} 37 | 38 | func TestSentryContextSuite(t *testing.T) { 39 | suite.Run(t, &SentryContextTestSuite{}) 40 | } 41 | 42 | func (suite *SentryContextTestSuite) TestKubeContexJsonOmitEmpty() { 43 | //prepare 44 | sentryContext := &sentry_utils.KubeContext{} 45 | 46 | //act 47 | json, err := json.Marshal(sentryContext) 48 | suite.NoError(err) 49 | 50 | // assert 51 | expect := []byte("{}") 52 | suite.Equal(expect, json) 53 | } 54 | 55 | func (suite *SentryContextTestSuite) TestKubeContextSetOnCurrentScopeSuccess() { 56 | // prepare 57 | nodesCount := 2 58 | kubeconfig := uuid.New().String() 59 | kubecontext := uuid.New().String() 60 | tolerationsAndTaintsRatio := "1/1" 61 | 62 | sentryContext := sentry_utils.NewKubeContext(kubeconfig, kubecontext) 63 | sentryContext.NodesCount = nodesCount 64 | sentryContext.TolerationsAndTaintsRatio = tolerationsAndTaintsRatio 65 | 66 | // act 67 | sentryContext.SetOnCurrentScope() 68 | sentry.CaptureMessage("kube context") 69 | 70 | // assert 71 | expect := map[string]interface{}{ 72 | "kubernetes": &sentry_utils.KubeContext{ 73 | NodesCount: nodesCount, 74 | Kubeconfig: kubeconfig, 75 | Kubecontext: kubecontext, 76 | TolerationsAndTaintsRatio: tolerationsAndTaintsRatio, 77 | CompatibleNodeSamples: nil, 78 | IncompatibleNodeSamples: nil, 79 | ClusterReport: nil, 80 | }, 81 | } 82 | 83 | event := suite.Transport.lastEvent 84 | sentry.CurrentHub().Scope().RemoveContext(sentry_utils.KUBE_CONTEXT_NAME) 85 | 86 | suite.Equal(expect, event.Contexts) 87 | } 88 | 89 | func (suite *SentryContextTestSuite) TestKubeContextSetNodeReportSamplesDoesNotExceedMaxLenght() { 90 | // prepare 91 | kubeconfig := uuid.New().String() 92 | kubecontext := uuid.New().String() 93 | nodesCount := sentry_utils.MAX_NODE_REPORT_SAMPLES + 2 94 | 95 | nodesReport := &k8s.NodesReport{ 96 | CompatibleNodes: make([]*k8s.NodeSummary, nodesCount), 97 | IncompatibleNodes: make([]*k8s.IncompatibleNode, nodesCount), 98 | } 99 | 100 | sentryContext := sentry_utils.NewKubeContext(kubeconfig, kubecontext) 101 | 102 | // act 103 | sentryContext.SetNodesSamples(nodesReport) 104 | 105 | // assert 106 | expectCompatibleNodes := nodesReport.CompatibleNodes[:sentry_utils.MAX_NODE_REPORT_SAMPLES] 107 | expectInCompatibleNodes := nodesReport.IncompatibleNodes[:sentry_utils.MAX_NODE_REPORT_SAMPLES] 108 | 109 | suite.Equal(expectCompatibleNodes, sentryContext.CompatibleNodeSamples) 110 | suite.Equal(expectInCompatibleNodes, sentryContext.IncompatibleNodeSamples) 111 | } 112 | 113 | func (suite *SentryContextTestSuite) TestKubeContextSetNodeReportSamplesWithTaints() { 114 | // prepare 115 | kubeconfig := uuid.New().String() 116 | kubecontext := uuid.New().String() 117 | 118 | nodesReport := &k8s.NodesReport{ 119 | TaintedNodes: []*k8s.IncompatibleNode{ 120 | { 121 | NodeSummary: &k8s.NodeSummary{ 122 | Name: "node", 123 | Taints: []v1.Taint{ 124 | { 125 | Key: "key", 126 | Value: "value", 127 | Effect: "effect", 128 | }, 129 | }, 130 | }, 131 | }, 132 | }, 133 | } 134 | 135 | sentryContext := sentry_utils.NewKubeContext(kubeconfig, kubecontext) 136 | 137 | // act 138 | sentryContext.SetNodesSamples(nodesReport) 139 | 140 | // assert 141 | suite.Equal(nodesReport.TaintedNodes, sentryContext.TaintedNodeSamples) 142 | } 143 | 144 | func (suite *SentryContextTestSuite) TestHelmContexJsonOmitEmpty() { 145 | //prepare 146 | sentryContext := &sentry_utils.HelmContext{} 147 | 148 | //act 149 | json, err := json.Marshal(sentryContext) 150 | suite.NoError(err) 151 | 152 | // assert 153 | expect := []byte("{}") 154 | suite.Equal(expect, json) 155 | } 156 | 157 | func (suite *SentryContextTestSuite) TestHelmContextSetOnCurrentScopeSuccess() { 158 | //prepare 159 | chartVersion := "1.0.0" 160 | runningSensors := "1/1" 161 | previousChartVersion := "0.9.0" 162 | repoUrl := uuid.New().String() 163 | chartName := uuid.New().String() 164 | releaseName := uuid.New().String() 165 | resourcesPresets := []string{uuid.New().String()} 166 | valuesOverride := map[string]interface{}{"override": uuid.New().String()} 167 | 168 | sentryContext := sentry_utils.NewHelmContext(releaseName, chartName, repoUrl) 169 | sentryContext.Upgrade = true 170 | sentryContext.ChartVersion = chartVersion 171 | sentryContext.PreviousChartVersion = previousChartVersion 172 | sentryContext.ValuesOverride = valuesOverride 173 | sentryContext.ResourcesPresets = resourcesPresets 174 | sentryContext.RunningSensors = runningSensors 175 | 176 | //act 177 | sentryContext.SetOnCurrentScope() 178 | sentry.CaptureMessage("helm context") 179 | 180 | // assert 181 | expect := map[string]interface{}{ 182 | "helm": &sentry_utils.HelmContext{ 183 | Upgrade: true, 184 | RepoUrl: repoUrl, 185 | ChartName: chartName, 186 | ReleaseName: releaseName, 187 | ChartVersion: chartVersion, 188 | ValuesOverride: valuesOverride, 189 | ResourcesPresets: resourcesPresets, 190 | RunningSensors: runningSensors, 191 | PreviousChartVersion: previousChartVersion, 192 | }, 193 | } 194 | 195 | event := suite.Transport.lastEvent 196 | sentry.CurrentHub().Scope().RemoveContext(sentry_utils.HELM_CONTEXT_NAME) 197 | 198 | suite.Equal(expect, event.Contexts) 199 | } 200 | 201 | func (suite *SentryContextTestSuite) TestSelfUpdateContextSetOnCurrentScopeSuccess() { 202 | //prepare 203 | currentVersion := semver.MustParse("0.1.0") 204 | lastestVersion := semver.MustParse("1.0.0") 205 | 206 | sentryContext := sentry_utils.NewSelfUpdateContext(currentVersion, lastestVersion) 207 | 208 | //act 209 | sentryContext.SetOnCurrentScope() 210 | sentry.CaptureMessage("cli update context") 211 | 212 | // assert 213 | expect := map[string]interface{}{ 214 | "cli-update": &sentry_utils.SelfUpdateContext{ 215 | CurrentVersion: currentVersion, 216 | LatestVersion: lastestVersion, 217 | }, 218 | } 219 | 220 | event := suite.Transport.lastEvent 221 | sentry.CurrentHub().Scope().RemoveContext(sentry_utils.SELF_UPDATE_CONTEXT_NAME) 222 | 223 | suite.Equal(expect, event.Contexts) 224 | } 225 | 226 | func (suite *SentryContextTestSuite) TestCommandContextSetOnCurrentScopeSuccess() { 227 | //prepare 228 | start := time.Now() 229 | sentry.CurrentHub().Scope().SetTransaction("test") 230 | 231 | //act 232 | sentryContext := sentry_utils.NewCommandContext(start) 233 | sentryContext.SetOnCurrentScope() 234 | sentry.CaptureMessage("command context") 235 | 236 | // assert 237 | expect := map[string]interface{}{ 238 | "command": &sentry_utils.CommandContext{ 239 | Name: "test", 240 | Took: "0s", 241 | Log: ui.NewWriter(), 242 | }, 243 | } 244 | 245 | event := suite.Transport.lastEvent 246 | sentry.CurrentHub().Scope().RemoveContext(sentry_utils.COMMAND_CONTEXT_NAME) 247 | 248 | suite.Equal(expect, event.Contexts) 249 | } 250 | -------------------------------------------------------------------------------- /pkg/sentry/mocks_test.go: -------------------------------------------------------------------------------- 1 | package sentry_test 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | . "github.com/getsentry/sentry-go" 8 | ) 9 | 10 | type TransportMock struct { 11 | mu sync.Mutex 12 | events []*Event 13 | lastEvent *Event 14 | } 15 | 16 | func (t *TransportMock) Configure(options ClientOptions) {} 17 | 18 | func (t *TransportMock) SendEvent(event *Event) { 19 | t.mu.Lock() 20 | defer t.mu.Unlock() 21 | t.events = append(t.events, event) 22 | t.lastEvent = event 23 | } 24 | 25 | func (t *TransportMock) Flush(timeout time.Duration) bool { 26 | return true 27 | } 28 | 29 | func (t *TransportMock) Events() []*Event { 30 | t.mu.Lock() 31 | defer t.mu.Unlock() 32 | return t.events 33 | } 34 | -------------------------------------------------------------------------------- /pkg/ui/spinner.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "github.com/theckman/yacspin" 11 | ) 12 | 13 | const ( 14 | statusOK = "\u2714" 15 | statusErr = "\u2715" 16 | statusWarning = "\u270B" 17 | Bullet = "\u2022" 18 | spinnerCharset = 11 19 | ) 20 | 21 | var ErrSpinnerTimeout = errors.New("spinner timeout") 22 | 23 | type retryableError struct { 24 | error 25 | } 26 | 27 | func RetryableError(err error) error { 28 | return &retryableError{err} 29 | } 30 | 31 | type Spinner struct { 32 | *yacspin.Spinner 33 | writer *Writer 34 | 35 | mu *sync.Mutex 36 | stopFailChar string 37 | stopFailMsg string 38 | stopChar string 39 | stopMsg string 40 | wroteError bool 41 | } 42 | 43 | func newSpinner(writer *Writer, message string) *Spinner { 44 | cfg := yacspin.Config{ 45 | Frequency: 100 * time.Millisecond, 46 | Colors: []string{"fgBlue"}, 47 | CharSet: yacspin.CharSets[spinnerCharset], 48 | SuffixAutoColon: true, 49 | Suffix: " ", 50 | Message: message, 51 | StopCharacter: statusOK, 52 | StopColors: []string{"fgGreen"}, 53 | StopFailCharacter: statusErr, 54 | StopFailColors: []string{"fgRed"}, 55 | } 56 | 57 | s, _ := yacspin.New(cfg) 58 | 59 | spinner := Spinner{ 60 | Spinner: s, 61 | writer: writer, 62 | mu: &sync.Mutex{}, 63 | stopFailChar: statusErr, 64 | stopChar: statusOK, 65 | } 66 | return &spinner 67 | } 68 | 69 | func (s *Spinner) SetWarningSign() { 70 | s.mu.Lock() 71 | defer s.mu.Unlock() 72 | 73 | s.stopFailChar = statusWarning 74 | s.StopFailCharacter(statusWarning) 75 | s.StopFailColors("fgYellow") 76 | } 77 | 78 | func (s *Spinner) WriteMessage(message string) { 79 | s.writer.Writeln(message) 80 | s.Message(message) 81 | } 82 | 83 | func (s *Spinner) SetStopMessage(message string) { 84 | s.mu.Lock() 85 | defer s.mu.Unlock() 86 | 87 | s.stopMsg = message 88 | s.StopMessage(message) 89 | } 90 | 91 | func (s *Spinner) WriteStop() { 92 | s.mu.Lock() 93 | defer s.mu.Unlock() 94 | 95 | if s.wroteError { 96 | return 97 | } 98 | 99 | s.writer.Writeln(fmt.Sprintf("%v %v", s.stopChar, s.stopMsg)) 100 | s.Stop() 101 | } 102 | 103 | func (s *Spinner) SetStopFailMessage(message string) { 104 | s.mu.Lock() 105 | defer s.mu.Unlock() 106 | 107 | s.stopFailMsg = message 108 | s.StopFailMessage(message) 109 | } 110 | 111 | func (s *Spinner) WriteStopFail() { 112 | s.mu.Lock() 113 | defer s.mu.Unlock() 114 | 115 | s.wroteError = true 116 | s.writer.Writeln(fmt.Sprintf("%v %v", s.stopFailChar, s.stopFailMsg)) 117 | s.StopFail() 118 | } 119 | 120 | func (s *Spinner) Poll(ctx context.Context, function func() error, interval, duration time.Duration, maxRetries int) error { 121 | var attempts int 122 | 123 | timeout := time.After(duration) 124 | ticker := time.NewTicker(interval) 125 | 126 | for { 127 | select { 128 | case <-ctx.Done(): 129 | return ctx.Err() 130 | case <-timeout: 131 | return ErrSpinnerTimeout 132 | case <-ticker.C: 133 | err := function() 134 | 135 | if err == nil { 136 | return nil 137 | } 138 | 139 | var retryableErr *retryableError 140 | if !errors.As(err, &retryableErr) { 141 | return err 142 | } 143 | 144 | if attempts >= maxRetries { 145 | return retryableErr 146 | } 147 | 148 | attempts++ 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /pkg/ui/spinner_test.go: -------------------------------------------------------------------------------- 1 | package ui_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/suite" 10 | "groundcover.com/pkg/ui" 11 | ) 12 | 13 | type SpinnerTestSuite struct { 14 | suite.Suite 15 | } 16 | 17 | func (suite *SpinnerTestSuite) SetupSuite() {} 18 | 19 | func (suite *SpinnerTestSuite) TearDownSuite() {} 20 | 21 | func TestSpinnerSuite(t *testing.T) { 22 | suite.Run(t, &SpinnerTestSuite{}) 23 | } 24 | 25 | func (suite *SpinnerTestSuite) TestPollFuncSuccues() { 26 | //prepare 27 | ctx := context.Background() 28 | spinner := ui.NewWriter().NewSpinner("test") 29 | 30 | //act 31 | testFunc := func() error { 32 | return nil 33 | } 34 | 35 | err := spinner.Poll(ctx, testFunc, time.Millisecond, time.Second, 0) 36 | 37 | // assert 38 | suite.NoError(err) 39 | } 40 | 41 | func (suite *SpinnerTestSuite) TestPollFuncMaxRetries() { 42 | //prepare 43 | ctx := context.Background() 44 | spinner := ui.NewWriter().NewSpinner("test") 45 | myError := errors.New("test") 46 | 47 | //act 48 | var attempts int 49 | testFunc := func() error { 50 | attempts++ 51 | return ui.RetryableError(myError) 52 | } 53 | 54 | err := spinner.Poll(ctx, testFunc, time.Millisecond, time.Second, 1) 55 | 56 | // assert 57 | suite.Equal(2, attempts) 58 | suite.ErrorContains(err, "test") 59 | } 60 | 61 | func (suite *SpinnerTestSuite) TestPollFuncTimeout() { 62 | //prepare 63 | ctx := context.Background() 64 | myError := errors.New("test") 65 | spinner := ui.NewWriter().NewSpinner("test") 66 | 67 | //act 68 | testFunc := func() error { 69 | time.Sleep(time.Millisecond * 500) 70 | return ui.RetryableError(myError) 71 | } 72 | 73 | err := spinner.Poll(ctx, testFunc, time.Millisecond, time.Second, 100) 74 | 75 | // assert 76 | suite.ErrorIs(err, ui.ErrSpinnerTimeout) 77 | } 78 | 79 | func (suite *SpinnerTestSuite) TestPollFuncNonRetryableError() { 80 | //prepare 81 | ctx := context.Background() 82 | myError := errors.New("test") 83 | spinner := ui.NewWriter().NewSpinner("test") 84 | 85 | //act 86 | var attempts int 87 | testFunc := func() error { 88 | attempts++ 89 | return myError 90 | } 91 | 92 | err := spinner.Poll(ctx, testFunc, time.Millisecond, time.Second, 100) 93 | 94 | // assert 95 | suite.Equal(1, attempts) 96 | suite.ErrorIs(err, myError) 97 | } 98 | -------------------------------------------------------------------------------- /pkg/ui/writer.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/AlecAivazis/survey/v2" 11 | "github.com/fatih/color" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | const ( 16 | ASSUME_YES_FLAG = "yes" 17 | ) 18 | 19 | type Writer struct { 20 | writen []string 21 | } 22 | 23 | var ( 24 | greenStatusOk = color.GreenString(statusOK) 25 | redStatusErr = color.RedString(statusErr) 26 | writenStatusOk = "V" 27 | writenStatusErr = "X" 28 | writenStatusWarn = "!" 29 | ) 30 | 31 | func NewWriter() *Writer { 32 | return &Writer{ 33 | writen: []string{}, 34 | } 35 | } 36 | 37 | var GlobalWriter = NewWriter() 38 | var QuietWriter = NewWriter() 39 | 40 | func (w *Writer) MarshalJSON() ([]byte, error) { 41 | return json.Marshal((w.Dump())) 42 | } 43 | 44 | func (w *Writer) Writeln(message string) { 45 | w.addMessage(fmt.Sprintln(message)) 46 | } 47 | 48 | func (w *Writer) Println(message string) { 49 | w.addMessage(message) 50 | fmt.Println(message) 51 | } 52 | 53 | func (w *Writer) PrintlnWithPrefixln(message string) { 54 | w.addMessage(message) 55 | fmt.Printf("\n%s\n", message) 56 | } 57 | 58 | func (w *Writer) PrintflnWithPrefixln(format string, args ...interface{}) { 59 | message := fmt.Sprintf(format, args...) 60 | w.addMessage(message) 61 | fmt.Printf("\n%s\n", message) 62 | } 63 | 64 | func (w *Writer) Printf(format string, args ...interface{}) { 65 | formatted := fmt.Sprintf(format, args...) 66 | w.addMessage(formatted) 67 | fmt.Print(formatted) 68 | } 69 | 70 | func (w *Writer) PrintUrl(message string, url string) { 71 | w.addMessage(fmt.Sprintf("%s%s", message, url)) 72 | fmt.Printf("%s%s\n", message, w.UrlLink(url)) 73 | } 74 | 75 | func (w *Writer) Errorf(format string, args ...interface{}) error { 76 | formatted := fmt.Sprintf(format, args...) 77 | w.addMessage(formatted) 78 | return errors.New(formatted) 79 | } 80 | 81 | func (w *Writer) PrintSuccessMessage(message string) { 82 | w.addMessage(fmt.Sprintf("%s %s", writenStatusOk, message)) 83 | fmt.Printf("%s %s", greenStatusOk, message) 84 | } 85 | 86 | func (w *Writer) PrintSuccessMessageln(message string) { 87 | w.addMessage(fmt.Sprintf("%s %s", writenStatusOk, message)) 88 | fmt.Printf("%s %s\n", greenStatusOk, message) 89 | } 90 | 91 | func (w *Writer) PrintErrorMessage(message string) { 92 | w.addMessage(fmt.Sprintf("%s %s", writenStatusErr, message)) 93 | fmt.Printf("%s %s", redStatusErr, message) 94 | } 95 | 96 | func (w *Writer) PrintErrorMessageln(message string) { 97 | w.addMessage(fmt.Sprintf("%s %s", writenStatusErr, message)) 98 | fmt.Printf("%s %s\n", redStatusErr, message) 99 | } 100 | 101 | func (w *Writer) PrintWarningMessage(message string) { 102 | w.addMessage(fmt.Sprintf("%s %s", writenStatusWarn, message)) 103 | fmt.Printf("%s %s", statusWarning, message) 104 | } 105 | 106 | func (w *Writer) PrintWarningMessageln(message string) { 107 | w.addMessage(fmt.Sprintf("%s %s", writenStatusWarn, message)) 108 | fmt.Printf("%s %s\n", statusWarning, message) 109 | } 110 | 111 | func (w *Writer) PrintNoticeMessage(message string) { 112 | w.addMessage(message) 113 | fmt.Printf("🚨 %s", message) 114 | } 115 | 116 | func (w *Writer) UrlLink(url string) string { 117 | return color.New(color.FgBlue).Add(color.Underline).Sprint(url) 118 | } 119 | 120 | func (w *Writer) NewSpinner(message string) *Spinner { 121 | w.addMessage(message) 122 | return newSpinner(w, message) 123 | } 124 | 125 | func (w *Writer) YesNoPrompt(message string, defaultValue bool) bool { 126 | if viper.GetBool(ASSUME_YES_FLAG) { 127 | return true 128 | } 129 | 130 | prompt := &survey.Confirm{ 131 | Message: message, 132 | Default: defaultValue, 133 | } 134 | 135 | var answer bool 136 | survey.AskOne(prompt, &answer) 137 | w.addMessage(fmt.Sprintf("%s %t", message, answer)) 138 | return answer 139 | } 140 | 141 | func (w *Writer) MultiSelectPrompt(message string, options, defaults []string) []string { 142 | if viper.GetBool(ASSUME_YES_FLAG) { 143 | return defaults 144 | } 145 | 146 | prompt := &survey.MultiSelect{ 147 | Options: options, 148 | Default: defaults, 149 | Message: message, 150 | } 151 | 152 | var response []string 153 | survey.AskOne(prompt, &response) 154 | w.addMessage(fmt.Sprintf("%s %v", message, response)) 155 | return response 156 | } 157 | 158 | func (w *Writer) SelectPrompt(message string, options []string) string { 159 | if viper.GetBool(ASSUME_YES_FLAG) { 160 | return options[0] 161 | } 162 | 163 | prompt := &survey.Select{ 164 | Options: options, 165 | Default: options[0], 166 | Message: message, 167 | } 168 | 169 | var response string 170 | survey.AskOne(prompt, &response) 171 | w.addMessage(fmt.Sprintf("%s %v", message, response)) 172 | return response 173 | } 174 | 175 | func (w *Writer) timeFormat(message string) string { 176 | timeFormatted := time.Now().Format(time.RFC3339) 177 | 178 | return fmt.Sprintf("%s - %s", timeFormatted, message) 179 | } 180 | 181 | func (w *Writer) addMessage(message string) { 182 | lines := strings.Split(message, "\n") 183 | for _, line := range lines { 184 | if line == "" { 185 | continue 186 | } 187 | 188 | w.writen = append(w.writen, w.timeFormat(line)) 189 | } 190 | } 191 | 192 | func (w *Writer) Dump() string { 193 | return strings.Join(w.writen, "\n") 194 | } 195 | -------------------------------------------------------------------------------- /pkg/utils/browser.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/skratchdot/open-golang/open" 5 | "groundcover.com/pkg/ui" 6 | ) 7 | 8 | func TryOpenBrowser(writer *ui.Writer, message string, url string) { 9 | writer.PrintUrl(message, url) 10 | open.Run(url) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/utils/diskv.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/peterbourgon/diskv/v3" 8 | ) 9 | 10 | const ( 11 | STROAGE_PREFIX = ".groundcover" 12 | ) 13 | 14 | var PresistentStorage *diskv.Diskv = NewStorage() 15 | 16 | func NewStorage() *diskv.Diskv { 17 | var err error 18 | 19 | var baseDir string 20 | if baseDir, err = os.UserHomeDir(); err != nil { 21 | baseDir = os.TempDir() 22 | } 23 | 24 | diskv := diskv.New(diskv.Options{ 25 | BasePath: filepath.Join(baseDir, STROAGE_PREFIX), 26 | Transform: func(s string) []string { return []string{} }, 27 | }) 28 | 29 | return diskv 30 | } 31 | --------------------------------------------------------------------------------