├── .github ├── actions │ └── setup-goversion │ │ └── action.yaml ├── dependabot.yml └── workflows │ ├── docker.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .goreleaser.yaml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── catalog-info.yaml ├── cmd ├── bot │ ├── comment.md │ ├── config.go │ ├── main.go │ └── main_test.go └── estimator │ └── main.go ├── docs └── contribute │ └── release.md ├── go.mod ├── go.sum └── pkg ├── costmodel ├── aux_test.go ├── client.go ├── client_test.go ├── comment.tmpl.md ├── costmodel.go ├── costmodel_test.go ├── markdown_reporter.go ├── markdown_reporter_test.go ├── reporter.go ├── reporter_test.go ├── requirements.go ├── requirements_test.go ├── testdata │ └── resource │ │ ├── DaemonSet-more-requests.json │ │ ├── DaemonSet.json │ │ ├── Deployment-more-requests.json │ │ ├── Deployment.json │ │ ├── Job.json │ │ ├── Pod.json │ │ ├── StatefulSet-more-storage.json │ │ ├── StatefulSet-with-2-containers.json │ │ ├── StatefulSet-with-replicas.json │ │ ├── StatefulSet-without-replicas.yaml │ │ └── StatefulSet.json └── utils │ ├── utils.go │ └── utils_test.go ├── git └── repository.go └── github ├── client.go ├── config.go └── config_test.go /.github/actions/setup-goversion/action.yaml: -------------------------------------------------------------------------------- 1 | # This action extracts the go version from the Dockerfile and uses the value for the setup-go action used in later workflows 2 | name: setup-goversion 3 | description: Extracts the go version from Dockerfile and uses this version setup go 4 | runs: 5 | using: composite 6 | steps: 7 | - id: goversion 8 | run: | 9 | cat Dockerfile | awk 'BEGIN{IGNORECASE=1} /^FROM golang:.* AS build$/ {v=$2;split(v,a,":|-")}; END {printf("version=%s", a[2])}' >> $GITHUB_OUTPUT 10 | shell: bash 11 | - uses: actions/setup-go@v5 12 | with: 13 | go-version: "${{steps.goversion.outputs.version}}" 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "docker" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/build/ci/github-actions/multi-platform/ 2 | name: Build and Push Image 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - v* 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | env: 16 | REGISTRY_IMAGE: grafana/kost 17 | # Docker image tags. See https://github.com/docker/metadata-action for format 18 | TAGS_CONFIG: | 19 | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} 20 | type=sha,prefix={{ branch }}-,format=short,enable=${{ github.ref == 'refs/heads/main' }} 21 | type=semver,pattern={{ version }} 22 | 23 | jobs: 24 | build: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | with: 29 | persist-credentials: false 30 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 31 | 32 | - name: Build and push 33 | uses: grafana/shared-workflows/actions/build-push-to-dockerhub@a30107276148b4f29eaeaef05a3f9173d1aa0ad9 34 | with: 35 | repository: ${{ env.REGISTRY_IMAGE }} 36 | context: . 37 | push: true 38 | platforms: linux/amd64 39 | tags: ${{ env.TAGS_CONFIG }} 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | goreleaser: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | with: 18 | persist-credentials: false 19 | fetch-depth: 0 20 | - name: Set up Go 21 | uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # 5.1.0 22 | with: 23 | cache: false 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # 6.1.0 26 | with: 27 | distribution: goreleaser 28 | version: 2 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build-lint-test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 20 | with: 21 | persist-credentials: false 22 | 23 | - uses: ./.github/actions/setup-goversion 24 | 25 | - name: Build 26 | run: go build -v ./... 27 | 28 | - name: Lint 29 | uses: golangci/golangci-lint-action@d6238b002a20823d52840fda27e2d4891c5952dc 30 | with: 31 | version: v1.64.7 32 | 33 | - name: Test 34 | run: go test -v ./... 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 3 | 4 | ### Go ### 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Test binary, built with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | kost 27 | 28 | # End of https://www.toptal.com/developers/gitignore/api/go 29 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # Make sure to check the documentation at https://goreleaser.com 2 | 3 | version: 2 4 | 5 | project_name: kost 6 | builds: 7 | - id: kost 8 | binary: kost 9 | main: ./cmd/bot 10 | env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | goarch: 15 | - amd64 16 | - arm64 17 | 18 | archives: 19 | - formats: ['tar.gz'] 20 | # this name template makes the OS and Arch compatible with the results of uname. 21 | name_template: >- 22 | {{ .ProjectName }}_ 23 | {{- title .Os }}_ 24 | {{- if eq .Arch "amd64" }}x86_64 25 | {{- else }}{{ .Arch }}{{ end }} 26 | {{- if .Arm }}v{{ .Arm }}{{ end }} 27 | checksum: 28 | name_template: 'checksums.txt' 29 | changelog: 30 | sort: asc 31 | filters: 32 | exclude: 33 | - '^docs' 34 | - '^chore(deps):' 35 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default all reviews to be Grafana's Monitoring squad 2 | * @grafana/platform-monitoring 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@grafana.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build Go Binary 2 | FROM golang:1.24.1 AS build 3 | 4 | WORKDIR /app 5 | COPY ["go.mod", "go.sum", "./"] 6 | RUN go mod download 7 | 8 | COPY . . 9 | RUN make build-binary 10 | 11 | FROM debian:bullseye-slim 12 | 13 | RUN apt-get -qqy update && \ 14 | apt-get -qqy install git-core && \ 15 | apt-get -qqy autoclean && \ 16 | apt-get -qqy autoremove 17 | 18 | COPY --from=build /app/kost /app/ 19 | ENTRYPOINT ["/app/kost"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [2024] [Grafana Labs] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build-image build-binary build test push push-dev 2 | 3 | VERSION=$(shell git describe --tags --dirty --always) 4 | 5 | IMAGE_PREFIX=grafana 6 | 7 | IMAGE_NAME=kost 8 | IMAGE_NAME_LATEST=${IMAGE_PREFIX}/${IMAGE_NAME}:latest 9 | IMAGE_NAME_VERSION=$(IMAGE_PREFIX)/$(IMAGE_NAME):$(VERSION) 10 | DIVE_HIGHEST_USER_WASTED_PERCENT := 0.15 11 | 12 | PROM_VERSION_PKG ?= github.com/prometheus/common/version 13 | BUILD_USER ?= $(shell whoami)@$(shell hostname) 14 | BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 15 | GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) 16 | GIT_REVISION ?= $(shell git rev-parse --short HEAD) 17 | GO_LDFLAGS = -X $(PROM_VERSION_PKG).Branch=$(GIT_BRANCH) -X $(PROM_VERSION_PKG).Version=$(VERSION) -X $(PROM_VERSION_PKG).Revision=$(GIT_REVISION) -X ${PROM_VERSION_PKG}.BuildUser=${BUILD_USER} -X ${PROM_VERSION_PKG}.BuildDate=${BUILD_DATE} 18 | 19 | build-image: 20 | docker build --build-arg GO_LDFLAGS="$(GO_LDFLAGS)" -t $(IMAGE_PREFIX)/$(IMAGE_NAME) -t $(IMAGE_NAME_VERSION) . 21 | 22 | build-binary: 23 | CGO_ENABLED=0 go build -v -ldflags "$(GO_LDFLAGS)" -o kost ./cmd/bot 24 | 25 | build: build-binary build-image 26 | 27 | test: build 28 | go test -v ./... 29 | 30 | lint: 31 | golangci-lint run ./... 32 | 33 | push-dev: build test 34 | docker push $(IMAGE_NAME_VERSION) 35 | 36 | push: build test push-dev 37 | docker push $(IMAGE_NAME_LATEST) 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kost 2 | 3 | Kost is a tool built at Grafana Labs to estimate the cost of workloads running in our k8s clusters. 4 | 5 | > [!CAUTION] 6 | > This is still highly experimental and somewhat tightly coupled to how Grafana Labs manages and monitors our k8s infrastructure. 7 | > We'd love to support other teams and organizations, but do not have the bandwidth to implement it. 8 | > If you want to adopt the tool, please fill out an issue and connect with us! 9 | 10 | 11 | ## Requirements 12 | 13 | `kost` is somewhat tightly coupled to how Grafana Labs manages our k8s environments. 14 | Specifically, the following assumptions need to be met: 15 | - K8s resources defined in a standalone repository 16 | - We use [jsonnet](https://jsonnet.org/) to define resources + [tanka](https://tanka.dev/) to generate the k8s manifest files and commit them to a `kube-manifest` repo + [flux](https://fluxcd.io/) to deploy them to clusters 17 | - Mimir to store [cloudcost-exporter](https://github.com/grafana/cloudcost-exporter) metrics for cost data 18 | - GitHub Actions to detect changes and run the cost report 19 | 20 | While these are what we use internally and require, in theory the bot should work so long as you have: 21 | 1. Two manifest files that you can compare changes 22 | 2. Prometheus compliant backend with cost metrics 23 | 3. A CI system to run the bot when changes happen 24 | 25 | 26 | ## Prerequisites for local development 27 | 28 | - HTTP access to a prometheus server that has [cloudcost-exporter](https://github.com/grafana/cloudcost-exporter) metrics available 29 | - For example, navigate to `https:///connections/datasources/`, identify the Prometheus (or Mimir!) datasource to use, and copy the value of `Prometheus server URL`. 30 | - A local copy of the repository that stores kube-manifest files 31 | - See [flux](https://fluxcd.io/flux/guides/repository-structure/) docs a similar structure to what Grafana Labs uses 32 | - If you have a Mimir instance running on Grafana Cloud, create an [access policy with a token](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) with `metrics:read` permissions 33 | - Create a yaml file with basic auth creds to use (see below). 34 | - Replace `` with the the username of the datasource account. For example, after finding the datasource to use in `https:///connections/datasources/`, find the username under `Authentication` > `Basic authentiaction` > `User`. 35 | - Replace `` with the token you created earlier. 36 | 37 | ```yaml 38 | basic_auth: 39 | username: 40 | password: 41 | ``` 42 | 43 | ## Running 44 | 45 | There are two entrypoints that you can run: 46 | - estimator 47 | - bot 48 | 49 | Estimator is a simple cli that accepts two manifest files and a set of clusters to generate the cost estimator for. 50 | Bot is what is ran in GitHub Actions today and requires the `kube-manifest` repository to be available locally. 51 | 52 | ## Estimator 53 | 54 | To check the cost on a single cluster, run the following command: 55 | ```shell 56 | go run ./cmd/estimator/ \ 57 | -from $PWD/pkg/costmodel/testdata/resource/Deployment.json \ 58 | -to $PWD/pkg/costmodel/testdata/resource/Deployment-more-requests.json \ 59 | -http.config.file /tmp/dev.yaml \ 60 | -prometheus.address $PROMETHEUS_ADDRESS \ 61 | 62 | ``` 63 | 64 | To check the cost across multiple clusters, run the following command: 65 | ```shell 66 | go run ./cmd/estimator/ \ 67 | -from $PWD/pkg/costmodel/testdata/resource/Deployment.json \ 68 | -to $PWD/pkg/costmodel/testdata/resource/Deployment-more-requests.json \ 69 | -http.config.file /tmp/dev.yaml \ 70 | -prometheus.address $PROMETHEUS_ADDRESS \ 71 | 72 | ``` 73 | 74 | ## Kost(bot) 75 | 76 | Set the following environment variables: 77 | 78 | - `KUBE_MANIFESTS_PATH`: path to `grafana/kube-manifests` 79 | - `HTTP_CONFIG_FILE`: path to configuration created in [Prereqs](#prerequisites) 80 | - `PROMETHEUS_ADDRESS`: Prometheus compatible TSDB endpoint 81 | - `GITHUB_PULL_REQUEST`: GitHub PR to create comment on 82 | - `GITHUB_EVENT_NAME`: set to `pull_request` 83 | - `GITHUB_TOKEN`: set to a token that is able to comment on PRs 84 | - `CI`: set to `true` 85 | 86 | ``` 87 | go run ./cmd/bot/ 88 | ``` 89 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting security issues 2 | 3 | If you think you have found a security vulnerability, please send a report to [security@grafana.com](mailto:security@grafana.com). This address can be used for all of Grafana Labs's open source and commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). We can accept only vulnerability reports at this address. 4 | 5 | Please encrypt your message to us; please use our PGP key. The key fingerprint is: 6 | 7 | F988 7BEA 027A 049F AE8E 5CAA D125 8932 BE24 C5CA 8 | 9 | The key is available from [pgp.mit.edu](https://pgp.mit.edu/pks/lookup?op=get&search=0xF9887BEA027A049FAE8E5CAAD1258932BE24C5CA) by searching for [grafana](https://pgp.mit.edu/pks/lookup?search=grafana&op=index). 10 | 11 | Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. 12 | 13 | **Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so. 14 | 15 | ## Security announcements 16 | 17 | We maintain a category on the community site called [Security Announcements](https://community.grafana.com/c/security-announcements), 18 | where we will post a summary, remediation, and mitigation details for any patch containing security fixes. 19 | 20 | You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the community site or track updates via an [RSS feed](https://community.grafana.com/c/security-announcements.rss). 21 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: kost 5 | title: kost 6 | annotations: 7 | github.com/project-slug: grafana/kost 8 | links: 9 | - title: Community Slack Channel 10 | url: https://grafana.slack.com/archives/C064EMEB4E6 11 | - title: Internal Slack Channel 12 | url: https://raintank-corp.slack.com/archives/C03PDLFK29K 13 | description: | 14 | k8s cost estimation reporter that can be run locally or via GitHub Actions 15 | spec: 16 | type: tool 17 | owner: group:default/platform-monitoring 18 | lifecycle: production 19 | 20 | -------------------------------------------------------------------------------- /cmd/bot/comment.md: -------------------------------------------------------------------------------- 1 | {{ .Prefix }} 2 | ## :dollar: Cost Report :moneybag: 3 | {{ .Summary }} 4 | 5 |
6 |
 7 | {{ .Details }}
 8 | 
9 |
10 | 11 | {{ if .Warnings -}} 12 |
13 | Warnings: the following errors happened while calculating the cost: 14 | {{ range .Warnings }} 15 | - {{ . -}} 16 | {{ end }} 17 |
18 | {{ end }} 19 | -------------------------------------------------------------------------------- /cmd/bot/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/kelseyhightower/envconfig" 10 | 11 | "github.com/grafana/kost/pkg/github" 12 | ) 13 | 14 | type promConfig struct { 15 | Address string `envconfig:"PROMETHEUS_ADDRESS" default:"http://localhost:9090/" required:"true"` 16 | HTTPConfigFile string `envconfig:"HTTP_CONFIG_FILE"` 17 | Username string `envconfig:"MIMIR_USER_ID"` 18 | Password string `envconfig:"MIMIR_USER_PASSWORD"` 19 | MaxConcurrentQueries int `envconfig:"MAX_CONCURRENT_QUERIES" default:"-1"` // -1 means unlimited 20 | } 21 | 22 | type config struct { 23 | Manifests struct { 24 | RepoPath string `envconfig:"KUBE_MANIFESTS_PATH" required:"true"` 25 | Head string `envconfig:"KUBE_MANIFESTS_SHA1"` 26 | } 27 | 28 | Prometheus struct { 29 | Prod, Dev promConfig 30 | } 31 | 32 | GitHub github.Config 33 | 34 | IsCI bool `envconfig:"CI"` 35 | PR int `envconfig:"GITHUB_PULL_REQUEST" required:"true"` 36 | Event string `envconfig:"GITHUB_EVENT_NAME"` 37 | LogLevel string `envconfig:"LOG_LEVEL" default:"info"` 38 | } 39 | 40 | const pullRequestEvent = "pull_request" 41 | 42 | func parseConfig() (config, error) { 43 | var c config 44 | if err := envconfig.Process("", &c); err != nil { 45 | return c, err 46 | } 47 | if err := envconfig.Process("DEV", &c.Prometheus.Dev); err != nil { 48 | return c, fmt.Errorf("parsing envconfig for Prometheus dev: %w", err) 49 | } 50 | return c, nil 51 | } 52 | 53 | func (c config) validate() error { 54 | if !c.IsCI { 55 | return errors.New("this can only be run in CI") 56 | } 57 | 58 | if c.PR == 0 || c.Event != pullRequestEvent { 59 | return errors.New("expecting GITHUB_PULL_REQUEST and GITHUB_EVENT_NAME to be set") 60 | } 61 | 62 | if err := c.GitHub.Validate(); err != nil { 63 | return fmt.Errorf("github configuration: %w", err) 64 | } 65 | 66 | var level slog.Level 67 | if err := level.UnmarshalText([]byte(c.LogLevel)); err != nil { 68 | return fmt.Errorf("parsing log level: %w", err) 69 | } 70 | logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) 71 | slog.SetDefault(logger) 72 | // TODO check repo exists 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /cmd/bot/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "os" 10 | "sort" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "golang.org/x/sync/errgroup" 16 | 17 | "github.com/grafana/kost/pkg/costmodel" 18 | 19 | "github.com/grafana/kost/pkg/git" 20 | "github.com/grafana/kost/pkg/github" 21 | ) 22 | 23 | //go:embed comment.md 24 | //nolint:unused 25 | var commentTemplate string 26 | 27 | var ( 28 | // ErrNoClustersFound is returned when no clusters are found in the changed files 29 | ErrNoClustersFound = errors.New("no clusters found for changed file") 30 | ) 31 | 32 | func main() { 33 | ctx := context.TODO() 34 | start := time.Now() 35 | defer func() { 36 | slog.Info("finished", "method", "main", "duration", time.Since(start)) 37 | }() 38 | 39 | if err := realMain(ctx); err != nil { 40 | slog.Error("failed to run", "method", "main", "error", err) 41 | // TODO: Once we have a better handle on the app, let's exit 1 42 | os.Exit(0) 43 | } 44 | } 45 | 46 | func realMain(ctx context.Context) error { 47 | start := time.Now() 48 | cfg, err := parseConfig() 49 | if err != nil { 50 | return fmt.Errorf("parsing configuration: %w", err) 51 | } 52 | 53 | if err := cfg.validate(); err != nil { 54 | return fmt.Errorf("validating configuration: %w", err) 55 | } 56 | 57 | prometheusClients, err := costmodel.NewClients( 58 | &costmodel.ClientConfig{ 59 | Address: cfg.Prometheus.Prod.Address, 60 | HTTPConfigFile: cfg.Prometheus.Prod.HTTPConfigFile, 61 | Username: cfg.Prometheus.Prod.Username, 62 | Password: cfg.Prometheus.Prod.Password, 63 | }, 64 | &costmodel.ClientConfig{ 65 | Address: cfg.Prometheus.Dev.Address, 66 | HTTPConfigFile: cfg.Prometheus.Dev.HTTPConfigFile, 67 | Username: cfg.Prometheus.Dev.Username, 68 | Password: cfg.Prometheus.Dev.Password, 69 | }) 70 | if err != nil { 71 | return fmt.Errorf("creating cost model client: %w", err) 72 | } 73 | 74 | repo := git.NewRepository(cfg.Manifests.RepoPath) 75 | 76 | oldCommit, err := repo.GetCommit(ctx, "HEAD^") 77 | if err != nil { 78 | return fmt.Errorf("getting commit: %w", err) 79 | } 80 | 81 | newCommit, err := repo.GetCommit(ctx, "HEAD") 82 | if err != nil { 83 | return fmt.Errorf("getting commit: %w", err) 84 | } 85 | 86 | cf, err := repo.ChangedFiles(ctx, oldCommit, newCommit) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | var ( 92 | comment strings.Builder 93 | reporter = costmodel.New(&comment, "markdown") 94 | ) 95 | 96 | costPerCluster := make(map[string]*costmodel.CostModel) 97 | var mu sync.RWMutex 98 | var warnings []error 99 | 100 | clusters := findClusters(cf) 101 | g := &errgroup.Group{} 102 | g.SetLimit(cfg.Prometheus.Prod.MaxConcurrentQueries) 103 | for _, cluster := range clusters { 104 | mu.RLock() 105 | _, ok := costPerCluster[cluster] 106 | mu.RUnlock() 107 | if !ok { 108 | cluster := cluster // https://golang.org/doc/faq#closures_and_goroutines 109 | g.Go(func() error { 110 | slog.Info("fetching cost model for cluster", "cluster", cluster) 111 | cost, err := prometheusClients.GetClusterCosts(ctx, cluster) 112 | mu.Lock() 113 | if err != nil { 114 | // TODO here we should probably return an error like below 115 | warnings = append(warnings, fmt.Errorf("fetching cost model for cluster %s: %w", cluster, err)) 116 | } 117 | costPerCluster[cluster] = cost 118 | mu.Unlock() 119 | slog.Info("finished fetching cost model for cluster", "cluster", cluster, "duration", time.Since(start)) 120 | return nil 121 | }) 122 | } 123 | } 124 | 125 | // We currently don't return an error if one of the goroutines fails 126 | _ = g.Wait() 127 | 128 | parseManifest := func(commit, path string) (*costmodel.CostModel, costmodel.Requirements, error) { 129 | slog.Info("parseManifest", "commit", commit, "path", path) 130 | var req costmodel.Requirements 131 | 132 | src, err := repo.Contents(ctx, commit, path) 133 | if err != nil { 134 | return nil, req, fmt.Errorf("checking %s:%s contents: %w", commit, path, err) 135 | } 136 | 137 | cm := costPerCluster[findCluster(path)] 138 | if cm == nil { 139 | slog.Error("no cost model found for path", "path", path) 140 | return nil, req, ErrNoClustersFound 141 | } 142 | req, err = costmodel.ParseManifest(src, cm) 143 | if err != nil { 144 | return nil, req, fmt.Errorf("parsing manifest %s:%s: %w", commit, path, err) 145 | } 146 | 147 | return cm, req, nil 148 | } 149 | 150 | start = time.Now() 151 | // Added files only increase 152 | for _, f := range cf.Added { 153 | cost, req, err := parseManifest(newCommit, f) 154 | if errors.Is(err, costmodel.ErrUnknownKind) || errors.Is(err, ErrNoClustersFound) { 155 | slog.Error("parsing manifest", "path", f, "error", err) 156 | continue 157 | } else if err != nil { 158 | return fmt.Errorf("added manifests: %w", err) 159 | } 160 | 161 | reporter.AddReport(cost, costmodel.Requirements{}, req) 162 | } 163 | slog.Info("Finished processing added files", "count", len(cf.Added), "duration", time.Since(start)) 164 | 165 | start = time.Now() 166 | // Deleted files only decrease 167 | for _, f := range cf.Deleted { 168 | cost, req, err := parseManifest(oldCommit, f) // get contents at previous commit 169 | if errors.Is(err, costmodel.ErrUnknownKind) || errors.Is(err, ErrNoClustersFound) { 170 | slog.Error("parsing manifest", "path", f, "error", err) 171 | continue 172 | } else if err != nil { 173 | return fmt.Errorf("deleted manifest: %w", err) 174 | } 175 | 176 | reporter.AddReport(cost, req, costmodel.Requirements{}) 177 | } 178 | slog.Info("Finished processing deleted files", "count", len(cf.Deleted), "duration", time.Since(start)) 179 | 180 | // Modified files 181 | for _, f := range cf.Modified { 182 | cost, from, err := parseManifest(oldCommit, f) // get contents at previous commit 183 | if errors.Is(err, costmodel.ErrUnknownKind) || errors.Is(err, ErrNoClustersFound) { 184 | slog.Error("parsing manifest", "path", f, "error", err) 185 | continue 186 | } else if err != nil { 187 | return fmt.Errorf("previous manifest: %w", err) 188 | } 189 | 190 | _, to, err := parseManifest(newCommit, f) 191 | if errors.Is(err, costmodel.ErrUnknownKind) || errors.Is(err, ErrNoClustersFound) { 192 | slog.Error("parsing manifest", "path", f, "error", err) 193 | continue 194 | } else if err != nil { 195 | return fmt.Errorf("new manifest: %w", err) 196 | } 197 | 198 | reporter.AddReport(cost, from, to) 199 | } 200 | slog.Info("Finished processing modified files", "count", len(cf.Modified), "duration", time.Since(start)) 201 | 202 | for old, f := range cf.Renamed { 203 | cost, from, err := parseManifest(oldCommit, old) // get contents at previous commit 204 | if errors.Is(err, costmodel.ErrUnknownKind) || errors.Is(err, ErrNoClustersFound) { 205 | slog.Error("parsing manifest", "path", old, "error", err) 206 | continue 207 | } else if err != nil { 208 | return fmt.Errorf("manifest before renaming: %w", err) 209 | } 210 | 211 | // TODO here we assume the cluster of the renamed file is the 212 | // same, but it could be a new one. 213 | _, to, err := parseManifest(newCommit, f) 214 | if errors.Is(err, costmodel.ErrUnknownKind) || errors.Is(err, ErrNoClustersFound) { 215 | slog.Error("parsing manifest", "path", f, "error", err) 216 | continue 217 | } else if err != nil { 218 | return fmt.Errorf("manifest after renaming: %w", err) 219 | } 220 | 221 | reporter.AddReport(cost, from, to) 222 | } 223 | slog.Info("Finished processing renamed files", "count", len(cf.Renamed), "duration", time.Since(start)) 224 | 225 | if err := reporter.Write(); errors.Is(err, costmodel.ErrNoReports) { 226 | return nil 227 | } else if err != nil { 228 | return fmt.Errorf("writing report: %w", err) 229 | } 230 | slog.Info("Finished", "method", "cost-model:write-report", "duration", time.Since(start)) 231 | 232 | gh, err := github.NewClient(ctx, cfg.GitHub) 233 | if err != nil { 234 | return fmt.Errorf("creating GitHub client: %w", err) 235 | } 236 | 237 | start = time.Now() 238 | if err := gh.HideCommentsWithPrefix(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo, cfg.PR, costmodel.CommentPrefix); err != nil { 239 | // Here we log this because there's no point in stopping the 240 | // program if it can't hide old comments. 241 | warnings = append(warnings, fmt.Errorf("hiding previous GitHub comments: %w", err)) 242 | } 243 | slog.Info("Finished", "method", "GitHub:hide-previous-comments", "duration", time.Since(start)) 244 | 245 | start = time.Now() 246 | if err := gh.Comment(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo, cfg.PR, comment.String()); err != nil { 247 | return fmt.Errorf("commenting on GitHub: %w", err) 248 | } 249 | slog.Info("Finished", "method", "GitHub:comment", "duration", time.Since(start)) 250 | 251 | if len(warnings) > 0 { 252 | fmt.Fprintln(os.Stderr, "WARNINGS:") 253 | for _, w := range warnings { 254 | slog.Error("warning", "message", w) 255 | } 256 | } 257 | 258 | return nil 259 | } 260 | 261 | func findCluster(path string) string { 262 | ps := strings.SplitN(path, "/", 3) 263 | // Prevent panic if the path is not in the expected format 264 | if len(ps) <= 1 { 265 | return "" 266 | } 267 | return ps[1] 268 | } 269 | 270 | func findClusters(cf git.ChangedFiles) []string { 271 | cs := make(map[string]struct{}) 272 | 273 | var fs []string 274 | 275 | fs = append(fs, cf.Added...) 276 | fs = append(fs, cf.Modified...) 277 | fs = append(fs, cf.Deleted...) 278 | for o, n := range cf.Renamed { 279 | fs = append(fs, o, n) 280 | } 281 | 282 | for _, f := range fs { 283 | // TODO find a better way to find the clusters 284 | if strings.HasPrefix(f, "flux/") || strings.HasPrefix(f, "flux-disabled/") { 285 | cs[findCluster(f)] = struct{}{} 286 | } 287 | } 288 | 289 | keys := make([]string, 0, len(cs)) 290 | for c := range cs { 291 | keys = append(keys, c) 292 | } 293 | 294 | sort.Strings(keys) 295 | 296 | return keys 297 | } 298 | -------------------------------------------------------------------------------- /cmd/bot/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/grafana/kost/pkg/git" 7 | ) 8 | 9 | func TestFindCluster(t *testing.T) { 10 | tests := map[string]string{ 11 | "flux/ops-us-east-0/exporters/Deployment-gcp-compute-exporter-grafanalabs-dev.yaml": "ops-us-east-0", 12 | "flux/dev-us-central-0/default/StatefulSet-prometheus.yaml": "dev-us-central-0", 13 | "flux/prod-us-central-0/default/StatefulSet-prometheus.yaml": "prod-us-central-0", 14 | "flux-disabled/ops-us-east-0/ctank-migrations/StatefulSet-cassandra-chunk-extractor-us.yaml": "ops-us-east-0", 15 | } 16 | 17 | for f, exp := range tests { 18 | if got := findCluster(f); exp != got { 19 | t.Errorf("expecting cluster %s for file %s, got %s", exp, f, got) 20 | } 21 | } 22 | } 23 | 24 | func TestFindClusters(t *testing.T) { 25 | cf := git.ChangedFiles{ 26 | Added: []string{ 27 | "flux/ops-us-east-0/exporters/Deployment-gcp-compute-exporter-grafanalabs-dev.yaml", 28 | "flux/ops-us-east-0/exporters/Deployment-gcp-compute-exporter-grafanalabs-global.yaml", 29 | }, 30 | Modified: []string{ 31 | "flux/dev-us-central-0/default/StatefulSet-prometheus.yaml", 32 | "flux/prod-us-central-0/default/StatefulSet-prometheus.yaml", 33 | }, 34 | Deleted: []string{ 35 | "flux/prod-us-central-0/default/StatefulSet-prometheus.yaml", 36 | }, 37 | Renamed: map[string]string{ 38 | "flux/prod-eu-west-2/default/StatefulSet-prometheus.yaml": "flux/prod-eu-west-2/default/Deployment-prometheus.yaml", 39 | }, 40 | } 41 | 42 | exp := []string{"dev-us-central-0", "ops-us-east-0", "prod-eu-west-2", "prod-us-central-0"} 43 | 44 | got := findClusters(cf) 45 | 46 | if e, g := len(exp), len(got); e != g { 47 | t.Fatalf("expecting %d clusters, got %d", e, g) 48 | } 49 | 50 | for i, e := range exp { 51 | if g := got[i]; e != g { 52 | t.Errorf("expecting cluster %s at index %d, got %s", e, i, g) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /cmd/estimator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/grafana/kost/pkg/costmodel" 10 | ) 11 | 12 | func main() { 13 | var fromFile, toFile, prometheusAddress, httpConfigFile, reportType, username, password string 14 | flag.StringVar(&fromFile, "from", "", "The file to compare from") 15 | flag.StringVar(&toFile, "to", "", "The file to compare to") 16 | flag.StringVar(&prometheusAddress, "prometheus.address", "http://localhost:9093/prometheus", "The Address of the prometheus server") 17 | flag.StringVar(&httpConfigFile, "http.config.file", "", "The path to the http config file") 18 | flag.StringVar(&username, "username", "", "Mimir username") 19 | flag.StringVar(&password, "password", "", "Mimir password") 20 | flag.StringVar(&reportType, "report.type", "table", "The type of report to generate. Options are: table, summary") 21 | flag.Parse() 22 | 23 | clusters := flag.Args() 24 | 25 | ctx := context.Background() 26 | if err := run(ctx, fromFile, toFile, prometheusAddress, httpConfigFile, reportType, username, password, clusters); err != nil { 27 | fmt.Printf("Could not run: %s\n", err) 28 | os.Exit(1) 29 | } 30 | } 31 | 32 | func run(ctx context.Context, fromFile, toFile, address, httpConfigFile, reportType, username, password string, clusters []string) error { 33 | from, err := os.ReadFile(fromFile) 34 | if err != nil { 35 | return fmt.Errorf("could not read file: %s", err) 36 | } 37 | 38 | // TODO: If the to file is not set, we should default to printing out the cost of the current configuration 39 | to, err := os.ReadFile(toFile) 40 | if err != nil { 41 | return fmt.Errorf("could not read file: %s", err) 42 | } 43 | 44 | client, err := costmodel.NewClient(&costmodel.ClientConfig{ 45 | Address: address, 46 | HTTPConfigFile: httpConfigFile, 47 | Username: username, 48 | Password: password, 49 | }) 50 | 51 | if err != nil { 52 | return fmt.Errorf("could not create cost model client: %s", err) 53 | } 54 | 55 | reporter := costmodel.New(os.Stdout, reportType) 56 | 57 | for _, cluster := range clusters { 58 | cost, err := costmodel.GetCostModelForCluster(ctx, client, cluster) 59 | if err != nil { 60 | return fmt.Errorf("could not get costmodel for cluster(%s): %s", cluster, err) 61 | } 62 | 63 | fromRequests, err := costmodel.ParseManifest(from, cost) 64 | if err != nil { 65 | return fmt.Errorf("could not parse manifest file(%s): %s", fromFile, err) 66 | } 67 | 68 | toRequests, err := costmodel.ParseManifest(to, cost) 69 | if err != nil { 70 | return fmt.Errorf("could not parse manifest file(%s): %s", toFile, err) 71 | } 72 | reporter.AddReport(cost, fromRequests, toRequests) 73 | } 74 | 75 | return reporter.Write() 76 | } 77 | -------------------------------------------------------------------------------- /docs/contribute/release.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | ## Building images 4 | 5 | There is a [GitHub Workflow](../../.github/workflows/docker.yml) that publishes images on changes to `main` and new tags. 6 | 7 | ## Versioning 8 | 9 | We follow [semver](https://semver.org/) and generate new tags manually. 10 | 11 | To cut a release, clone `kost` locally and pull latest from main. 12 | Determine if the next release is a major, minor, or hotfix. 13 | Then increment the relevant version label. 14 | 15 | For instance, let's say we're on `v0.2.2` and determined the next release is a minor change. 16 | The next version would then be `v0.3.0`. 17 | Execute the following command to generate the tag and push it: 18 | 19 | ```sh 20 | git tag v0.3.0 21 | # Optionally, add a message on why the specific version label was updated: git tag v0.3.0 -m "Adds liveness probes with backwards compatibility" 22 | git push origin tag v0.3.0 23 | ``` 24 | 25 | ## Releases 26 | 27 | Creating and pushing a new tag will trigger the `goreleaser` workflow in [./.github/workflows/release.yml](https://github.com/grafana/cloudcost-exporter/tree/main/.github/workflows/release.yml). 28 | 29 | The configuration for `goreleaser` itself can be found in [./.goreleaser.yaml](https://github.com/grafana/cloudcost-exporter/blob/main/.goreleaser.yaml). 30 | 31 | See https://github.com/grafana/cloudcost-exporter/issues/18 for progress on our path to automating releases. 32 | 33 | ## GitHub Actions 34 | 35 | When adding or upgrading a GitHub Actions `actions`, please set the full length commit SHA instead of the version: 36 | 37 | ``` 38 | jobs: 39 | myjob: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: foo/baraction@abcdef1234567890abcdef1234567890abcdef12 # v1.2.3 43 | ``` 44 | 45 | Granular control of the version helps with security since commit SHAs are immutable. 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/kost 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 9 | github.com/google/go-github/v50 v50.2.0 10 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 11 | github.com/kelseyhightower/envconfig v1.4.0 12 | github.com/prometheus/client_golang v1.21.1 13 | github.com/prometheus/common v0.64.0 14 | github.com/shurcooL/githubv4 v0.0.0-20230305132112-efb623903184 15 | golang.org/x/oauth2 v0.30.0 16 | k8s.io/api v0.32.3 17 | k8s.io/apimachinery v0.32.3 18 | k8s.io/client-go v0.32.3 19 | ) 20 | 21 | require ( 22 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 23 | github.com/google/go-github/v62 v62.0.0 // indirect 24 | github.com/kr/text v0.2.0 // indirect 25 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 26 | github.com/x448/float16 v0.8.4 // indirect 27 | golang.org/x/text v0.25.0 // indirect 28 | ) 29 | 30 | require ( 31 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect 32 | github.com/beorn7/perks v1.0.1 // indirect 33 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 34 | github.com/cloudflare/circl v1.3.7 // indirect 35 | github.com/go-logr/logr v1.4.2 // indirect 36 | github.com/gogo/protobuf v1.3.2 // indirect 37 | github.com/golang-jwt/jwt/v4 v4.5.0 // indirect 38 | github.com/google/go-querystring v1.1.0 // indirect 39 | github.com/google/gofuzz v1.2.0 // indirect 40 | github.com/jpillora/backoff v1.0.0 // indirect 41 | github.com/json-iterator/go v1.1.12 // indirect 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 43 | github.com/modern-go/reflect2 v1.0.2 // indirect 44 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect 45 | github.com/prometheus/client_model v0.6.2 // indirect 46 | github.com/prometheus/procfs v0.15.1 // indirect 47 | github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect 48 | golang.org/x/crypto v0.38.0 // indirect 49 | golang.org/x/net v0.40.0 // indirect 50 | golang.org/x/sync v0.14.0 51 | golang.org/x/sys v0.33.0 // indirect 52 | google.golang.org/protobuf v1.36.6 // indirect 53 | gopkg.in/inf.v0 v0.9.1 // indirect 54 | gopkg.in/yaml.v2 v2.4.0 // indirect 55 | k8s.io/klog/v2 v2.130.1 // indirect 56 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 57 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 58 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect 59 | sigs.k8s.io/yaml v1.4.0 // indirect 60 | ) 61 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= 2 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 h1:R9d0v+iobRHSaE4wKUnXFiZp53AL4ED5MzgEMwGTZag= 6 | github.com/bradleyfalzon/ghinstallation/v2 v2.11.0/go.mod h1:0LWKQwOHewXO/1acI6TtyE0Xc4ObDb2rFN7eHBAG71M= 7 | github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 8 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 9 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 10 | github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= 11 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 12 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 13 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 19 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 20 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 21 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 22 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 23 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 24 | github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= 25 | github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 26 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 27 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 28 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 29 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 30 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 31 | github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= 32 | github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= 33 | github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= 34 | github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= 35 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 36 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 37 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 38 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 39 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 40 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= 41 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= 42 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 43 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 44 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 45 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 46 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 47 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 48 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 49 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 50 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 51 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 52 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 53 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 54 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 55 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 56 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 57 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 58 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 59 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 60 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 61 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 62 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 63 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= 64 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 65 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 66 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 67 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 68 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 69 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 70 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 71 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 72 | github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 73 | github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 74 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 75 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 76 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 77 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 78 | github.com/shurcooL/githubv4 v0.0.0-20230305132112-efb623903184 h1:QwdHPs+b2raoqIDBgAkjYw89KHH2/CXbV+m2qrbDi9k= 79 | github.com/shurcooL/githubv4 v0.0.0-20230305132112-efb623903184/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= 80 | github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc= 81 | github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= 82 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 83 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 84 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 85 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 86 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 87 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 88 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 89 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 90 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 91 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 92 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 93 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 94 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 95 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 96 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 97 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 98 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 99 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 100 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 101 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 102 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 103 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 104 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 105 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 106 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 107 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 108 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 109 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 110 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 111 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 112 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 113 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 114 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 115 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 116 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 117 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 118 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 119 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 120 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 121 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 122 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 123 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 124 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 125 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 126 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 127 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 128 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 129 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 130 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 131 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 132 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 133 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 134 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 135 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 136 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 137 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 138 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 139 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 140 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 141 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 142 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 143 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 144 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 145 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 146 | k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= 147 | k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= 148 | k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= 149 | k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= 150 | k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= 151 | k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= 152 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 153 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 154 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= 155 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 156 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= 157 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= 158 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= 159 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= 160 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 161 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 162 | -------------------------------------------------------------------------------- /pkg/costmodel/aux_test.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "testing" 5 | 6 | "k8s.io/apimachinery/pkg/api/resource" 7 | 8 | "github.com/grafana/kost/pkg/costmodel/utils" 9 | ) 10 | 11 | // This is for making it easier to assign resources for testing. 12 | type requirementsHelperFuncs struct { 13 | cpu, mem, pv func(string) int64 14 | 15 | cpuCost, memCost, pvCost func(string, float64) float64 16 | } 17 | 18 | func requirementsHelpers(t *testing.T) requirementsHelperFuncs { 19 | pq := func(s string) *resource.Quantity { 20 | t.Helper() 21 | q, err := resource.ParseQuantity(s) 22 | if err != nil { 23 | t.Fatalf("parsing CPU %q: %v", s, err) 24 | } 25 | return &q 26 | } 27 | 28 | h := requirementsHelperFuncs{ 29 | cpu: func(s string) int64 { 30 | t.Helper() 31 | return pq(s).MilliValue() 32 | }, 33 | 34 | mem: func(s string) int64 { 35 | t.Helper() 36 | return pq(s).Value() 37 | }, 38 | 39 | pv: func(s string) int64 { 40 | t.Helper() 41 | return pq(s).Value() 42 | }, 43 | } 44 | 45 | const period float64 = 24 * 30 46 | 47 | h.cpuCost = func(s string, c float64) float64 { 48 | return float64(h.cpu(s)) / 1000 * c * period 49 | } 50 | h.memCost = func(s string, c float64) float64 { 51 | return utils.BytesToGiB(h.mem(s)) * c * period 52 | } 53 | h.pvCost = h.cpuCost 54 | 55 | return h 56 | } 57 | -------------------------------------------------------------------------------- /pkg/costmodel/client.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "log/slog" 9 | "strings" 10 | "time" 11 | 12 | "github.com/prometheus/client_golang/api" 13 | v1 "github.com/prometheus/client_golang/api/prometheus/v1" 14 | configutil "github.com/prometheus/common/config" 15 | "github.com/prometheus/common/model" 16 | ) 17 | 18 | const ( 19 | queryCostPerCpu = ` 20 | avg by (price_tier) ( 21 | cloudcost_aws_ec2_instance_cpu_usd_per_core_hour{cluster_name="%s"} 22 | or 23 | cloudcost_azure_aks_instance_cpu_usd_per_core_hour{cluster_name="%s"} 24 | or 25 | cloudcost_gcp_gke_instance_cpu_usd_per_core_hour{cluster_name="%s"} 26 | ) 27 | ` 28 | queryMemoryCost = ` 29 | avg by (price_tier) ( 30 | cloudcost_aws_ec2_instance_memory_usd_per_gib_hour{cluster_name="%s"} 31 | or 32 | cloudcost_azure_aks_instance_memory_usd_per_gib_hour{cluster_name="%s"} 33 | or 34 | cloudcost_gcp_gke_instance_memory_usd_per_gib_hour{cluster_name="%s"} 35 | ) 36 | ` 37 | 38 | // TODO(@Pokom): update this query with azure's PVC's cost once https://github.com/grafana/cloudcost-exporter/issues/236 is merged in 39 | queryPersistentVolumeCost = ` 40 | avg( 41 | cloudcost_aws_ec2_persistent_volume_usd_per_hour{persistentvolume!="", state="in-use"} 42 | / on (persistentvolume) group_left() ( 43 | kube_persistentvolume_capacity_bytes{cluster="%s"} / 1e9 44 | ) 45 | ) 46 | or 47 | avg( 48 | cloudcost_gcp_gke_persistent_volume_usd_per_hour{persistentvolume!="", use_status="in-use", cluster_name="%s"} 49 | / on (persistentvolume) group_left() ( 50 | kube_persistentvolume_capacity_bytes{cluster="%s"} / 1e9 51 | ) 52 | ) 53 | or 54 | avg( 55 | pv_hourly_cost{cluster="%s"} 56 | ) 57 | ` 58 | 59 | queryAverageNodeCount = ` 60 | avg_over_time( 61 | sum(nodepool:node:sum{cluster="%s"})[30d:1d] 62 | ) 63 | ` 64 | ) 65 | 66 | // ErrNoResults is the error returned when querying for costs returns 67 | // no results. 68 | var ( 69 | ErrNoResults = errors.New("no cost results") 70 | ErrBadQuery = errors.New("bad query") 71 | ErrNilConfig = errors.New("client config is nil") 72 | ErrEmptyAddress = errors.New("client address can't be empty") 73 | ErrProdConfigMissing = errors.New("prod config is missing") 74 | ) 75 | 76 | // Client is a client for the cost model. 77 | type Client struct { 78 | client api.Client 79 | } 80 | 81 | // Clients bundles the dev and prod client in one struct. 82 | type Clients struct { 83 | Prod *Client 84 | Dev *Client 85 | } 86 | 87 | // ClientConfig is the configuration for the cost model client. 88 | type ClientConfig struct { 89 | Address string 90 | HTTPConfigFile string 91 | Username string 92 | Password string 93 | } 94 | 95 | // NewClient creates a new cost model client with the given configuration. 96 | func NewClient(config *ClientConfig) (*Client, error) { 97 | cfg := &configutil.HTTPClientConfig{} 98 | if config == nil { 99 | return nil, ErrNilConfig 100 | } 101 | if config.Address == "" { 102 | return nil, ErrEmptyAddress 103 | } 104 | if config.HTTPConfigFile != "" { 105 | fmt.Printf("loading http config file: %s\n", config.HTTPConfigFile) 106 | var err error 107 | cfg, _, err = configutil.LoadHTTPConfigFile(config.HTTPConfigFile) 108 | if err != nil { 109 | return nil, fmt.Errorf("error loading http config file: %v", err) 110 | } 111 | } else if config.Username != "" && config.Password != "" { 112 | fmt.Println("using basic auth") 113 | cfg = &configutil.HTTPClientConfig{ 114 | BasicAuth: &configutil.BasicAuth{ 115 | Username: config.Username, 116 | Password: configutil.Secret(config.Password), 117 | }, 118 | } 119 | } else { 120 | fmt.Println("HTTP config file and basic auth not provided, using no authentication") 121 | } 122 | 123 | roundTripper, err := configutil.NewRoundTripperFromConfig(*cfg, "grafana-kost-estimator", configutil.WithHTTP2Disabled(), configutil.WithUserAgent("grafana-kost-estimator")) 124 | if err != nil { 125 | return nil, fmt.Errorf("error creating round tripper: %v", err) 126 | } 127 | client, err := api.NewClient(api.Config{Address: config.Address, RoundTripper: roundTripper}) 128 | if err != nil { 129 | return nil, err 130 | } 131 | return &Client{ 132 | client: client, 133 | }, nil 134 | } 135 | 136 | // NewClients creates a new cost model clients with the given configuration. 137 | func NewClients(prodConfig, devConfig *ClientConfig) (*Clients, error) { 138 | var clients Clients 139 | prometheusProdClient, err := NewClient(prodConfig) 140 | if err != nil { 141 | return nil, ErrProdConfigMissing 142 | } 143 | clients.Prod = prometheusProdClient 144 | // It isn't necessary to initiate the dev client therefore we ignore potential errors from this 145 | prometheusDevClient, _ := NewClient(devConfig) 146 | clients.Dev = prometheusDevClient 147 | return &clients, nil 148 | } 149 | 150 | // GetCostPerCPU returns the average cost per CPU for a given cluster. 151 | func (c *Client) GetCostPerCPU(ctx context.Context, cluster string) (Cost, error) { 152 | query := fmt.Sprintf(queryCostPerCpu, cluster, cluster, cluster) 153 | results, err := c.query(ctx, query) 154 | if err != nil { 155 | return Cost{}, err 156 | } 157 | return c.parseResults(results) 158 | } 159 | 160 | // GetMemoryCost returns the cost per memory for a given cluster 161 | func (c *Client) GetMemoryCost(ctx context.Context, cluster string) (Cost, error) { 162 | query := fmt.Sprintf(queryMemoryCost, cluster, cluster, cluster) 163 | results, err := c.query(ctx, query) 164 | if err != nil { 165 | return Cost{}, err 166 | } 167 | return c.parseResults(results) 168 | } 169 | 170 | // GetNodeCount returns the average number of nodes over 30 days for a given cluster 171 | func (c *Client) GetNodeCount(ctx context.Context, cluster string) (int, error) { 172 | query := fmt.Sprintf(queryAverageNodeCount, cluster) 173 | results, err := c.query(ctx, query) 174 | if err != nil { 175 | return 0, ErrBadQuery 176 | } 177 | 178 | result := results.(model.Vector) 179 | if len(result) == 0 { 180 | return 0, ErrNoResults 181 | } 182 | 183 | return int(result[0].Value), nil 184 | } 185 | 186 | // GetCostForPersistentVolume returns the average cost per persistent volume for a given cluster 187 | func (c *Client) GetCostForPersistentVolume(ctx context.Context, cluster string) (Cost, error) { 188 | query := fmt.Sprintf(queryPersistentVolumeCost, cluster, cluster, cluster, cluster) 189 | results, err := c.query(ctx, query) 190 | if err != nil { 191 | return Cost{}, err 192 | } 193 | return c.parseResults(results) 194 | } 195 | 196 | func (c *Client) parseResults(results model.Value) (Cost, error) { 197 | result := results.(model.Vector) 198 | 199 | if len(result) == 0 { 200 | return Cost{}, ErrNoResults 201 | } 202 | 203 | var cost Cost 204 | for _, sample := range result { 205 | value := float64(sample.Value) 206 | 207 | switch sample.Metric["spot"] { 208 | case "true": 209 | cost.Spot = value 210 | case "false": 211 | cost.NonSpot = value 212 | default: 213 | // This is when there is no spot/non-spot label 214 | cost.Dollars = value 215 | } 216 | // Handles the case for cloudcost exporter metrics where `price_tier` is the label for spot/non-spot 217 | // TODO: Delete after removing support for OpenCost 218 | switch sample.Metric["price_tier"] { 219 | case "ondemand": 220 | cost.NonSpot = value 221 | case "spot": 222 | cost.Spot = value 223 | default: 224 | // This is when there is no spot/non-spot label 225 | cost.Dollars = value 226 | } 227 | } 228 | 229 | return cost, nil 230 | } 231 | 232 | // query queries prometheus with the given query 233 | func (c *Client) query(ctx context.Context, query string) (model.Value, error) { 234 | api := v1.NewAPI(c.client) 235 | results, warnings, err := api.Query(ctx, query, time.Now()) 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | if len(warnings) > 0 { 241 | // TODO this isn't probably something we want to. Let's 242 | // revisit the feasibility of receiving warnings later. 243 | log.Printf("Warnings: %v", warnings) 244 | } 245 | return results, nil 246 | } 247 | 248 | // GetClusterCosts returns the cost for a cluster and differentiate for dev and prod clusters 249 | func (c *Clients) GetClusterCosts(ctx context.Context, cluster string) (*CostModel, error) { 250 | start := time.Now() 251 | defer func() { 252 | slog.Info("GetClusterCosts", "cluster", cluster, "duration", time.Since(start)) 253 | }() 254 | var cost *CostModel 255 | var err error 256 | // if dev client is present 257 | client := c.Prod 258 | if c.Dev != nil && strings.HasPrefix(cluster, "dev-") { 259 | client = c.Dev 260 | } 261 | cost, err = GetCostModelForCluster(ctx, client, cluster) 262 | if err != nil { 263 | // TODO here we should probably return an error like below 264 | return nil, fmt.Errorf("fetching cost model for cluster %s: %w", cluster, err) 265 | } 266 | return cost, nil 267 | } 268 | -------------------------------------------------------------------------------- /pkg/costmodel/client_test.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "testing" 12 | 13 | "github.com/prometheus/common/model" 14 | ) 15 | 16 | func TestNewClient(t *testing.T) { 17 | type args struct { 18 | config *ClientConfig 19 | } 20 | tests := []struct { 21 | name string 22 | args args 23 | want bool 24 | error error 25 | }{ 26 | { 27 | name: "happy path", 28 | args: args{ 29 | config: &ClientConfig{ 30 | Address: "http://localhost:9090", 31 | }, 32 | }, 33 | want: true, 34 | error: nil, 35 | }, 36 | { 37 | name: "no address", 38 | args: args{ 39 | config: &ClientConfig{}, 40 | }, 41 | want: false, 42 | error: ErrEmptyAddress, 43 | }, 44 | { 45 | name: "nil config", 46 | args: args{ 47 | config: nil, 48 | }, 49 | want: false, 50 | error: ErrNilConfig, 51 | }, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | got, err := NewClient(tt.args.config) 56 | if err != nil && !errors.Is(err, tt.error) { 57 | t.Errorf("Unexpected error type error = %v, wantErr %v", err, tt.error) 58 | return 59 | } 60 | 61 | if got == nil && tt.want { 62 | t.Errorf("NewClient() got = %v, want %v", got, tt.want) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func TestNewClients(t *testing.T) { 69 | type args struct { 70 | devConfig *ClientConfig 71 | prodConfig *ClientConfig 72 | } 73 | tests := []struct { 74 | name string 75 | args args 76 | wantDev bool 77 | wantProd bool 78 | error error 79 | }{ 80 | { 81 | name: "happy path", 82 | args: args{ 83 | devConfig: &ClientConfig{ 84 | Address: "http://localhost:9090", 85 | }, 86 | prodConfig: &ClientConfig{ 87 | Address: "http://localhost:9090", 88 | }, 89 | }, 90 | wantDev: true, 91 | wantProd: true, 92 | error: nil, 93 | }, 94 | { 95 | name: "dev is missing", 96 | args: args{ 97 | devConfig: nil, 98 | prodConfig: &ClientConfig{ 99 | Address: "http://localhost:9090", 100 | }, 101 | }, 102 | wantDev: false, 103 | wantProd: true, 104 | error: nil, 105 | }, 106 | { 107 | name: "prod is missing", 108 | args: args{ 109 | devConfig: &ClientConfig{ 110 | Address: "http://localhost:9090", 111 | }, 112 | prodConfig: nil, 113 | }, 114 | wantDev: false, 115 | wantProd: false, 116 | error: ErrProdConfigMissing, 117 | }, 118 | } 119 | for _, tt := range tests { 120 | t.Run(tt.name, func(t *testing.T) { 121 | got, err := NewClients(tt.args.prodConfig, tt.args.devConfig) 122 | if err != nil && !errors.Is(err, tt.error) { 123 | t.Errorf("Unexpected error type error = %v, wantErr %v", err, tt.error) 124 | return 125 | } 126 | if tt.wantDev && got.Dev == nil { 127 | t.Errorf("NewClient() got = %v, want %v", got.Dev, tt.wantDev) 128 | return 129 | } 130 | if tt.wantProd && got.Prod == nil { 131 | t.Errorf("NewClient() got = %v, want %v", got.Prod, tt.wantProd) 132 | } 133 | }) 134 | } 135 | } 136 | 137 | func TestNewClientAuthMethods(t *testing.T) { 138 | t.Run("basic auth with username and password", func(t *testing.T) { 139 | cfg := &ClientConfig{ 140 | Address: "http://localhost:9090", 141 | Username: "testing", 142 | Password: "12345", 143 | } 144 | 145 | client, err := NewClient(cfg) 146 | if err != nil { 147 | t.Errorf("NewClient() error = %v, wantErr %v", err, false) 148 | return 149 | } 150 | if client == nil { 151 | t.Errorf("NewClient() got = %v, want %v", client, true) 152 | } 153 | }) 154 | 155 | t.Run("basic auth with http config file", func(t *testing.T) { 156 | tmpCfg, err := os.CreateTemp("", "http_config.yaml") 157 | if err != nil { 158 | t.Errorf("error creating temp file: %v", err) 159 | return 160 | } 161 | defer os.Remove(tmpCfg.Name()) 162 | content := fmt.Sprintf("basic_auth:\n username: %s\n password: %s", "testing", "12345") 163 | _, err = tmpCfg.WriteString(content) 164 | if err != nil { 165 | t.Errorf("error writing to temp file: %v", err) 166 | return 167 | } 168 | cfg := &ClientConfig{ 169 | Address: "http://localhost:9090", 170 | HTTPConfigFile: tmpCfg.Name(), 171 | } 172 | client, err := NewClient(cfg) 173 | if err != nil { 174 | t.Errorf("NewClient() error = %v, wantErr %v", err, false) 175 | return 176 | } 177 | if client == nil { 178 | t.Errorf("NewClient() got = %v, want %v", client, true) 179 | } 180 | }) 181 | } 182 | 183 | func TestParseResults(t *testing.T) { 184 | tests := map[string]struct { 185 | in model.Vector 186 | exp Cost 187 | err error 188 | }{ 189 | "empty": { 190 | model.Vector{}, 191 | Cost{}, 192 | ErrNoResults, 193 | }, 194 | 195 | "memory": { 196 | model.Vector{ 197 | &model.Sample{Value: 3.14}, 198 | }, 199 | Cost{Dollars: 3.14}, 200 | nil, 201 | }, 202 | 203 | "cpu": { 204 | model.Vector{ 205 | &model.Sample{Metric: model.Metric{"spot": "false"}, Value: 2.71}, 206 | &model.Sample{Metric: model.Metric{"spot": "true"}, Value: 1.41}, 207 | }, 208 | Cost{Spot: 1.41, NonSpot: 2.71, Dollars: 1.41}, 209 | nil, 210 | }, 211 | } 212 | 213 | c := &Client{} 214 | 215 | for n, tt := range tests { 216 | t.Run(n, func(t *testing.T) { 217 | got, err := c.parseResults(tt.in) 218 | if !errors.Is(tt.err, err) { 219 | t.Fatalf("expecting error %v, got %v", tt.err, err) 220 | } 221 | 222 | if got != tt.exp { 223 | t.Fatalf("expecting cost %v, got %v", tt.exp, got) 224 | } 225 | }) 226 | } 227 | } 228 | 229 | func TestClient_GetNodeCount(t *testing.T) { 230 | type Result struct { 231 | Metric model.Metric `json:"metric"` 232 | Value model.SamplePair `json:"value"` 233 | } 234 | type mockQueryRangeResponse struct { 235 | Status string `json:"status"` 236 | Data struct { 237 | Type string `json:"resultType"` 238 | Result []Result `json:"result"` 239 | } `json:"data"` 240 | } 241 | 242 | type args struct { 243 | ctx context.Context 244 | cluster string 245 | } 246 | tests := []struct { 247 | name string 248 | args args 249 | response *mockQueryRangeResponse 250 | want int 251 | wantErr error 252 | }{ 253 | { 254 | "Respones with a single value.", 255 | args{ 256 | context.Background(), 257 | "test", 258 | }, 259 | &mockQueryRangeResponse{ 260 | Status: "success", 261 | Data: struct { 262 | Type string `json:"resultType"` 263 | Result []Result `json:"result"` 264 | }{ 265 | Type: "vector", 266 | Result: []Result{ 267 | { 268 | Metric: model.Metric{}, 269 | Value: model.SamplePair{ 270 | Timestamp: model.TimeFromUnix(0), 271 | Value: 1, 272 | }, 273 | }, 274 | }, 275 | }, 276 | }, 277 | 1, 278 | nil, 279 | }, 280 | { 281 | "Prometheus responds with multiple values and GetNodeCount returns first value", 282 | args{ 283 | context.Background(), 284 | "test", 285 | }, 286 | &mockQueryRangeResponse{ 287 | Status: "success", 288 | Data: struct { 289 | Type string `json:"resultType"` 290 | Result []Result `json:"result"` 291 | }{ 292 | Type: "vector", 293 | Result: []Result{ 294 | { 295 | Metric: model.Metric{}, 296 | Value: model.SamplePair{ 297 | Timestamp: model.TimeFromUnix(0), 298 | Value: 10, 299 | }, 300 | }, 301 | { 302 | Metric: model.Metric{}, 303 | Value: model.SamplePair{ 304 | Timestamp: model.TimeFromUnix(0), 305 | Value: 100, 306 | }, 307 | }, 308 | }, 309 | }, 310 | }, 311 | 10, 312 | nil, 313 | }, 314 | { 315 | "Responds with an error if results is nil.", 316 | args{ 317 | context.Background(), 318 | "test", 319 | }, 320 | &mockQueryRangeResponse{ 321 | Status: "success", 322 | Data: struct { 323 | Type string `json:"resultType"` 324 | Result []Result `json:"result"` 325 | }{ 326 | Type: "vector", 327 | }, 328 | }, 329 | 0, 330 | ErrBadQuery, 331 | }, 332 | { 333 | "Responds with an error if there are no values.", 334 | args{ 335 | context.Background(), 336 | "test", 337 | }, 338 | &mockQueryRangeResponse{ 339 | Status: "success", 340 | Data: struct { 341 | Type string `json:"resultType"` 342 | Result []Result `json:"result"` 343 | }{ 344 | Type: "vector", 345 | Result: []Result{}, 346 | }, 347 | }, 348 | 0, 349 | ErrNoResults, 350 | }, 351 | } 352 | for _, tt := range tests { 353 | t.Run(tt.name, func(t *testing.T) { 354 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 355 | response := tt.response 356 | if err := json.NewEncoder(w).Encode(response); err != nil { 357 | t.Errorf("error encoding response: %v", err) 358 | return 359 | } 360 | })) 361 | 362 | defer svr.Close() 363 | c, err := NewClient(&ClientConfig{ 364 | Address: svr.URL, 365 | }) 366 | if err != nil { 367 | t.Errorf("error creating client: %v", err) 368 | return 369 | } 370 | got, err := c.GetNodeCount(tt.args.ctx, tt.args.cluster) 371 | if !errors.Is(err, tt.wantErr) { 372 | t.Errorf("Client.GetNodeCount() error = %v, wantErr %v", err, tt.wantErr) 373 | return 374 | } 375 | if got != tt.want { 376 | t.Errorf("Client.GetNodeCount() = %v, want %v", got, tt.want) 377 | } 378 | }) 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /pkg/costmodel/comment.tmpl.md: -------------------------------------------------------------------------------- 1 | {{ commentPrefix }} 2 | {{- if eq 0.0 .Delta }} 3 | {{- template "unchanged" . -}} 4 | {{ else }} 5 | {{- template "changes" . -}} 6 | {{ end }} 7 | 8 | {{- if .Errors }} 9 |
10 | :exclamation: Errors: the following errors happened while calculating the cost: 11 | {{ range .Errors -}} 12 | - {{ . }}
13 | {{- end }} 14 |
15 | {{ end }} 16 | 17 | {{- if .Warnings }} 18 |
19 | :warning: Warnings: the following resources had warnings, while calculating cost: 20 | {{ range .Warnings -}} 21 | - {{ . }}
22 | {{- end }} 23 |
24 | {{ end }} 25 | 26 | 27 | See the [FAQ](https://github.com/grafana/deployment_tools/blob/master/docker/k8s-cost-estimator/FAQ.md) for any questions! 28 | Still need help? Then join us in the [`#platform-capacity-chat`](https://raintank-corp.slack.com/archives/C03PDLFK29K) channel. 29 | 30 | 31 | {{- /* TEMPLATES */ -}} 32 | {{ define "unchanged" }} 33 | ## :dollar: Cost Estimation Report 34 | No changes in monthly cost for the affected resources. Here are the current estimated costs. 35 | 36 | {{ if gt (len .Summary) 1 }} 37 |
38 | Details by cluster and resource type 39 | 40 | {{ template "unchanged_details" .Reports }} 41 |
42 | {{ else }} 43 | {{ template "unchanged_details" .Reports }} 44 | {{ end }} 45 | {{ end }} 46 | 47 | {{ define "changes" }} 48 | {{- $increased := gt .Delta 0.0 }} 49 | ## :dollar: Cost Estimation Report {{ if $increased }}:chart_with_upwards_trend:{{ else }}:chart_with_downwards_trend:{{ end }} 50 | Monthly cost for the affected resources will {{ if $increased }}increase by {{ dollars .Delta }} ({{ ratio .Delta .OldTotal | percentage }}){{ else }}decrease by {{ dollars (multiply .Delta -1) }} ({{ multiply (ratio .Delta .OldTotal) -1 | percentage }}){{ end }} 51 | 52 | {{ if gt (len .Summary) 1 }} 53 | 54 | | Cluster | Previous | New | Delta | 55 | | - | - | - | - | 56 | {{ range $cluster, $report := .Summary -}} 57 | {{- if eq $report.Delta 0.0 -}}{{ continue }}{{- end -}} 58 | | `{{ $cluster }}` | {{ dollars $report.Old }} | {{ dollars $report.New }} | {{ if eq $report.Delta 0.0 }}N/A{{ else }}{{ dollars $report.Delta }} ({{ ratio .Delta $report.Old | percentage }}){{ end }} | 59 | {{ end }} 60 | 61 |
62 | Details by cluster and resource type 63 | 64 | {{ template "change_details" .Reports }} 65 |
66 | {{ else }} 67 | {{ template "change_details" .Reports }} 68 | {{ end }} 69 | {{ end }} 70 | 71 | {{ define "unchanged_details" }} 72 | {{ range $cluster, $resources := . }} 73 |
74 | Details for {{ $cluster}} 75 | 76 | | Namespace | Resource | CPU | Memory | Storage | Total | 77 | | - | - | - | - | - | - | 78 | {{ range $resources -}}| `{{ .New.Namespace }}` | `{{ .New.Kind }}`
`{{ .New.Name }}` | {{ dollars .New.CPU }} | {{ dollars .New.Memory }} | {{ dollars .New.Storage }} | {{ dollars .New.Total }} | 79 | {{ end }} 80 |
81 | {{ end }} 82 | {{ end }} 83 | 84 | {{ define "change_details" }} 85 | {{ range $cluster, $resources := . -}} 86 |
87 | Details for {{ $cluster}} 88 | 89 | | Namespace | Resource | CPU | Memory | Storage | Total | Delta | 90 | | - | - | - | - | - | - | - | 91 | {{ range $resources -}} 92 | | `{{ .New.Namespace}}` | `{{ .New.Kind }}`
`{{.New.Name}}` | {{ dollars .Old.CPU }}→
{{ dollars .New.CPU }} | {{ dollars .Old.Memory }}→
{{ dollars .New.Memory }} | {{ dollars .Old.Storage }}→
{{ dollars .New.Storage }} | {{ dollars .Old.Total }}→
{{ dollars .New.Total }} | {{ if eq 0.0 .Delta }}N/A{{ else }}{{ dollars .Delta }}
({{ ratio .Delta .Old.Total | percentage }}) {{ end }}| 93 | {{ end }} 94 |
95 | {{ end }} 96 | 97 |

Legend: previous cost on top, expected cost below.

98 | {{ end }} 99 | -------------------------------------------------------------------------------- /pkg/costmodel/costmodel.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/grafana/kost/pkg/costmodel/utils" 8 | ) 9 | 10 | type Period float64 11 | 12 | const ( 13 | Hourly Period = 1 14 | Daily = 24 15 | Weekly = 24 * 7 16 | Monthly = 24 * 30 17 | Yearly = 24 * 365 18 | ) 19 | 20 | func (p Period) String() string { 21 | switch p { 22 | case Hourly: 23 | return "hourly" 24 | case Daily: 25 | return "daily" 26 | case Weekly: 27 | return "weekly" 28 | case Monthly: 29 | return "monthly" 30 | case Yearly: 31 | return "yearly" 32 | default: 33 | panic("unrecognized value for Period") 34 | } 35 | } 36 | 37 | type PeriodKeys struct { 38 | To string 39 | Delta string 40 | From string 41 | } 42 | 43 | func (p Period) Keys() PeriodKeys { 44 | to := fmt.Sprintf("%s-to", p) 45 | delta := fmt.Sprintf("%s-delta", p) 46 | from := fmt.Sprintf("%s-from", p) 47 | return PeriodKeys{To: to, Delta: delta, From: from} 48 | } 49 | 50 | // Cost represents the _hourly_ cost of a resource in USD. 51 | // If the cluster does not have pricing data for spot nodes, then Dollars will be set. 52 | type Cost struct { 53 | Dollars float64 54 | Spot float64 55 | NonSpot float64 56 | } 57 | 58 | func (c Cost) SpotCPUForPeriod(p Period, r int64) float64 { 59 | return float64(r) / 1000 * c.Spot * float64(p) 60 | } 61 | 62 | func (c Cost) NonSpotCPUForPeriod(p Period, r int64) float64 { 63 | return float64(r) / 1000 * c.NonSpot * float64(p) 64 | } 65 | 66 | // DollarsForPeriod returns the cost of a resource in USD for a given period. 67 | // Primarily used by PersistentVolumeClaims which do not have spot/non spot pricing. 68 | func (c Cost) DollarsForPeriod(p Period, r int64) float64 { 69 | return utils.BytesToGiB(r) * c.Dollars * float64(p) 70 | } 71 | 72 | func (c Cost) SpotMemoryForPeriod(p Period, r int64) float64 { 73 | return utils.BytesToGiB(r) * c.Spot * float64(p) 74 | } 75 | 76 | func (c Cost) NonSpotMemoryForPeriod(p Period, r int64) float64 { 77 | return utils.BytesToGiB(r) * c.NonSpot * float64(p) 78 | } 79 | 80 | func (c Cost) SpotYearly(cpuReq int64) float64 { return c.SpotCPUForPeriod(Yearly, cpuReq) } 81 | 82 | func (c Cost) NonSpotYearly(cpuReq int64) float64 { return c.NonSpotCPUForPeriod(Yearly, cpuReq) } 83 | 84 | func (c Cost) DollarsYearly(memReq int64) float64 { return c.DollarsForPeriod(Yearly, memReq) } 85 | 86 | // CostModel represents the cost of each resource for a specific cluster 87 | type CostModel struct { 88 | Cluster *Cluster 89 | CPU Cost 90 | RAM Cost 91 | PersistentVolume Cost 92 | } 93 | 94 | type Cluster struct { 95 | Name string 96 | NodeCount int 97 | } 98 | 99 | func GetCostModelForCluster(ctx context.Context, client *Client, cluster string) (*CostModel, error) { 100 | cpu, err := client.GetCostPerCPU(ctx, cluster) 101 | if err != nil { 102 | return nil, fmt.Errorf("could not find CPU cost: %s", err) 103 | } 104 | 105 | memory, err := client.GetMemoryCost(ctx, cluster) 106 | if err != nil { 107 | return nil, fmt.Errorf("could not find memory cost: %s", err) 108 | } 109 | 110 | pvc, err := client.GetCostForPersistentVolume(ctx, cluster) 111 | if err != nil { 112 | return nil, fmt.Errorf("could not find persistent volume cost: %s", err) 113 | } 114 | 115 | nodeCount, err := client.GetNodeCount(ctx, cluster) 116 | if err != nil { 117 | return nil, fmt.Errorf("could not find node count: %s", err) 118 | } 119 | 120 | return &CostModel{ 121 | Cluster: &Cluster{Name: cluster, NodeCount: nodeCount}, 122 | CPU: cpu, 123 | RAM: memory, 124 | PersistentVolume: pvc, 125 | }, nil 126 | } 127 | 128 | // TotalCostForPeriod calculates the costs of each resource on the CostModel and returns the sum of the costs 129 | func (c *CostModel) TotalCostForPeriod(p Period, r Requirements) float64 { 130 | cpuCost := c.CPU.NonSpotCPUForPeriod(p, r.CPU) 131 | ramCost := c.RAM.NonSpotMemoryForPeriod(p, r.Memory) 132 | pvCost := c.PersistentVolume.DollarsForPeriod(p, r.PersistentVolume) 133 | return cpuCost + ramCost + pvCost 134 | } 135 | -------------------------------------------------------------------------------- /pkg/costmodel/costmodel_test.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | 7 | "k8s.io/apimachinery/pkg/api/resource" 8 | ) 9 | 10 | // feq returns true if two floats are equal within certain tolerance 11 | func feq(a, b float64) bool { 12 | const tolerance = 0.001 13 | return math.Abs(a-b) < tolerance 14 | } 15 | 16 | func TestCostModelForPeriod(t *testing.T) { 17 | t.Run("Spot", func(t *testing.T) { 18 | tests := []struct { 19 | req string 20 | hourly float64 21 | period Period 22 | exp float64 23 | }{ 24 | // Properly progress the values 25 | {"1", 0.0069, Hourly, 0.0069}, 26 | {"1", 0.0069, Daily, 0.0069 * 24}, 27 | {"1", 0.0069, Weekly, 0.0069 * 24 * 7}, 28 | {"1", 0.0069, Monthly, 0.0069 * 24 * 30}, 29 | {"1", 0.0069, Yearly, 0.0069 * 24 * 365}, 30 | 31 | // General tests 32 | {"10", 0.0125, Daily, 3}, 33 | {"500m", 2.7108, Weekly, 227.707}, 34 | } 35 | 36 | for _, tt := range tests { 37 | c := Cost{Spot: tt.hourly} 38 | q := resource.MustParse(tt.req) 39 | r := q.MilliValue() 40 | g := c.SpotCPUForPeriod(tt.period, r) 41 | if !feq(tt.exp, g) { 42 | t.Errorf("%v CPU expecting %v cost %f, got %f", tt.req, tt.period, tt.exp, g) 43 | } 44 | } 45 | }) 46 | 47 | t.Run("Non-Spot", func(t *testing.T) { 48 | tests := []struct { 49 | req string 50 | hourly float64 51 | period Period 52 | exp float64 53 | }{ 54 | // Properly progress the values 55 | {"1", 0.0832, Hourly, 0.0832}, 56 | {"1", 0.0832, Daily, 0.0832 * 24}, 57 | {"1", 0.0832, Weekly, 0.0832 * 24 * 7}, 58 | {"1", 0.0832, Monthly, 0.0832 * 24 * 30}, 59 | {"1", 0.0832, Yearly, 0.0832 * 24 * 365}, 60 | 61 | // General tests 62 | {"10", 0.3328, Daily, 79.8723}, 63 | {"500m", 0.408, Hourly, 0.204}, 64 | } 65 | 66 | for _, tt := range tests { 67 | c := Cost{NonSpot: tt.hourly} 68 | q := resource.MustParse(tt.req) 69 | r := q.MilliValue() 70 | g := c.NonSpotCPUForPeriod(tt.period, r) 71 | if !feq(tt.exp, g) { 72 | t.Errorf("%v CPU expecting %v cost %f, got %f", tt.req, tt.period, tt.exp, g) 73 | } 74 | } 75 | }) 76 | 77 | t.Run("Dollars", func(t *testing.T) { 78 | tests := []struct { 79 | req string 80 | hourly float64 81 | period Period 82 | exp float64 83 | }{ 84 | // Properly progress the values 85 | {"1Gi", 1.336, Hourly, 1.336}, 86 | {"1Gi", 1.336, Daily, 1.336 * 24}, 87 | {"1Gi", 1.336, Weekly, 1.336 * 24 * 7}, 88 | {"1Gi", 1.336, Monthly, 1.336 * 24 * 30}, 89 | {"1Gi", 1.336, Yearly, 1.336 * 24 * 365}, 90 | 91 | // General tests 92 | {"10Gi", 0.0835, Daily, 20.04}, 93 | {"0.5Gi", 0.167, Weekly, 14.028}, 94 | } 95 | 96 | for _, tt := range tests { 97 | c := Cost{Dollars: tt.hourly} 98 | q := resource.MustParse(tt.req) 99 | r := q.Value() 100 | g := c.DollarsForPeriod(tt.period, r) 101 | if !feq(tt.exp, g) { 102 | t.Errorf("%v RAM expecting %v cost %f, got %f", tt.req, tt.period, tt.exp, g) 103 | } 104 | } 105 | }) 106 | } 107 | 108 | func TestCostModelYearly(t *testing.T) { 109 | c := Cost{ 110 | Dollars: 22, 111 | Spot: 121, 112 | NonSpot: 4, 113 | } 114 | 115 | const hoursInYear = 24 * 365 116 | 117 | t.Run("CPU", func(t *testing.T) { 118 | tests := []struct { 119 | in string 120 | spot float64 121 | nonSpot float64 122 | }{ 123 | {"1", 121 * hoursInYear, 4 * hoursInYear}, 124 | {"10", 1210 * hoursInYear, 40 * hoursInYear}, 125 | {"5", 605 * hoursInYear, 20 * hoursInYear}, 126 | {"500m", 60.5 * hoursInYear, 2 * hoursInYear}, 127 | } 128 | 129 | for _, tt := range tests { 130 | q, err := resource.ParseQuantity(tt.in) 131 | if err != nil { 132 | t.Fatalf("cannot parse quantity %q: %v", tt.in, err) 133 | } 134 | r := q.MilliValue() 135 | if s := c.SpotYearly(r); s != tt.spot { 136 | t.Errorf("%v CPU expecting yearly spot %f, got %f", tt.in, tt.spot, s) 137 | } 138 | if ns := c.NonSpotYearly(r); ns != tt.nonSpot { 139 | t.Errorf("%v CPU expecting yearly non-spot %f, got %f", tt.in, tt.nonSpot, ns) 140 | } 141 | } 142 | }) 143 | 144 | t.Run("Memory", func(t *testing.T) { 145 | tests := []struct { 146 | in string 147 | spot float64 148 | nonSpot float64 149 | }{ 150 | {"1Gi", c.Spot * Yearly, c.NonSpot * Yearly}, 151 | {"2Gi", c.Spot * 2 * Yearly, c.NonSpot * 2 * Yearly}, 152 | {"10Gi", c.Spot * 10 * Yearly, c.NonSpot * 10 * Yearly}, 153 | {"0.5Gi", c.Spot * 0.5 * Yearly, c.NonSpot * 0.5 * Yearly}, 154 | {"0.25Gi", c.Spot * 0.25 * Yearly, c.NonSpot * 0.25 * Yearly}, 155 | } 156 | 157 | for _, tt := range tests { 158 | q, err := resource.ParseQuantity(tt.in) 159 | if err != nil { 160 | t.Fatalf("cannot parse quantity %q: %v", tt.in, err) 161 | } 162 | r := q.Value() 163 | 164 | if s := c.SpotMemoryForPeriod(Yearly, r); s != tt.spot { 165 | t.Errorf("%v memory expecting yearly dollars %f, got %f", tt.in, tt.spot, s) 166 | } 167 | 168 | if g := c.NonSpotMemoryForPeriod(Yearly, r); tt.nonSpot != g { 169 | t.Errorf("%v memory expecting yearly dollars %f, got %f", tt.in, tt.nonSpot, g) 170 | } 171 | } 172 | }) 173 | } 174 | -------------------------------------------------------------------------------- /pkg/costmodel/markdown_reporter.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "sort" 7 | "text/template" 8 | ) 9 | 10 | // resourcesCost contains the detailed cost for cluster resources. 11 | type resourcesCost struct { 12 | CPU float64 13 | Memory float64 14 | Storage float64 15 | Kind string 16 | Namespace string 17 | Name string 18 | } 19 | 20 | func (c resourcesCost) Total() float64 { 21 | return c.CPU + c.Memory + c.Storage 22 | } 23 | 24 | func resourcesCosts(m *CostModel, req Requirements) resourcesCost { 25 | return resourcesCost{ 26 | CPU: m.CPU.NonSpotCPUForPeriod(Monthly, req.CPU), 27 | Memory: m.RAM.NonSpotMemoryForPeriod(Monthly, req.Memory), 28 | Storage: m.PersistentVolume.DollarsForPeriod(Monthly, req.PersistentVolume), 29 | Kind: req.Kind, 30 | Namespace: req.Namespace, 31 | Name: req.Name, 32 | } 33 | } 34 | 35 | // costReport holds information about a cluster & resource cost from 36 | // its previous state and the desired new requirements. 37 | type costReport struct { 38 | Cluster string 39 | 40 | Old, New resourcesCost 41 | } 42 | 43 | func (r costReport) Delta() float64 { 44 | return r.New.Total() - r.Old.Total() 45 | } 46 | 47 | func (s summaryReport) Delta() float64 { 48 | return s.New - s.Old 49 | } 50 | 51 | // costReports is a collection of CostReport. 52 | type costReports []costReport 53 | type summaryReport struct { 54 | Old, New float64 55 | } 56 | 57 | func (rs costReports) Totals() (float64, float64) { 58 | var n, o float64 59 | 60 | for _, r := range rs { 61 | n += r.New.Total() 62 | o += r.Old.Total() 63 | } 64 | 65 | return n, o 66 | } 67 | 68 | func (rs costReports) Sort() { 69 | sort.Slice(rs, func(i, j int) bool { 70 | // Higher deltas go on top 71 | return rs[i].Delta() > rs[j].Delta() 72 | }) 73 | } 74 | 75 | // templateData holds the information passed to the markdown comment 76 | // template. 77 | type templateData struct { 78 | Reports map[string]costReports 79 | Summary map[string]summaryReport 80 | // Errors are events that aren't expected and can lead to unexpected results of kost. 81 | Errors []string 82 | // Warnings are expected events, or known limitations. 83 | Warnings []string 84 | } 85 | 86 | func (d templateData) Delta() float64 { 87 | var n, o float64 88 | for _, s := range d.Reports { 89 | nt, ot := s.Totals() 90 | n += nt 91 | o += ot 92 | 93 | } 94 | return n - o 95 | } 96 | 97 | func (d templateData) OldTotal() float64 { 98 | var output float64 99 | for _, s := range d.Reports { 100 | _, o := s.Totals() 101 | output += o 102 | } 103 | return output 104 | } 105 | 106 | // CommentPrefix is used to identify and hide old messages on GitHub. 107 | const CommentPrefix = "" 108 | 109 | //go:embed comment.tmpl.md 110 | var commentTemplate string 111 | 112 | // templateFuncs holds the custom functions used within the template. 113 | var templateFuncs = template.FuncMap{ 114 | "commentPrefix": func() string { return CommentPrefix }, 115 | "dollars": func(v float64) string { 116 | return displayCostInDollars(v) 117 | }, 118 | "percentage": func(r float64) string { 119 | return fmt.Sprintf("%.2f%%", r*100) 120 | }, 121 | "ratio": func(a, b float64) float64 { 122 | if b == 0 { 123 | if a > 0 { 124 | return 1 125 | } 126 | return 0 127 | } 128 | return a / b 129 | }, 130 | "multiply": func(a, b float64) float64 { 131 | return a * b 132 | }, 133 | } 134 | 135 | var tpl = template.Must(template.New("").Funcs(templateFuncs).Parse(commentTemplate)) 136 | 137 | // writeMarkdown 138 | func (r *Reporter) writeMarkdown() error { 139 | d := templateData{ 140 | Reports: make(map[string]costReports), 141 | Summary: make(map[string]summaryReport), 142 | } 143 | 144 | for _, r := range r.reports { 145 | if r.CostModel == nil { 146 | d.Errors = append(d.Errors, fmt.Sprintf("%v report is missing cost model", r.To.Name)) 147 | continue 148 | } 149 | 150 | // We have no good estimation for the time a (Cron)Job is running, therefor a cost estimation is highly impossible 151 | if r.To.Kind == "CronJob" || r.To.Kind == "Job" { 152 | d.Warnings = append(d.Warnings, fmt.Sprintf("%v is a Job or CronJob, cost estimation impossible.", r.To.Name)) 153 | continue 154 | } 155 | 156 | cr := costReport{ 157 | Cluster: r.CostModel.Cluster.Name, 158 | Old: resourcesCosts(r.CostModel, r.From), 159 | New: resourcesCosts(r.CostModel, r.To), 160 | } 161 | reports := d.Reports[r.CostModel.Cluster.Name] 162 | reports = append(reports, cr) 163 | d.Reports[r.CostModel.Cluster.Name] = reports 164 | 165 | sr := d.Summary[r.CostModel.Cluster.Name] 166 | sr.Old += cr.Old.Total() 167 | sr.New += cr.New.Total() 168 | d.Summary[r.CostModel.Cluster.Name] = sr 169 | } 170 | 171 | for _, reports := range d.Reports { 172 | reports.Sort() 173 | } 174 | 175 | return tpl.Execute(r.Writer, d) 176 | } 177 | -------------------------------------------------------------------------------- /pkg/costmodel/markdown_reporter_test.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "math" 5 | "math/rand" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/grafana/kost/pkg/costmodel/utils" 10 | ) 11 | 12 | func TestResourcesCost(t *testing.T) { 13 | rc := resourcesCost{ 14 | CPU: 1024, 15 | Memory: 512, 16 | Storage: 256, 17 | } 18 | 19 | var exp float64 = 1024 + 512 + 256 20 | 21 | if got := rc.Total(); exp != got { 22 | t.Fatalf("expecting %v total %v, got %v", rc, exp, got) 23 | } 24 | } 25 | 26 | func TestResourcesCosts(t *testing.T) { 27 | h := requirementsHelpers(t) 28 | 29 | cm := &CostModel{ 30 | CPU: Cost{NonSpot: 1}, 31 | RAM: Cost{NonSpot: 2}, 32 | 33 | PersistentVolume: Cost{Dollars: 3}, 34 | } 35 | 36 | req := Requirements{ 37 | CPU: h.cpu("100m"), 38 | Memory: h.mem("2Gi"), 39 | PersistentVolume: h.pv("4Gi"), 40 | } 41 | 42 | got := resourcesCosts(cm, req) 43 | 44 | exp := resourcesCost{ 45 | CPU: 0.1 * 1 * 24 * 30, 46 | Memory: utils.BytesToGiB(2147483648) * 2 * 24 * 30, 47 | Storage: utils.BytesToGiB(1024*1024*1024*4) * 3 * 24 * 30, 48 | } 49 | 50 | if e, g := exp.CPU, got.CPU; !eq(e, g) { 51 | t.Errorf("expecting CPU %.05f, got %.05f", e, g) 52 | } 53 | if e, g := exp.Memory, got.Memory; !eq(e, g) { 54 | t.Errorf("expecting Memory %.05f, got %.05f", e, g) 55 | } 56 | if e, g := exp.Storage, got.Storage; !eq(e, g) { 57 | t.Errorf("expecting Storage %.05f, got %.05f", e, g) 58 | } 59 | } 60 | 61 | func eq(a, b float64) bool { 62 | const d = 0.001 63 | return math.Abs(a-b) < d 64 | } 65 | 66 | func TestCostReport(t *testing.T) { 67 | tests := []struct { 68 | cr costReport 69 | exp float64 70 | }{ 71 | { 72 | costReport{ 73 | Old: resourcesCost{ 74 | CPU: 1, 75 | Memory: 2, 76 | Storage: 3, 77 | }, 78 | New: resourcesCost{ 79 | CPU: 1, 80 | Memory: 2, 81 | Storage: 3, 82 | }, 83 | }, 84 | 0, 85 | }, 86 | { 87 | costReport{ 88 | Old: resourcesCost{ 89 | CPU: 1, 90 | Memory: 2, 91 | Storage: 3, 92 | }, 93 | New: resourcesCost{ 94 | CPU: 10, 95 | Memory: 20, 96 | Storage: 30, 97 | }, 98 | }, 99 | 54, 100 | }, 101 | { 102 | costReport{ 103 | Old: resourcesCost{ 104 | CPU: 2, 105 | Memory: 4, 106 | Storage: 6, 107 | }, 108 | New: resourcesCost{ 109 | CPU: 1, 110 | Memory: 2, 111 | Storage: 3, 112 | }, 113 | }, 114 | -6, 115 | }, 116 | } 117 | 118 | for _, tt := range tests { 119 | if g := tt.cr.Delta(); !eq(tt.exp, g) { 120 | t.Errorf("expecting cost report %v delta %.05f, got %0.5f", tt.cr, tt.exp, g) 121 | } 122 | } 123 | } 124 | 125 | func TestCostReports(t *testing.T) { 126 | crs := costReports{ 127 | { 128 | New: resourcesCost{CPU: 1, Memory: 2, Storage: 3}, 129 | Old: resourcesCost{CPU: 2, Memory: 4, Storage: 6}, 130 | }, 131 | { 132 | New: resourcesCost{CPU: 1, Memory: 2, Storage: 3}, 133 | Old: resourcesCost{CPU: 2, Memory: 4, Storage: 6}, 134 | }, 135 | } 136 | 137 | var en, eo float64 = 12, 24 138 | 139 | n, o := crs.Totals() 140 | 141 | if !eq(n, en) || !eq(o, eo) { 142 | t.Errorf("expecting cost reports new & old totals %.05f, %.05f; got %.05f, %.05f", en, eo, n, o) 143 | } 144 | } 145 | 146 | type templateTestInput struct { 147 | cm *CostModel 148 | oldReq Requirements 149 | newReq Requirements 150 | } 151 | 152 | func TestTemplate(t *testing.T) { 153 | // TODO this isn't really testing anything, just printing the 154 | // messages when verbose testing is enabled. 155 | 156 | h := requirementsHelpers(t) 157 | 158 | cm1 := &CostModel{ 159 | Cluster: &Cluster{ 160 | Name: "ops-us-east-0", 161 | }, 162 | 163 | CPU: Cost{NonSpot: 1}, 164 | RAM: Cost{NonSpot: 2}, 165 | 166 | PersistentVolume: Cost{NonSpot: 0.0003}, 167 | } 168 | cm2 := &CostModel{ 169 | Cluster: &Cluster{ 170 | Name: "prod-us-central-4", 171 | }, 172 | 173 | CPU: Cost{NonSpot: 1}, 174 | RAM: Cost{NonSpot: 2}, 175 | 176 | PersistentVolume: Cost{NonSpot: 0.003}, 177 | } 178 | 179 | req1 := Requirements{ 180 | CPU: h.cpu("500m"), 181 | Memory: h.mem("2Gi"), 182 | PersistentVolume: h.pv("4Gi"), 183 | } 184 | req2 := Requirements{ 185 | CPU: h.cpu("1"), 186 | Memory: h.mem("4Gi"), 187 | PersistentVolume: h.pv("8Gi"), 188 | } 189 | 190 | tests := map[string]struct { 191 | reports []templateTestInput 192 | }{ 193 | "no changes": { 194 | []templateTestInput{ 195 | {cm: cm1, oldReq: req1, newReq: req1}, 196 | {cm: cm2, oldReq: req2, newReq: req2}, 197 | }, 198 | }, 199 | "one cluster unchanged": { 200 | []templateTestInput{ 201 | {cm: cm1, oldReq: req1, newReq: req1}, 202 | }, 203 | }, 204 | "increase": { 205 | []templateTestInput{ 206 | {cm: cm1, oldReq: req1, newReq: req2}, 207 | }, 208 | }, 209 | "decrease": { 210 | []templateTestInput{ 211 | {cm: cm1, oldReq: req2, newReq: req1}, 212 | }, 213 | }, 214 | "mixed": { 215 | []templateTestInput{ 216 | {cm: cm1, oldReq: req2, newReq: req1}, 217 | {cm: cm2, oldReq: req1, newReq: req2}, 218 | }, 219 | }, 220 | } 221 | 222 | for n, tt := range tests { 223 | t.Run(n, func(t *testing.T) { 224 | var s strings.Builder 225 | r := New(&s, "markdown") 226 | 227 | for _, rep := range tt.reports { 228 | r.AddReport(rep.cm, rep.oldReq, rep.newReq) 229 | } 230 | 231 | if err := r.Write(); err != nil { 232 | t.Fatalf("unexpected: %v", err) 233 | } 234 | 235 | t.Log(s.String()) 236 | }) 237 | } 238 | } 239 | 240 | func TestCostRerpots_Sort(t *testing.T) { 241 | crs := costReports{ 242 | costReport{ 243 | Cluster: "foo", 244 | New: resourcesCost{CPU: 1, Memory: 1, Storage: 1}, 245 | }, 246 | costReport{ 247 | Cluster: "bar", 248 | New: resourcesCost{CPU: 1, Memory: 1, Storage: 1}, 249 | Old: resourcesCost{CPU: 1, Memory: 1, Storage: 1}, 250 | }, 251 | costReport{ 252 | Cluster: "quux", 253 | Old: resourcesCost{CPU: 1, Memory: 1, Storage: 1}, 254 | }, 255 | } 256 | 257 | rand.Shuffle(len(crs), func(i, j int) { 258 | crs[i], crs[j] = crs[j], crs[i] 259 | }) 260 | 261 | crs.Sort() 262 | 263 | for i, exp := range []string{"foo", "bar", "quux"} { 264 | if g := crs[i]; g.Cluster != exp { 265 | t.Errorf("expecting %s at index %d, got %s", exp, i, g.Cluster) 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /pkg/costmodel/reporter.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "text/tabwriter" 9 | ) 10 | 11 | var ( 12 | headers = []string{"Cluster", "Total Weekly Cost", "Δ Weekly Cost", "Total Monthly Cost", "Δ Monthly Cost"} 13 | periods = []Period{ 14 | Weekly, 15 | Monthly, 16 | } 17 | ) 18 | 19 | var ErrNoReports = errors.New("nothing to report") 20 | 21 | type ReportType string 22 | 23 | const ( 24 | Table ReportType = "table" 25 | Summary ReportType = "summary" 26 | Markdown ReportType = "markdown" 27 | ) 28 | 29 | type Reporter struct { 30 | Writer io.Writer 31 | reports []report 32 | reportType ReportType 33 | } 34 | 35 | // report is a model for a cost report. 36 | type report struct { 37 | CostModel *CostModel 38 | From Requirements 39 | To Requirements 40 | } 41 | 42 | // AddReport adds a costmodel and associated from, to resources to the reporter. 43 | func (r *Reporter) AddReport(costModel *CostModel, from, to Requirements) { 44 | if to.Kind == "Job" || to.Kind == "Cronjob" { 45 | return 46 | } 47 | r.reports = append(r.reports, report{ 48 | CostModel: costModel, 49 | From: from, 50 | To: to, 51 | }) 52 | } 53 | 54 | func New(w io.Writer, reportType string) *Reporter { 55 | // If the writer passed in is nil, set it to io.Discard to prevent nil pointer exceptions later on 56 | if w == nil { 57 | w = io.Discard 58 | } 59 | return &Reporter{ 60 | Writer: w, 61 | reportType: ReportType(reportType), 62 | } 63 | } 64 | 65 | func (r *Reporter) Write() error { 66 | if len(r.reports) == 0 { 67 | return ErrNoReports 68 | } 69 | 70 | switch r.reportType { 71 | case Summary: 72 | return r.writeSummary() 73 | case Table: 74 | return r.writeTable() 75 | case Markdown: 76 | return r.writeMarkdown() 77 | default: 78 | return fmt.Errorf("report type %s not supported", r.reportType) 79 | } 80 | } 81 | 82 | func (r *Reporter) writeSummary() error { 83 | tabwriter := tabwriter.NewWriter(r.Writer, 8, 6, 2, ' ', 0) 84 | 85 | var p Period = Monthly 86 | fromTotalCost, toTotalCost := 0.0, 0.0 87 | for _, m := range r.reports { 88 | // Prevent a nil pointer exception here. Probably better ways to handle this 89 | if m.CostModel == nil { 90 | continue 91 | } 92 | from, to := calculateTotalCostForPeriod(p, m.From, m.To, m.CostModel) 93 | fromTotalCost += from 94 | toTotalCost += to 95 | } 96 | 97 | totalDiff := toTotalCost - fromTotalCost 98 | var rows []string 99 | rows = append( 100 | rows, 101 | fmt.Sprintf("PR changed the overall cost by %s(%.1f%%).", displayCostInDollars(totalDiff), percentageChange(fromTotalCost, toTotalCost)), 102 | fmt.Sprintf("Total Monthly Cost went from $%.2f to $%.2f.", fromTotalCost, toTotalCost), 103 | ) 104 | fmt.Fprintln(r.Writer, strings.Join(rows, "\n")) 105 | return tabwriter.Flush() 106 | } 107 | 108 | func (r *Reporter) writeTable() error { 109 | tabWriter := tabwriter.NewWriter(r.Writer, 8, 6, 2, ' ', 0) 110 | fmt.Fprintln(tabWriter, strings.Join(headers, "\t")) 111 | totalCosts := make(map[string]float64) 112 | 113 | for _, m := range r.reports { 114 | row := []string{ 115 | m.CostModel.Cluster.Name, 116 | } 117 | 118 | for _, p := range periods { 119 | keys := p.Keys() 120 | 121 | fromCost, toCost := calculateTotalCostForPeriod(p, m.From, m.To, m.CostModel) 122 | row = append(row, 123 | fmt.Sprintf("$%.2f", toCost), 124 | fmt.Sprintf("%s(%.1f%%)", displayCostInDollars(toCost-fromCost), percentageChange(fromCost, toCost)), 125 | ) 126 | totalCosts[keys.From] += fromCost 127 | totalCosts[keys.To] += toCost 128 | } 129 | 130 | fmt.Fprintln(tabWriter, strings.Join(row, "\t")) 131 | } 132 | 133 | // If there are multiple models, print a Total Costs row. 134 | if len(r.reports) > 1 { 135 | row := []string{"Total Cost:"} 136 | 137 | for _, p := range periods { 138 | keys := p.Keys() 139 | fromCost, toCost := totalCosts[keys.From], totalCosts[keys.To] 140 | row = append(row, 141 | fmt.Sprintf("$%.2f", toCost), 142 | fmt.Sprintf("$%.2f(%.1f%%)", toCost-fromCost, percentageChange(fromCost, toCost)), 143 | ) 144 | } 145 | 146 | fmt.Fprintln(tabWriter, strings.Join(row, "\t")) 147 | } 148 | 149 | return tabWriter.Flush() 150 | } 151 | 152 | func calculateTotalCostForPeriod(p Period, from Requirements, to Requirements, cm *CostModel) (float64, float64) { 153 | fromCost := cm.TotalCostForPeriod(p, from) 154 | toCost := cm.TotalCostForPeriod(p, to) 155 | return fromCost, toCost 156 | } 157 | 158 | func percentageChange(from, to float64) float64 { 159 | if from == 0 && to == 0 { 160 | return 0.0 161 | } 162 | return ((to - from) / from) * 100.0 163 | } 164 | 165 | // displayCostInDollars is a helper to print out the dollars properly if it's negative or positive. 166 | func displayCostInDollars(cost float64) string { 167 | sign := "" 168 | if cost < 0 { 169 | sign, cost = "-", cost*-1 170 | } 171 | 172 | return fmt.Sprintf("%s$%.2f", sign, cost) 173 | } 174 | -------------------------------------------------------------------------------- /pkg/costmodel/reporter_test.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestReporter_writeSummary(t *testing.T) { 11 | baseCostModel := &CostModel{ 12 | Cluster: &Cluster{ 13 | Name: "test", 14 | }, 15 | CPU: Cost{ 16 | Dollars: 1, 17 | Spot: 1, 18 | NonSpot: 1, 19 | }, 20 | RAM: Cost{ 21 | Dollars: 1, 22 | Spot: 1, 23 | NonSpot: 1, 24 | }, 25 | PersistentVolume: Cost{ 26 | Dollars: 1, 27 | Spot: 1, 28 | NonSpot: 1, 29 | }, 30 | } 31 | 32 | fromRequirements := Requirements{ 33 | CPU: 1000, 34 | Memory: 1024 * 1024 * 1024, 35 | PersistentVolume: 1024 * 1024 * 1024, 36 | } 37 | 38 | toRequirements := Requirements{ 39 | CPU: 2000, 40 | Memory: 1024 * 1024 * 1024 * 2, 41 | PersistentVolume: 1024 * 1024 * 1024 * 2, 42 | } 43 | 44 | t.Run("Test a summary with an empty report", func(t *testing.T) { 45 | var b bytes.Buffer 46 | want := "PR changed the overall cost by $0.00(0.0%).\nTotal Monthly Cost went from $0.00 to $0.00.\n" 47 | r := New(&b, "summary") 48 | r.AddReport(nil, Requirements{}, Requirements{}) 49 | if err := r.Write(); err != nil { 50 | t.Errorf("writeSummary() must not return an error if reports exist") 51 | } 52 | got := b.String() 53 | if got != want { 54 | t.Errorf("writeSummary()\n%v\n%v", got, want) 55 | } 56 | }) 57 | 58 | t.Run("Test a summary with a report", func(t *testing.T) { 59 | var b bytes.Buffer 60 | want := "PR changed the overall cost by $2160.00(100.0%).\nTotal Monthly Cost went from $2160.00 to $4320.00.\n" 61 | r := New(&b, "summary") 62 | r.AddReport(baseCostModel, fromRequirements, toRequirements) 63 | if err := r.Write(); err != nil { 64 | t.Errorf("writeSummary() must not return an error if reports exist") 65 | } 66 | got := b.String() 67 | if got != want { 68 | t.Errorf("writeSummary()\n%v\n%v", got, want) 69 | } 70 | }) 71 | 72 | t.Run("Test a summary with decreased report", func(t *testing.T) { 73 | var b bytes.Buffer 74 | want := "PR changed the overall cost by -$2160.00(-50.0%).\nTotal Monthly Cost went from $4320.00 to $2160.00.\n" 75 | r := New(&b, "summary") 76 | r.AddReport(baseCostModel, toRequirements, fromRequirements) 77 | if err := r.Write(); err != nil { 78 | t.Errorf("writeSummary() must not return an error if reports exist") 79 | } 80 | got := b.String() 81 | if got != want { 82 | t.Errorf("writeSummary()\n%v\n%v", got, want) 83 | } 84 | }) 85 | 86 | t.Run("Test a table with a single increasing report", func(t *testing.T) { 87 | var b bytes.Buffer 88 | r := New(&b, "table") 89 | want := "Cluster Total Weekly Cost Δ Weekly Cost Total Monthly Cost Δ Monthly Cost\ntest $1008.00 $504.00(100.0%) $4320.00 $2160.00(100.0%)\n" 90 | r.AddReport(baseCostModel, fromRequirements, toRequirements) 91 | if err := r.Write(); err != nil { 92 | t.Errorf("writeSummary() must not return an error if reports exist") 93 | } 94 | got := b.String() 95 | if !strings.Contains(got, want) { 96 | t.Errorf("Write()\n%v\n%v", got, want) 97 | } 98 | }) 99 | 100 | t.Run("Test a table with multiple increasing report", func(t *testing.T) { 101 | var b bytes.Buffer 102 | r := New(&b, "table") 103 | 104 | r.AddReport(baseCostModel, fromRequirements, toRequirements) 105 | r.AddReport(baseCostModel, fromRequirements, toRequirements) 106 | if err := r.Write(); err != nil { 107 | t.Errorf("writeSummary() must not return an error if reports exist") 108 | } 109 | got := b.String() 110 | 111 | if !strings.Contains(got, "Total Cost:") { 112 | t.Errorf("Write() with multiple rows did not include the total cost row.") 113 | } 114 | }) 115 | 116 | t.Run("Test a table with a single decreasing report", func(t *testing.T) { 117 | var b bytes.Buffer 118 | r := New(&b, "table") 119 | // @Pokom: This kind of sucks to test this way, but I couldn't think of a better way. 120 | // I don't think we need to test here that it prints out correctly. Ideally I want to check to see that the 121 | // Printed numbers are accurate. 122 | want := "Cluster Total Weekly Cost Δ Weekly Cost Total Monthly Cost Δ Monthly Cost\ntest $504.00 -$504.00(-50.0%) $2160.00 -$2160.00(-50.0%)\n" 123 | r.AddReport(baseCostModel, toRequirements, fromRequirements) 124 | if err := r.Write(); err != nil { 125 | t.Errorf("writeSummary() must not return an error if reports exist") 126 | } 127 | got := b.String() 128 | if !strings.Contains(got, want) { 129 | t.Errorf("Write()\n%v\n%v", got, want) 130 | } 131 | }) 132 | } 133 | 134 | func Test_percentageChange(t *testing.T) { 135 | tests := map[string]struct { 136 | from float64 137 | to float64 138 | want float64 139 | }{ 140 | "Test a percentage change with 0 for from and to": { 141 | from: 0, 142 | to: 0, 143 | want: 0, 144 | }, 145 | "Test a percentage change with a negative value": { 146 | from: 100, 147 | to: 50, 148 | want: -50, 149 | }, 150 | } 151 | for name, tt := range tests { 152 | t.Run(name, func(t *testing.T) { 153 | if got := percentageChange(tt.from, tt.to); got != tt.want { 154 | t.Errorf("percentageChange() = %v, want %v", got, tt.want) 155 | } 156 | }) 157 | } 158 | } 159 | 160 | func Test_calculateTotalCostForPeriod(t *testing.T) { 161 | cost := Cost{ 162 | Spot: 1, 163 | NonSpot: 1, 164 | Dollars: 1, 165 | } 166 | cm := &CostModel{ 167 | Cluster: &Cluster{ 168 | Name: "test", 169 | }, 170 | CPU: cost, 171 | RAM: cost, 172 | PersistentVolume: cost, 173 | } 174 | var p Period = Monthly 175 | 176 | tests := map[string]struct { 177 | cm *CostModel 178 | from Requirements 179 | to Requirements 180 | changeMultiplier float64 181 | }{ 182 | "no change should result in no cost": { 183 | cm: cm, 184 | from: Requirements{ 185 | CPU: 1, 186 | Memory: 1, 187 | PersistentVolume: 1, 188 | }, 189 | to: Requirements{ 190 | CPU: 1, 191 | Memory: 1, 192 | PersistentVolume: 1, 193 | }, 194 | changeMultiplier: 1, 195 | }, 196 | "double in resources should result in a double in cost": { 197 | cm: cm, 198 | from: Requirements{ 199 | CPU: 1000, 200 | Memory: 1024 * 1024 * 1024, 201 | PersistentVolume: 1000, 202 | }, 203 | 204 | to: Requirements{ 205 | CPU: 2000, 206 | Memory: 1024 * 1024 * 1024 * 2, 207 | PersistentVolume: 2000, 208 | }, 209 | changeMultiplier: 2, 210 | }, 211 | "halving in resources should result in a halving in cost": { 212 | cm: cm, 213 | from: Requirements{ 214 | CPU: 1000, 215 | Memory: 1024 * 1024 * 1024, 216 | PersistentVolume: 1000, 217 | }, 218 | to: Requirements{ 219 | CPU: 500, 220 | Memory: 1024 * 1024 * 1024 / 2, 221 | PersistentVolume: 500, 222 | }, 223 | changeMultiplier: 0.5, 224 | }, 225 | } 226 | for name, test := range tests { 227 | from, to := calculateTotalCostForPeriod(p, test.from, test.to, test.cm) 228 | if from*test.changeMultiplier != to { 229 | t.Errorf("%s: from %v != to %v * changeMultiplier %v", name, from, to, test.changeMultiplier) 230 | } 231 | } 232 | } 233 | 234 | func TestReporter_Write(t *testing.T) { 235 | t.Run("no reports", func(t *testing.T) { 236 | for _, rt := range []ReportType{Table, Summary, Markdown} { 237 | rt := string(rt) 238 | t.Run(rt, func(t *testing.T) { 239 | var s strings.Builder 240 | err := New(&s, rt).Write() 241 | if !errors.Is(err, ErrNoReports) { 242 | t.Fatalf("expecting ErrNoReports, got %v", err) 243 | } 244 | if s.Len() > 0 { 245 | t.Fatalf("expecting reporter not to write anything, got %q", s.String()) 246 | } 247 | }) 248 | } 249 | }) 250 | } 251 | -------------------------------------------------------------------------------- /pkg/costmodel/requirements.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | appsv1 "k8s.io/api/apps/v1" 8 | batchv1 "k8s.io/api/batch/v1" 9 | corev1 "k8s.io/api/core/v1" 10 | "k8s.io/apimachinery/pkg/api/meta" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | "k8s.io/client-go/kubernetes/scheme" 13 | ) 14 | 15 | // ErrUnknownKind is the error throw when the kind of the resource in 16 | // the manifest is unknown to the parser. 17 | var ErrUnknownKind = errors.New("unknown kind") 18 | 19 | // Requirements is a struct that holds the aggergated amount of resources for a given manifest file. 20 | // CPU and Memory are in millicores and bytes respectively and are the sum of all containers in a pod. 21 | // TODO: Calculate the amount of persistent volume required for a given manifest. This will require finding the associated PVC and calculating the size of the volume. 22 | type Requirements struct { 23 | CPU int64 24 | Memory int64 25 | PersistentVolume int64 26 | Kind string 27 | Namespace string 28 | Name string 29 | } 30 | 31 | // AddRequirements will increment the resources by the amount specified in the given requirements. 32 | func (r *Requirements) AddRequirements(reqs corev1.ResourceRequirements) { 33 | r.CPU += reqs.Requests.Cpu().MilliValue() 34 | r.Memory += reqs.Requests.Memory().Value() 35 | } 36 | 37 | // ParseManifest will parse a manifest file and return the aggregated amount of resources requested. 38 | // The manifest can be a Deployment, StatefulSet, DaemonSet, Cronjob, Job, or Pod. 39 | // If the manifest has the number of Replicas, the total resources will be multiplied by the number of replicas. 40 | func ParseManifest(src []byte, costModel *CostModel) (Requirements, error) { 41 | var r Requirements 42 | 43 | decode := scheme.Codecs.UniversalDeserializer().Decode 44 | 45 | obj, kind, err := decode(src, nil, nil) 46 | if err != nil { 47 | return r, fmt.Errorf("%w: could not decode object: %s", ErrUnknownKind, err) 48 | } 49 | 50 | var ( 51 | containers []corev1.Container 52 | replicas = 1 53 | ) 54 | 55 | switch x := obj.(type) { 56 | case *appsv1.StatefulSet: 57 | containers = x.Spec.Template.Spec.Containers 58 | if x.Spec.Replicas != nil { 59 | replicas = int(*x.Spec.Replicas) 60 | } 61 | addPersistentVolumeClaimRequirements(x.Spec.VolumeClaimTemplates, &r, replicas) 62 | 63 | case *appsv1.Deployment: 64 | containers = x.Spec.Template.Spec.Containers 65 | if x.Spec.Replicas != nil { 66 | replicas = int(*x.Spec.Replicas) 67 | } 68 | 69 | case *appsv1.DaemonSet: 70 | containers = x.Spec.Template.Spec.Containers 71 | // DaemonSets don't have a replica count, so we need to use the number of nodes in the cluster. 72 | if costModel == nil { 73 | return r, fmt.Errorf("%w: daemonsets require a cost model", ErrUnknownKind) 74 | } 75 | if costModel.Cluster != nil && costModel.Cluster.NodeCount > replicas { 76 | replicas = costModel.Cluster.NodeCount 77 | } 78 | 79 | case *batchv1.Job: 80 | containers = x.Spec.Template.Spec.Containers 81 | 82 | case *batchv1.CronJob: 83 | containers = x.Spec.JobTemplate.Spec.Template.Spec.Containers 84 | 85 | case *corev1.Pod: 86 | containers = x.Spec.Containers 87 | 88 | default: 89 | return r, fmt.Errorf("%w: %v (%T)", ErrUnknownKind, kind, x) 90 | } 91 | 92 | r.Kind = kind.Kind 93 | addContainersRequirements(containers, &r, replicas) 94 | err = addMetadataToRequirements(obj, &r) 95 | if err != nil { 96 | return r, err 97 | } 98 | return r, nil 99 | } 100 | 101 | func addMetadataToRequirements(obj runtime.Object, requirements *Requirements) error { 102 | metadata, err := meta.Accessor(obj) 103 | if err != nil { 104 | return err 105 | } 106 | requirements.Namespace = metadata.GetNamespace() 107 | requirements.Name = metadata.GetName() 108 | return nil 109 | } 110 | 111 | // addPersistentVolumeClaimRequirements will add the resources requested by the given persistent volume claims to the given requirements. 112 | func addPersistentVolumeClaimRequirements(templates []corev1.PersistentVolumeClaim, r *Requirements, replicas int) { 113 | for _, template := range templates { 114 | r.PersistentVolume += int64(replicas) * template.Spec.Resources.Requests.Storage().Value() 115 | } 116 | } 117 | 118 | // addContainersRequirements will add the resources requested by the given containers to the given requirements. 119 | // If the number of replicas is greater than 1, the resources will be multiplied by the number of replicas. 120 | func addContainersRequirements(containers []corev1.Container, r *Requirements, replicas int) { 121 | for _, container := range containers { 122 | resources := container.Resources 123 | r.CPU += resources.Requests.Cpu().MilliValue() * int64(replicas) 124 | r.Memory += resources.Requests.Memory().Value() * int64(replicas) 125 | } 126 | } 127 | 128 | // Delta returns the difference between two resources. 129 | // A positive value signals that the resource has increased. 130 | // A negative value signals that the resource has decreased. 131 | func Delta(from, to Requirements) Requirements { 132 | return Requirements{ 133 | CPU: to.CPU - from.CPU, 134 | Memory: to.Memory - from.Memory, 135 | PersistentVolume: to.PersistentVolume - from.PersistentVolume, 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /pkg/costmodel/requirements_test.go: -------------------------------------------------------------------------------- 1 | package costmodel 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "k8s.io/apimachinery/pkg/api/resource" 8 | ) 9 | 10 | func TestParseManifest(t *testing.T) { 11 | pq := func(s string) *resource.Quantity { 12 | t.Helper() 13 | q, err := resource.ParseQuantity(s) 14 | if err != nil { 15 | t.Fatalf("parsing CPU %q: %v", s, err) 16 | } 17 | return &q 18 | } 19 | 20 | cpu := func(s string) int64 { 21 | t.Helper() 22 | return pq(s).MilliValue() 23 | } 24 | 25 | mem := func(s string) int64 { 26 | t.Helper() 27 | return pq(s).Value() 28 | } 29 | 30 | pv := func(s string) int64 { 31 | t.Helper() 32 | return pq(s).Value() 33 | } 34 | 35 | tests := map[string]Requirements{ 36 | "Deployment": { 37 | CPU: cpu("500m"), 38 | Memory: mem("1000Mi"), 39 | Kind: "Deployment", 40 | Namespace: "opencost", 41 | Name: "prom-label-proxy", 42 | }, 43 | "Job": { 44 | CPU: cpu("50m"), 45 | Memory: mem("200Mi"), 46 | Kind: "Job", 47 | Namespace: "hosted-grafana", 48 | Name: "hosted-grafana-source-ips-update-27973440", 49 | }, 50 | 51 | "StatefulSet": { 52 | CPU: cpu("1"), 53 | Memory: mem("4Gi"), 54 | PersistentVolume: pv("32Gi"), 55 | Kind: "StatefulSet", 56 | Namespace: "opencost", 57 | Name: "opencost", 58 | }, 59 | 60 | "DaemonSet": { 61 | CPU: cpu("50m"), 62 | Memory: mem("50Mi"), 63 | Kind: "DaemonSet", 64 | Namespace: "conntrack-exporter", 65 | Name: "conntrack-exporter", 66 | }, 67 | 68 | "Pod": { 69 | CPU: cpu("45"), 70 | Memory: mem("320Gi"), 71 | Kind: "Pod", 72 | Namespace: "default", 73 | Name: "prometheus-0", 74 | }, 75 | 76 | // Multi-container manifests 77 | "StatefulSet-with-2-containers": { 78 | CPU: cpu("1") + cpu("10m"), 79 | Memory: mem("4Gi") + mem("55M"), 80 | PersistentVolume: pv("32Gi"), 81 | Kind: "StatefulSet", 82 | Namespace: "opencost", 83 | Name: "opencost", 84 | }, 85 | 86 | // With replicas 87 | "StatefulSet-with-replicas": { 88 | CPU: 2 * cpu("45"), 89 | Memory: 2 * mem("320Gi"), 90 | PersistentVolume: 2 * pv("7500Gi"), 91 | Kind: "StatefulSet", 92 | Namespace: "default", 93 | Name: "prometheus", 94 | }, 95 | } 96 | 97 | for kind, exp := range tests { 98 | t.Run(kind, func(t *testing.T) { 99 | src, err := os.ReadFile("testdata/resource/" + kind + ".json") 100 | if err != nil { 101 | t.Fatalf("unexpected error reading manifest file: %v", err) 102 | } 103 | 104 | got, err := ParseManifest(src, &CostModel{}) 105 | if err != nil { 106 | t.Fatalf("unexpected error parsing manifest: %v", err) 107 | } 108 | 109 | if exp != got { 110 | t.Fatalf("wrong parsed values:\nexp: %#v\ngot: %#v", exp, got) 111 | } 112 | }) 113 | } 114 | 115 | t.Run("panics on StatefulSet without replicas", func(t *testing.T) { 116 | // This was first reported on Slack and it was failing all 117 | // Alertmanager PRs, blocking auto-rollouts. 118 | // https://raintank-corp.slack.com/archives/C051ALUR9LG/p1681286565973609 119 | src, err := os.ReadFile("testdata/resource/StatefulSet-without-replicas.yaml") 120 | if err != nil { 121 | t.Fatalf("unexpected error reading manifest file: %v", err) 122 | } 123 | 124 | got, err := ParseManifest(src, &CostModel{}) 125 | if err != nil { 126 | t.Fatalf("unexpected error parsing manifest: %v", err) 127 | } 128 | 129 | exp := Requirements{ 130 | CPU: cpu("200m"), 131 | Memory: mem("1Gi"), 132 | PersistentVolume: pv("100Gi"), 133 | Kind: "StatefulSet", 134 | Namespace: "alertmanager", 135 | Name: "alertmanager", 136 | } 137 | 138 | if exp != got { 139 | t.Fatalf("wrong parsed values:\nexp: %#v\ngot: %#v", exp, got) 140 | } 141 | }) 142 | 143 | t.Run("panics on Daemonset if costmodel is nil", func(t *testing.T) { 144 | src, err := os.ReadFile("testdata/resource/DaemonSet.json") 145 | if err != nil { 146 | t.Fatalf("unexpected error reading manifest file: %v", err) 147 | } 148 | 149 | _, err = ParseManifest(src, nil) 150 | if err == nil { 151 | t.Fatalf("expected error parsing manifest") 152 | } 153 | }) 154 | } 155 | 156 | func TestDelta(t *testing.T) { 157 | tests := map[string]struct { 158 | from Requirements 159 | to Requirements 160 | want Requirements 161 | }{ 162 | "Two equal resources should result in all values being zero": { 163 | from: Requirements{ 164 | CPU: 1, 165 | Memory: 2, 166 | }, 167 | to: Requirements{ 168 | CPU: 1, 169 | Memory: 2, 170 | }, 171 | want: Requirements{ 172 | CPU: 0, 173 | Memory: 0, 174 | }, 175 | }, 176 | "Two resources with more resources should result in the correct positive delta": { 177 | from: Requirements{ 178 | CPU: 1, 179 | Memory: 2, 180 | }, 181 | to: Requirements{ 182 | CPU: 2, 183 | Memory: 4, 184 | }, 185 | want: Requirements{ 186 | CPU: 1, 187 | Memory: 2, 188 | }, 189 | }, 190 | "To resources with less resources should result in the correct negative delta": { 191 | from: Requirements{ 192 | CPU: 2, 193 | Memory: 4, 194 | }, 195 | to: Requirements{ 196 | CPU: 1, 197 | Memory: 2, 198 | }, 199 | want: Requirements{ 200 | CPU: -1, 201 | Memory: -2, 202 | }, 203 | }, 204 | } 205 | for name, test := range tests { 206 | t.Run(name, func(t *testing.T) { 207 | if got := Delta(test.from, test.to); got != test.want { 208 | t.Errorf("Delta() = %v, want %v", got, test.want) 209 | } 210 | }) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /pkg/costmodel/testdata/resource/DaemonSet-more-requests.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "apps/v1", 3 | "kind": "DaemonSet", 4 | "metadata": { 5 | "annotations": { 6 | "deprecated.daemonset.template.generation": "13" 7 | }, 8 | "creationTimestamp": "2021-11-05T14:04:33Z", 9 | "generation": 13, 10 | "labels": { 11 | "kustomize.toolkit.fluxcd.io/name": "kube-manifests-conntrack-exporter", 12 | "kustomize.toolkit.fluxcd.io/namespace": "conntrack-exporter", 13 | "tanka.dev/environment": "13a04e52f423c084c804cb8517eac7af19003f2c5979835e" 14 | }, 15 | "name": "conntrack-exporter", 16 | "namespace": "conntrack-exporter", 17 | "resourceVersion": "5607071126", 18 | "uid": "2ffbac6a-b432-4487-9820-0106db3e4c83" 19 | }, 20 | "spec": { 21 | "minReadySeconds": 10, 22 | "revisionHistoryLimit": 10, 23 | "selector": { 24 | "matchLabels": { 25 | "name": "conntrack-exporter" 26 | } 27 | }, 28 | "template": { 29 | "metadata": { 30 | "annotations": { 31 | "prometheus.io.scrape": "false" 32 | }, 33 | "creationTimestamp": null, 34 | "labels": { 35 | "name": "conntrack-exporter" 36 | } 37 | }, 38 | "spec": { 39 | "containers": [ 40 | { 41 | "args": [ 42 | "-kubelet-pods-endpoint=https://$(NODE_IP):10250/pods", 43 | "-listen=$(POD_IP):9274" 44 | ], 45 | "env": [ 46 | { 47 | "name": "NODE_IP", 48 | "valueFrom": { 49 | "fieldRef": { 50 | "apiVersion": "v1", 51 | "fieldPath": "status.hostIP" 52 | } 53 | } 54 | }, 55 | { 56 | "name": "POD_IP", 57 | "valueFrom": { 58 | "fieldRef": { 59 | "apiVersion": "v1", 60 | "fieldPath": "status.podIP" 61 | } 62 | } 63 | } 64 | ], 65 | "image": "us.gcr.io/kubernetes-dev/conntrack-exporter:2022-10-17-v174750-136d81afb", 66 | "imagePullPolicy": "IfNotPresent", 67 | "name": "conntrack-exporter", 68 | "ports": [ 69 | { 70 | "containerPort": 9274, 71 | "hostPort": 9274, 72 | "name": "http-metrics", 73 | "protocol": "TCP" 74 | } 75 | ], 76 | "resources": { 77 | "requests": { 78 | "cpu": "1", 79 | "memory": "1Gi" 80 | } 81 | }, 82 | "securityContext": { 83 | "capabilities": { 84 | "add": [ 85 | "NET_ADMIN" 86 | ] 87 | } 88 | }, 89 | "terminationMessagePath": "/dev/termination-log", 90 | "terminationMessagePolicy": "File" 91 | } 92 | ], 93 | "dnsPolicy": "ClusterFirst", 94 | "hostNetwork": true, 95 | "imagePullSecrets": [ 96 | { 97 | "name": "gcr" 98 | } 99 | ], 100 | "priorityClassName": "high", 101 | "restartPolicy": "Always", 102 | "schedulerName": "default-scheduler", 103 | "securityContext": {}, 104 | "serviceAccount": "conntrack-exporter", 105 | "serviceAccountName": "conntrack-exporter", 106 | "terminationGracePeriodSeconds": 30, 107 | "tolerations": [ 108 | { 109 | "effect": "NoSchedule", 110 | "operator": "Exists" 111 | } 112 | ] 113 | } 114 | }, 115 | "updateStrategy": { 116 | "rollingUpdate": { 117 | "maxSurge": 0, 118 | "maxUnavailable": 1 119 | }, 120 | "type": "RollingUpdate" 121 | } 122 | }, 123 | "status": { 124 | "currentNumberScheduled": 1019, 125 | "desiredNumberScheduled": 1019, 126 | "numberAvailable": 1018, 127 | "numberMisscheduled": 0, 128 | "numberReady": 1018, 129 | "numberUnavailable": 1, 130 | "observedGeneration": 13, 131 | "updatedNumberScheduled": 1019 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /pkg/costmodel/testdata/resource/DaemonSet.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "apps/v1", 3 | "kind": "DaemonSet", 4 | "metadata": { 5 | "annotations": { 6 | "deprecated.daemonset.template.generation": "13" 7 | }, 8 | "creationTimestamp": "2021-11-05T14:04:33Z", 9 | "generation": 13, 10 | "labels": { 11 | "kustomize.toolkit.fluxcd.io/name": "kube-manifests-conntrack-exporter", 12 | "kustomize.toolkit.fluxcd.io/namespace": "conntrack-exporter", 13 | "tanka.dev/environment": "13a04e52f423c084c804cb8517eac7af19003f2c5979835e" 14 | }, 15 | "name": "conntrack-exporter", 16 | "namespace": "conntrack-exporter", 17 | "resourceVersion": "5607071126", 18 | "uid": "2ffbac6a-b432-4487-9820-0106db3e4c83" 19 | }, 20 | "spec": { 21 | "minReadySeconds": 10, 22 | "revisionHistoryLimit": 10, 23 | "selector": { 24 | "matchLabels": { 25 | "name": "conntrack-exporter" 26 | } 27 | }, 28 | "template": { 29 | "metadata": { 30 | "annotations": { 31 | "prometheus.io.scrape": "false" 32 | }, 33 | "creationTimestamp": null, 34 | "labels": { 35 | "name": "conntrack-exporter" 36 | } 37 | }, 38 | "spec": { 39 | "containers": [ 40 | { 41 | "args": [ 42 | "-kubelet-pods-endpoint=https://$(NODE_IP):10250/pods", 43 | "-listen=$(POD_IP):9274" 44 | ], 45 | "env": [ 46 | { 47 | "name": "NODE_IP", 48 | "valueFrom": { 49 | "fieldRef": { 50 | "apiVersion": "v1", 51 | "fieldPath": "status.hostIP" 52 | } 53 | } 54 | }, 55 | { 56 | "name": "POD_IP", 57 | "valueFrom": { 58 | "fieldRef": { 59 | "apiVersion": "v1", 60 | "fieldPath": "status.podIP" 61 | } 62 | } 63 | } 64 | ], 65 | "image": "us.gcr.io/kubernetes-dev/conntrack-exporter:2022-10-17-v174750-136d81afb", 66 | "imagePullPolicy": "IfNotPresent", 67 | "name": "conntrack-exporter", 68 | "ports": [ 69 | { 70 | "containerPort": 9274, 71 | "hostPort": 9274, 72 | "name": "http-metrics", 73 | "protocol": "TCP" 74 | } 75 | ], 76 | "resources": { 77 | "requests": { 78 | "cpu": "50m", 79 | "memory": "50Mi" 80 | } 81 | }, 82 | "securityContext": { 83 | "capabilities": { 84 | "add": [ 85 | "NET_ADMIN" 86 | ] 87 | } 88 | }, 89 | "terminationMessagePath": "/dev/termination-log", 90 | "terminationMessagePolicy": "File" 91 | } 92 | ], 93 | "dnsPolicy": "ClusterFirst", 94 | "hostNetwork": true, 95 | "imagePullSecrets": [ 96 | { 97 | "name": "gcr" 98 | } 99 | ], 100 | "priorityClassName": "high", 101 | "restartPolicy": "Always", 102 | "schedulerName": "default-scheduler", 103 | "securityContext": {}, 104 | "serviceAccount": "conntrack-exporter", 105 | "serviceAccountName": "conntrack-exporter", 106 | "terminationGracePeriodSeconds": 30, 107 | "tolerations": [ 108 | { 109 | "effect": "NoSchedule", 110 | "operator": "Exists" 111 | } 112 | ] 113 | } 114 | }, 115 | "updateStrategy": { 116 | "rollingUpdate": { 117 | "maxSurge": 0, 118 | "maxUnavailable": 1 119 | }, 120 | "type": "RollingUpdate" 121 | } 122 | }, 123 | "status": { 124 | "currentNumberScheduled": 1019, 125 | "desiredNumberScheduled": 1019, 126 | "numberAvailable": 1018, 127 | "numberMisscheduled": 0, 128 | "numberReady": 1018, 129 | "numberUnavailable": 1, 130 | "observedGeneration": 13, 131 | "updatedNumberScheduled": 1019 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /pkg/costmodel/testdata/resource/Deployment-more-requests.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "apps/v1", 3 | "kind": "Deployment", 4 | "metadata": { 5 | "annotations": { 6 | "deployment.kubernetes.io/revision": "2" 7 | }, 8 | "creationTimestamp": "2022-10-24T13:17:00Z", 9 | "generation": 2, 10 | "labels": { 11 | "kustomize.toolkit.fluxcd.io/name": "kube-manifests-opencost", 12 | "kustomize.toolkit.fluxcd.io/namespace": "opencost", 13 | "tanka.dev/environment": "be79d2edd9abf276a674f10b071fedae86fd1e0ecbb57813" 14 | }, 15 | "name": "prom-label-proxy", 16 | "namespace": "opencost", 17 | "resourceVersion": "5567881684", 18 | "uid": "0d5a258d-e62c-4f15-b8ed-11e80ebb21c6" 19 | }, 20 | "spec": { 21 | "minReadySeconds": 10, 22 | "progressDeadlineSeconds": 600, 23 | "replicas": 1, 24 | "revisionHistoryLimit": 10, 25 | "selector": { 26 | "matchLabels": { 27 | "name": "prom-label-proxy" 28 | } 29 | }, 30 | "strategy": { 31 | "rollingUpdate": { 32 | "maxSurge": "25%", 33 | "maxUnavailable": "25%" 34 | }, 35 | "type": "RollingUpdate" 36 | }, 37 | "template": { 38 | "metadata": { 39 | "creationTimestamp": null, 40 | "labels": { 41 | "name": "prom-label-proxy" 42 | } 43 | }, 44 | "spec": { 45 | "containers": [ 46 | { 47 | "args": [ 48 | "-insecure-listen-address=0.0.0.0:8080", 49 | "-label=cluster", 50 | "-label-value=prod-us-central-0", 51 | "-upstream=https://prometheus-ops-01-ops-us-east-0.grafana-ops.net/api/prom" 52 | ], 53 | "image": "quay.io/prometheuscommunity/prom-label-proxy:master@sha256:3d055b873827f95230d5babb9de48ad32b9a77e5e1a017eebe7eb898128194a9", 54 | "imagePullPolicy": "IfNotPresent", 55 | "name": "prom-label-proxy", 56 | "ports": [ 57 | { 58 | "containerPort": 8080, 59 | "name": "http-metrics", 60 | "protocol": "TCP" 61 | } 62 | ], 63 | "resources": { 64 | "limits": { 65 | "cpu": "4", 66 | "memory": "4000Mi" 67 | }, 68 | "requests": { 69 | "cpu": "2", 70 | "memory": "2000Mi" 71 | } 72 | }, 73 | "terminationMessagePath": "/dev/termination-log", 74 | "terminationMessagePolicy": "File" 75 | } 76 | ], 77 | "dnsPolicy": "ClusterFirst", 78 | "restartPolicy": "Always", 79 | "schedulerName": "default-scheduler", 80 | "securityContext": {}, 81 | "terminationGracePeriodSeconds": 30 82 | } 83 | } 84 | }, 85 | "status": { 86 | "availableReplicas": 1, 87 | "conditions": [ 88 | { 89 | "lastTransitionTime": "2022-10-24T13:17:00Z", 90 | "lastUpdateTime": "2022-10-27T17:18:19Z", 91 | "message": "ReplicaSet \"prom-label-proxy-7b54bcc9f4\" has successfully progressed.", 92 | "reason": "NewReplicaSetAvailable", 93 | "status": "True", 94 | "type": "Progressing" 95 | }, 96 | { 97 | "lastTransitionTime": "2023-03-08T08:25:09Z", 98 | "lastUpdateTime": "2023-03-08T08:25:09Z", 99 | "message": "Deployment has minimum availability.", 100 | "reason": "MinimumReplicasAvailable", 101 | "status": "True", 102 | "type": "Available" 103 | } 104 | ], 105 | "observedGeneration": 2, 106 | "readyReplicas": 1, 107 | "replicas": 1, 108 | "updatedReplicas": 1 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /pkg/costmodel/testdata/resource/Deployment.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "apps/v1", 3 | "kind": "Deployment", 4 | "metadata": { 5 | "annotations": { 6 | "deployment.kubernetes.io/revision": "2" 7 | }, 8 | "creationTimestamp": "2022-10-24T13:17:00Z", 9 | "generation": 2, 10 | "labels": { 11 | "kustomize.toolkit.fluxcd.io/name": "kube-manifests-opencost", 12 | "kustomize.toolkit.fluxcd.io/namespace": "opencost", 13 | "tanka.dev/environment": "be79d2edd9abf276a674f10b071fedae86fd1e0ecbb57813" 14 | }, 15 | "name": "prom-label-proxy", 16 | "namespace": "opencost", 17 | "resourceVersion": "5567881684", 18 | "uid": "0d5a258d-e62c-4f15-b8ed-11e80ebb21c6" 19 | }, 20 | "spec": { 21 | "minReadySeconds": 10, 22 | "progressDeadlineSeconds": 600, 23 | "replicas": 1, 24 | "revisionHistoryLimit": 10, 25 | "selector": { 26 | "matchLabels": { 27 | "name": "prom-label-proxy" 28 | } 29 | }, 30 | "strategy": { 31 | "rollingUpdate": { 32 | "maxSurge": "25%", 33 | "maxUnavailable": "25%" 34 | }, 35 | "type": "RollingUpdate" 36 | }, 37 | "template": { 38 | "metadata": { 39 | "creationTimestamp": null, 40 | "labels": { 41 | "name": "prom-label-proxy" 42 | } 43 | }, 44 | "spec": { 45 | "containers": [ 46 | { 47 | "args": [ 48 | "-insecure-listen-address=0.0.0.0:8080", 49 | "-label=cluster", 50 | "-label-value=prod-us-central-0", 51 | "-upstream=https://prometheus-ops-01-ops-us-east-0.grafana-ops.net/api/prom" 52 | ], 53 | "image": "quay.io/prometheuscommunity/prom-label-proxy:master@sha256:3d055b873827f95230d5babb9de48ad32b9a77e5e1a017eebe7eb898128194a9", 54 | "imagePullPolicy": "IfNotPresent", 55 | "name": "prom-label-proxy", 56 | "ports": [ 57 | { 58 | "containerPort": 8080, 59 | "name": "http-metrics", 60 | "protocol": "TCP" 61 | } 62 | ], 63 | "resources": { 64 | "limits": { 65 | "cpu": "2", 66 | "memory": "4000Mi" 67 | }, 68 | "requests": { 69 | "cpu": "500m", 70 | "memory": "1000Mi" 71 | } 72 | }, 73 | "terminationMessagePath": "/dev/termination-log", 74 | "terminationMessagePolicy": "File" 75 | } 76 | ], 77 | "dnsPolicy": "ClusterFirst", 78 | "restartPolicy": "Always", 79 | "schedulerName": "default-scheduler", 80 | "securityContext": {}, 81 | "terminationGracePeriodSeconds": 30 82 | } 83 | } 84 | }, 85 | "status": { 86 | "availableReplicas": 1, 87 | "conditions": [ 88 | { 89 | "lastTransitionTime": "2022-10-24T13:17:00Z", 90 | "lastUpdateTime": "2022-10-27T17:18:19Z", 91 | "message": "ReplicaSet \"prom-label-proxy-7b54bcc9f4\" has successfully progressed.", 92 | "reason": "NewReplicaSetAvailable", 93 | "status": "True", 94 | "type": "Progressing" 95 | }, 96 | { 97 | "lastTransitionTime": "2023-03-08T08:25:09Z", 98 | "lastUpdateTime": "2023-03-08T08:25:09Z", 99 | "message": "Deployment has minimum availability.", 100 | "reason": "MinimumReplicasAvailable", 101 | "status": "True", 102 | "type": "Available" 103 | } 104 | ], 105 | "observedGeneration": 2, 106 | "readyReplicas": 1, 107 | "replicas": 1, 108 | "updatedReplicas": 1 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /pkg/costmodel/testdata/resource/Job.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "batch/v1", 3 | "kind": "Job", 4 | "metadata": { 5 | "creationTimestamp": "2023-03-10T00:00:00Z", 6 | "generation": 1, 7 | "labels": { 8 | "controller-uid": "b7428da1-6d7b-4b9d-8b51-bdd170958e22", 9 | "job-name": "hosted-grafana-source-ips-update-27973440", 10 | "name": "hosted-grafana-source-ips-update" 11 | }, 12 | "name": "hosted-grafana-source-ips-update-27973440", 13 | "namespace": "hosted-grafana", 14 | "ownerReferences": [ 15 | { 16 | "apiVersion": "batch/v1", 17 | "blockOwnerDeletion": true, 18 | "controller": true, 19 | "kind": "CronJob", 20 | "name": "hosted-grafana-source-ips-update", 21 | "uid": "c4125095-716b-43dd-b112-0ba43ccb5b51" 22 | } 23 | ], 24 | "resourceVersion": "5597051618", 25 | "uid": "b7428da1-6d7b-4b9d-8b51-bdd170958e22" 26 | }, 27 | "spec": { 28 | "backoffLimit": 3, 29 | "completionMode": "NonIndexed", 30 | "parallelism": 1, 31 | "selector": { 32 | "matchLabels": { 33 | "controller-uid": "b7428da1-6d7b-4b9d-8b51-bdd170958e22" 34 | } 35 | }, 36 | "suspend": false, 37 | "template": { 38 | "metadata": { 39 | "creationTimestamp": null, 40 | "labels": { 41 | "controller-uid": "b7428da1-6d7b-4b9d-8b51-bdd170958e22", 42 | "job-name": "hosted-grafana-source-ips-update-27973440", 43 | "name": "hosted-grafana-source-ips-update" 44 | } 45 | }, 46 | "spec": { 47 | "activeDeadlineSeconds": 360, 48 | "containers": [ 49 | { 50 | "command": [ 51 | "/usr/local/bin/node", 52 | "./bin/update-src-ips.js", 53 | "--dry-run=false", 54 | "--debug=true", 55 | "--cluster-domain=src-ips.gke-us-central1.hosted-grafana.grafana.net", 56 | "--static-only=true", 57 | "--static-ips=34.69.204.106", 58 | "--static-ips=107.178.208.235", 59 | "--static-ips=35.223.104.30", 60 | "--static-ips=34.122.201.10", 61 | "--static-ips=35.194.53.190", 62 | "--static-ips=34.70.10.78", 63 | "--static-ips=104.154.18.168", 64 | "--static-ips=35.192.170.84", 65 | "--static-ips=35.225.46.237", 66 | "--static-ips=35.238.91.227", 67 | "--static-ips=35.239.61.132", 68 | "--static-ips=104.154.148.31", 69 | "--static-ips=23.236.55.100", 70 | "--static-ips=34.71.21.236", 71 | "--static-ips=104.154.179.160", 72 | "--static-ips=35.202.43.115", 73 | "--static-ips=104.197.149.206", 74 | "--static-ips=35.222.253.67", 75 | "--static-ips=34.122.25.189", 76 | "--static-ips=35.184.23.28", 77 | "--static-ips=34.72.240.165", 78 | "--static-ips=34.70.19.238", 79 | "--static-ips=34.68.98.63", 80 | "--static-ips=35.188.111.61", 81 | "--static-ips=35.188.211.52", 82 | "--static-ips=34.134.222.119", 83 | "--static-ips=35.192.9.186", 84 | "--static-ips=34.72.94.67", 85 | "--static-ips=35.223.234.104", 86 | "--static-ips=34.71.146.82", 87 | "--static-ips=34.71.98.16", 88 | "--static-ips=34.134.236.18", 89 | "--static-ips=35.232.101.234", 90 | "--static-ips=34.69.227.129", 91 | "--static-ips=34.69.89.218", 92 | "--static-ips=34.121.249.58", 93 | "--static-ips=35.188.35.239", 94 | "--static-ips=35.224.49.153", 95 | "--static-ips=35.224.152.225", 96 | "--static-ips=35.193.153.9", 97 | "--static-ips=35.188.223.159", 98 | "--static-ips=34.121.8.98", 99 | "--static-ips=35.232.52.64", 100 | "--static-ips=104.197.203.224", 101 | "--static-ips=35.232.164.62", 102 | "--static-ips=34.132.169.54", 103 | "--static-ips=34.67.217.87", 104 | "--static-ips=34.133.189.12", 105 | "--static-ips=35.193.116.27", 106 | "--static-ips=35.239.142.182", 107 | "--static-ips=34.71.213.69", 108 | "--static-ips=34.68.102.192", 109 | "--static-ips=34.136.227.230", 110 | "--static-ips=35.224.213.187", 111 | "--static-ips=104.197.72.38", 112 | "--static-ips=35.225.14.197", 113 | "--static-ips=34.28.196.162", 114 | "--static-ips=34.66.50.58", 115 | "--static-ips=34.30.244.87", 116 | "--static-ips=34.66.84.48" 117 | ], 118 | "image": "us.gcr.io/hosted-grafana/hosted-grafana-api:0.1.293", 119 | "imagePullPolicy": "IfNotPresent", 120 | "name": "source-ips-update", 121 | "resources": { 122 | "limits": { 123 | "memory": "400Mi" 124 | }, 125 | "requests": { 126 | "cpu": "50m", 127 | "memory": "200Mi" 128 | } 129 | }, 130 | "terminationMessagePath": "/dev/termination-log", 131 | "terminationMessagePolicy": "File" 132 | } 133 | ], 134 | "dnsPolicy": "ClusterFirst", 135 | "imagePullSecrets": [ 136 | { 137 | "name": "gcr" 138 | } 139 | ], 140 | "restartPolicy": "OnFailure", 141 | "schedulerName": "default-scheduler", 142 | "securityContext": {}, 143 | "serviceAccount": "hg-src-ips-update", 144 | "serviceAccountName": "hg-src-ips-update", 145 | "terminationGracePeriodSeconds": 30 146 | } 147 | } 148 | }, 149 | "status": { 150 | "completionTime": "2023-03-10T00:00:24Z", 151 | "conditions": [ 152 | { 153 | "lastProbeTime": "2023-03-10T00:00:24Z", 154 | "lastTransitionTime": "2023-03-10T00:00:24Z", 155 | "status": "True", 156 | "type": "Complete" 157 | } 158 | ], 159 | "startTime": "2023-03-10T00:00:00Z", 160 | "succeeded": 1 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /pkg/costmodel/testdata/resource/StatefulSet-more-storage.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "apps/v1", 3 | "kind": "StatefulSet", 4 | "metadata": { 5 | "creationTimestamp": "2022-10-05T20:38:31Z", 6 | "generation": 17, 7 | "labels": { 8 | "kustomize.toolkit.fluxcd.io/name": "kube-manifests-opencost", 9 | "kustomize.toolkit.fluxcd.io/namespace": "opencost", 10 | "tanka.dev/environment": "85ead74422d749cb54711e74c81bc5d6ed6da54e92b5fa69" 11 | }, 12 | "name": "opencost", 13 | "namespace": "opencost", 14 | "resourceVersion": "2386985939", 15 | "uid": "56495ef8-2650-46e8-9528-28759cf47151" 16 | }, 17 | "spec": { 18 | "podManagementPolicy": "OrderedReady", 19 | "replicas": 1, 20 | "revisionHistoryLimit": 10, 21 | "selector": { 22 | "matchLabels": { 23 | "name": "opencost" 24 | } 25 | }, 26 | "serviceName": "opencost", 27 | "template": { 28 | "metadata": { 29 | "creationTimestamp": null, 30 | "labels": { 31 | "name": "opencost" 32 | } 33 | }, 34 | "spec": { 35 | "affinity": { 36 | "nodeAffinity": { 37 | "preferredDuringSchedulingIgnoredDuringExecution": [ 38 | { 39 | "preference": { 40 | "matchExpressions": [ 41 | { 42 | "key": "cloud.google.com/gke-spot", 43 | "operator": "In", 44 | "values": [ 45 | "true" 46 | ] 47 | } 48 | ] 49 | }, 50 | "weight": 100 51 | } 52 | ] 53 | } 54 | }, 55 | "containers": [ 56 | { 57 | "env": [ 58 | ], 59 | "image": "quay.io/kubecost1/kubecost-cost-model:prod-1.100.0", 60 | "imagePullPolicy": "IfNotPresent", 61 | "name": "opencost", 62 | "ports": [ 63 | { 64 | "containerPort": 9003, 65 | "name": "http-metrics", 66 | "protocol": "TCP" 67 | } 68 | ], 69 | "resources": { 70 | "limits": { 71 | "cpu": "4", 72 | "memory": "8Gi" 73 | }, 74 | "requests": { 75 | "cpu": "1", 76 | "memory": "4Gi" 77 | } 78 | }, 79 | "terminationMessagePath": "/dev/termination-log", 80 | "terminationMessagePolicy": "File", 81 | "volumeMounts": [ 82 | { 83 | "mountPath": "/var/configs", 84 | "name": "opencost-data" 85 | } 86 | ] 87 | } 88 | ], 89 | "dnsPolicy": "ClusterFirst", 90 | "restartPolicy": "Always", 91 | "schedulerName": "default-scheduler", 92 | "securityContext": { 93 | "fsGroup": 10001 94 | }, 95 | "serviceAccount": "opencost", 96 | "serviceAccountName": "opencost", 97 | "terminationGracePeriodSeconds": 30, 98 | "tolerations": [ 99 | { 100 | "effect": "NoSchedule", 101 | "key": "type", 102 | "operator": "Equal", 103 | "value": "spot-node" 104 | } 105 | ] 106 | } 107 | }, 108 | "updateStrategy": { 109 | "type": "RollingUpdate" 110 | }, 111 | "volumeClaimTemplates": [ 112 | { 113 | "apiVersion": "v1", 114 | "kind": "PersistentVolumeClaim", 115 | "metadata": { 116 | "creationTimestamp": null, 117 | "name": "opencost-data" 118 | }, 119 | "spec": { 120 | "accessModes": [ 121 | "ReadWriteOnce" 122 | ], 123 | "resources": { 124 | "requests": { 125 | "storage": "320Gi" 126 | } 127 | }, 128 | "volumeMode": "Filesystem" 129 | }, 130 | "status": { 131 | "phase": "Pending" 132 | } 133 | } 134 | ] 135 | }, 136 | "status": { 137 | "availableReplicas": 1, 138 | "collisionCount": 0, 139 | "currentReplicas": 1, 140 | "currentRevision": "opencost-6666f8bdb7", 141 | "observedGeneration": 17, 142 | "readyReplicas": 1, 143 | "replicas": 1, 144 | "updateRevision": "opencost-6666f8bdb7", 145 | "updatedReplicas": 1 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /pkg/costmodel/testdata/resource/StatefulSet-with-2-containers.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "apps/v1", 3 | "kind": "StatefulSet", 4 | "metadata": { 5 | "creationTimestamp": "2022-10-05T20:38:31Z", 6 | "generation": 17, 7 | "labels": { 8 | "kustomize.toolkit.fluxcd.io/name": "kube-manifests-opencost", 9 | "kustomize.toolkit.fluxcd.io/namespace": "opencost", 10 | "tanka.dev/environment": "85ead74422d749cb54711e74c81bc5d6ed6da54e92b5fa69" 11 | }, 12 | "name": "opencost", 13 | "namespace": "opencost", 14 | "resourceVersion": "2386985939", 15 | "uid": "56495ef8-2650-46e8-9528-28759cf47151" 16 | }, 17 | "spec": { 18 | "podManagementPolicy": "OrderedReady", 19 | "replicas": 1, 20 | "revisionHistoryLimit": 10, 21 | "selector": { 22 | "matchLabels": { 23 | "name": "opencost" 24 | } 25 | }, 26 | "serviceName": "opencost", 27 | "template": { 28 | "metadata": { 29 | "creationTimestamp": null, 30 | "labels": { 31 | "name": "opencost" 32 | } 33 | }, 34 | "spec": { 35 | "affinity": { 36 | "nodeAffinity": { 37 | "preferredDuringSchedulingIgnoredDuringExecution": [ 38 | { 39 | "preference": { 40 | "matchExpressions": [ 41 | { 42 | "key": "cloud.google.com/gke-spot", 43 | "operator": "In", 44 | "values": [ 45 | "true" 46 | ] 47 | } 48 | ] 49 | }, 50 | "weight": 100 51 | } 52 | ] 53 | } 54 | }, 55 | "containers": [ 56 | { 57 | "env": [ 58 | ], 59 | "image": "quay.io/kubecost1/kubecost-cost-model:prod-1.100.0", 60 | "imagePullPolicy": "IfNotPresent", 61 | "name": "opencost", 62 | "ports": [ 63 | { 64 | "containerPort": 9003, 65 | "name": "http-metrics", 66 | "protocol": "TCP" 67 | } 68 | ], 69 | "resources": { 70 | "limits": { 71 | "cpu": "2500m", 72 | "memory": "8Gi" 73 | }, 74 | "requests": { 75 | "cpu": "1", 76 | "memory": "4Gi" 77 | } 78 | }, 79 | "terminationMessagePath": "/dev/termination-log", 80 | "terminationMessagePolicy": "File", 81 | "volumeMounts": [ 82 | { 83 | "mountPath": "/var/configs", 84 | "name": "opencost-data" 85 | } 86 | ] 87 | }, 88 | { 89 | "image": "quay.io/kubecost1/opencost-ui:prod-1.99.0", 90 | "imagePullPolicy": "IfNotPresent", 91 | "name": "opencost-ui", 92 | "ports": [ 93 | { 94 | "containerPort": 9090, 95 | "name": "http", 96 | "protocol": "TCP" 97 | } 98 | ], 99 | "resources": { 100 | "limits": { 101 | "cpu": "2", 102 | "memory": "1Gi" 103 | }, 104 | "requests": { 105 | "cpu": "10m", 106 | "memory": "55M" 107 | } 108 | }, 109 | "terminationMessagePath": "/dev/termination-log", 110 | "terminationMessagePolicy": "File" 111 | } 112 | ], 113 | "dnsPolicy": "ClusterFirst", 114 | "restartPolicy": "Always", 115 | "schedulerName": "default-scheduler", 116 | "securityContext": { 117 | "fsGroup": 10001 118 | }, 119 | "serviceAccount": "opencost", 120 | "serviceAccountName": "opencost", 121 | "terminationGracePeriodSeconds": 30, 122 | "tolerations": [ 123 | { 124 | "effect": "NoSchedule", 125 | "key": "type", 126 | "operator": "Equal", 127 | "value": "spot-node" 128 | } 129 | ] 130 | } 131 | }, 132 | "updateStrategy": { 133 | "type": "RollingUpdate" 134 | }, 135 | "volumeClaimTemplates": [ 136 | { 137 | "apiVersion": "v1", 138 | "kind": "PersistentVolumeClaim", 139 | "metadata": { 140 | "creationTimestamp": null, 141 | "name": "opencost-data" 142 | }, 143 | "spec": { 144 | "accessModes": [ 145 | "ReadWriteOnce" 146 | ], 147 | "resources": { 148 | "requests": { 149 | "storage": "32Gi" 150 | } 151 | }, 152 | "volumeMode": "Filesystem" 153 | }, 154 | "status": { 155 | "phase": "Pending" 156 | } 157 | } 158 | ] 159 | }, 160 | "status": { 161 | "availableReplicas": 1, 162 | "collisionCount": 0, 163 | "currentReplicas": 1, 164 | "currentRevision": "opencost-6666f8bdb7", 165 | "observedGeneration": 17, 166 | "readyReplicas": 1, 167 | "replicas": 1, 168 | "updateRevision": "opencost-6666f8bdb7", 169 | "updatedReplicas": 1 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /pkg/costmodel/testdata/resource/StatefulSet-without-replicas.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: StatefulSet 3 | metadata: 4 | labels: 5 | name: alertmanager 6 | tanka.dev/environment: 8d3cbfb926736c557b79412af9597422327058492909f6a0 7 | name: alertmanager 8 | namespace: alertmanager 9 | spec: 10 | selector: 11 | matchLabels: 12 | name: alertmanager 13 | serviceName: alertmanager 14 | template: 15 | metadata: 16 | labels: 17 | gossip_ring_member: "true" 18 | insights: "true" 19 | name: alertmanager 20 | spec: 21 | affinity: 22 | podAntiAffinity: 23 | requiredDuringSchedulingIgnoredDuringExecution: 24 | - labelSelector: 25 | matchLabels: 26 | name: alertmanager 27 | topologyKey: kubernetes.io/hostname 28 | containers: 29 | - args: 30 | - -admin.client.backend= 31 | - -alertmanager-storage.gcs.bucket-name=dev-us-central1-cortex-alertmanager 32 | - -alertmanager.configs.fallback=/configs/alertmanager_fallback_config.yaml 33 | - -alertmanager.max-config-size-bytes=102400 34 | - -alertmanager.max-template-size-bytes=51200 35 | - -alertmanager.max-templates-count=10 36 | - -alertmanager.receivers-firewall-block-private-addresses=true 37 | - -alertmanager.sharding-ring.replication-factor=3 38 | - -alertmanager.sharding-ring.store=memberlist 39 | - -alertmanager.storage.path=/data 40 | - -alertmanager.web.external-url=https://alertmanager-dev-us-central1.grafana-dev.net/alertmanager 41 | - -auth.type=trust 42 | - -common.storage.backend=gcs 43 | - -instrumentation.enabled=false 44 | - -log.level=debug 45 | - -memberlist.bind-port=7946 46 | - -memberlist.cluster-label=dev-us-central-0.alertmanager 47 | - -memberlist.join=dns+gossip-ring.alertmanager.svc.cluster.local:7946 48 | - -runtime-config.file=/etc/cortex/overrides.yaml 49 | - -server.grpc.keepalive.min-time-between-pings=10s 50 | - -server.grpc.keepalive.ping-without-stream-allowed=true 51 | - -server.http-listen-port=80 52 | - -target=alertmanager 53 | - -usage-stats.installation-mode=jsonnet 54 | env: 55 | - name: POD_IP 56 | valueFrom: 57 | fieldRef: 58 | fieldPath: status.podIP 59 | image: grafana/metrics-enterprise:r233-c2dd8abf 60 | imagePullPolicy: IfNotPresent 61 | name: alertmanager 62 | ports: 63 | - containerPort: 80 64 | name: http-metrics 65 | - containerPort: 9095 66 | name: grpc 67 | - containerPort: 7946 68 | name: gossip-ring 69 | readinessProbe: 70 | httpGet: 71 | path: /ready 72 | port: 80 73 | initialDelaySeconds: 15 74 | timeoutSeconds: 1 75 | resources: 76 | limits: 77 | memory: 15Gi 78 | requests: 79 | cpu: 200m 80 | memory: 1Gi 81 | volumeMounts: 82 | - mountPath: /data 83 | name: alertmanager-data 84 | - mountPath: /configs 85 | name: alertmanager-fallback-config 86 | - mountPath: /etc/cortex 87 | name: overrides 88 | securityContext: 89 | runAsUser: 0 90 | terminationGracePeriodSeconds: 900 91 | volumes: 92 | - configMap: 93 | name: overrides 94 | name: overrides 95 | - configMap: 96 | name: alertmanager-fallback-config 97 | name: alertmanager-fallback-config 98 | updateStrategy: 99 | type: RollingUpdate 100 | volumeClaimTemplates: 101 | - apiVersion: v1 102 | kind: PersistentVolumeClaim 103 | metadata: 104 | name: alertmanager-data 105 | spec: 106 | accessModes: 107 | - ReadWriteOnce 108 | resources: 109 | requests: 110 | storage: 100Gi 111 | -------------------------------------------------------------------------------- /pkg/costmodel/testdata/resource/StatefulSet.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "apps/v1", 3 | "kind": "StatefulSet", 4 | "metadata": { 5 | "creationTimestamp": "2022-10-05T20:38:31Z", 6 | "generation": 17, 7 | "labels": { 8 | "kustomize.toolkit.fluxcd.io/name": "kube-manifests-opencost", 9 | "kustomize.toolkit.fluxcd.io/namespace": "opencost", 10 | "tanka.dev/environment": "85ead74422d749cb54711e74c81bc5d6ed6da54e92b5fa69" 11 | }, 12 | "name": "opencost", 13 | "namespace": "opencost", 14 | "resourceVersion": "2386985939", 15 | "uid": "56495ef8-2650-46e8-9528-28759cf47151" 16 | }, 17 | "spec": { 18 | "podManagementPolicy": "OrderedReady", 19 | "replicas": 1, 20 | "revisionHistoryLimit": 10, 21 | "selector": { 22 | "matchLabels": { 23 | "name": "opencost" 24 | } 25 | }, 26 | "serviceName": "opencost", 27 | "template": { 28 | "metadata": { 29 | "creationTimestamp": null, 30 | "labels": { 31 | "name": "opencost" 32 | } 33 | }, 34 | "spec": { 35 | "affinity": { 36 | "nodeAffinity": { 37 | "preferredDuringSchedulingIgnoredDuringExecution": [ 38 | { 39 | "preference": { 40 | "matchExpressions": [ 41 | { 42 | "key": "cloud.google.com/gke-spot", 43 | "operator": "In", 44 | "values": [ 45 | "true" 46 | ] 47 | } 48 | ] 49 | }, 50 | "weight": 100 51 | } 52 | ] 53 | } 54 | }, 55 | "containers": [ 56 | { 57 | "env": [ 58 | ], 59 | "image": "quay.io/kubecost1/kubecost-cost-model:prod-1.100.0", 60 | "imagePullPolicy": "IfNotPresent", 61 | "name": "opencost", 62 | "ports": [ 63 | { 64 | "containerPort": 9003, 65 | "name": "http-metrics", 66 | "protocol": "TCP" 67 | } 68 | ], 69 | "resources": { 70 | "limits": { 71 | "cpu": "4", 72 | "memory": "8Gi" 73 | }, 74 | "requests": { 75 | "cpu": "1", 76 | "memory": "4Gi" 77 | } 78 | }, 79 | "terminationMessagePath": "/dev/termination-log", 80 | "terminationMessagePolicy": "File", 81 | "volumeMounts": [ 82 | { 83 | "mountPath": "/var/configs", 84 | "name": "opencost-data" 85 | } 86 | ] 87 | } 88 | ], 89 | "dnsPolicy": "ClusterFirst", 90 | "restartPolicy": "Always", 91 | "schedulerName": "default-scheduler", 92 | "securityContext": { 93 | "fsGroup": 10001 94 | }, 95 | "serviceAccount": "opencost", 96 | "serviceAccountName": "opencost", 97 | "terminationGracePeriodSeconds": 30, 98 | "tolerations": [ 99 | { 100 | "effect": "NoSchedule", 101 | "key": "type", 102 | "operator": "Equal", 103 | "value": "spot-node" 104 | } 105 | ] 106 | } 107 | }, 108 | "updateStrategy": { 109 | "type": "RollingUpdate" 110 | }, 111 | "volumeClaimTemplates": [ 112 | { 113 | "apiVersion": "v1", 114 | "kind": "PersistentVolumeClaim", 115 | "metadata": { 116 | "creationTimestamp": null, 117 | "name": "opencost-data" 118 | }, 119 | "spec": { 120 | "accessModes": [ 121 | "ReadWriteOnce" 122 | ], 123 | "resources": { 124 | "requests": { 125 | "storage": "32Gi" 126 | } 127 | }, 128 | "volumeMode": "Filesystem" 129 | }, 130 | "status": { 131 | "phase": "Pending" 132 | } 133 | } 134 | ] 135 | }, 136 | "status": { 137 | "availableReplicas": 1, 138 | "collisionCount": 0, 139 | "currentReplicas": 1, 140 | "currentRevision": "opencost-6666f8bdb7", 141 | "observedGeneration": 17, 142 | "readyReplicas": 1, 143 | "replicas": 1, 144 | "updateRevision": "opencost-6666f8bdb7", 145 | "updatedReplicas": 1 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /pkg/costmodel/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "math" 5 | ) 6 | 7 | func BytesToGiB(bytes int64) float64 { 8 | gb := float64(bytes) / (math.Pow(2, 30)) 9 | return gb 10 | } 11 | -------------------------------------------------------------------------------- /pkg/costmodel/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBytesToGiB(t *testing.T) { 8 | type args struct { 9 | bytes int64 10 | } 11 | tests := []struct { 12 | name string 13 | args args 14 | want float64 15 | }{ 16 | { 17 | name: "0 bytes", 18 | args: args{ 19 | bytes: 0, 20 | }, 21 | want: 0, 22 | }, 23 | { 24 | name: "1 GiB", 25 | args: args{ 26 | bytes: 1073741824, 27 | }, 28 | want: 1, 29 | }, 30 | { 31 | name: "2 GiB", 32 | args: args{ 33 | bytes: 2147483648, 34 | }, 35 | want: 2, 36 | }, 37 | { 38 | name: "Half a GiB", 39 | args: args{ 40 | bytes: 536870912, 41 | }, 42 | want: 0.5, 43 | }, 44 | } 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | if got := BytesToGiB(tt.args.bytes); got != tt.want { 48 | t.Errorf("BytesToGiB() = %v, want %v", got, tt.want) 49 | } 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/git/repository.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "os/exec" 10 | "strings" 11 | ) 12 | 13 | type Repository struct { 14 | wd string 15 | } 16 | 17 | func NewRepository(path string) Repository { 18 | return Repository{wd: path} 19 | } 20 | 21 | type ChangedFiles struct { 22 | Added []string 23 | Modified []string 24 | Deleted []string 25 | Renamed map[string]string 26 | } 27 | 28 | func (r Repository) git(ctx context.Context, args ...string) ([]byte, error) { 29 | cmd := exec.CommandContext(ctx, "git", append([]string{"-C", r.wd}, args...)...) 30 | 31 | out, err := cmd.Output() 32 | if err != nil { 33 | var ee *exec.ExitError 34 | if errors.As(err, &ee) { 35 | return nil, fmt.Errorf("running %v: %w\n%s", cmd, ee, ee.Stderr) 36 | } 37 | return nil, fmt.Errorf("running %v: %w", cmd, err) 38 | } 39 | 40 | return out, nil 41 | } 42 | 43 | func (r Repository) GetCommit(ctx context.Context, ref string) (string, error) { 44 | head, err := r.git(ctx, "rev-parse", ref) 45 | if err != nil { 46 | return "", err 47 | } 48 | 49 | return strings.TrimSpace(string(head)), nil 50 | } 51 | 52 | func (r Repository) ChangedFiles(ctx context.Context, oldCommit string, newCommit string) (ChangedFiles, error) { 53 | cf := ChangedFiles{ 54 | Renamed: make(map[string]string), 55 | } 56 | 57 | out, err := r.git(ctx, "diff", "--name-status", oldCommit, newCommit) 58 | if err != nil { 59 | return cf, err 60 | } 61 | 62 | for _, line := range toLines(out) { 63 | l := strings.Fields(line) 64 | switch l[0] { 65 | case "A": 66 | cf.Added = append(cf.Added, l[1]) 67 | case "M": 68 | cf.Modified = append(cf.Modified, l[1]) 69 | case "D": 70 | cf.Deleted = append(cf.Deleted, l[1]) 71 | default: 72 | if l[0][0] == 'R' { // it's a rename 73 | cf.Renamed[l[1]] = l[2] 74 | } 75 | // TODO(inkel) ignore for now 76 | } 77 | } 78 | 79 | return cf, nil 80 | } 81 | 82 | func (r Repository) Contents(ctx context.Context, head, path string) ([]byte, error) { 83 | return r.git(ctx, "cat-file", "blob", head+":"+path) 84 | } 85 | 86 | func toLines(b []byte) []string { 87 | var lines []string 88 | 89 | s := bufio.NewScanner(bytes.NewBuffer(b)) 90 | 91 | for s.Scan() { 92 | lines = append(lines, s.Text()) 93 | } 94 | 95 | if err := s.Err(); err != nil { 96 | // TODO(inkel) panicking is probably not the best here 97 | panic(err) 98 | } 99 | 100 | return lines 101 | } 102 | -------------------------------------------------------------------------------- /pkg/github/client.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "log/slog" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/bradleyfalzon/ghinstallation/v2" 12 | "github.com/google/go-github/v50/github" 13 | "github.com/gregjones/httpcache" 14 | "github.com/shurcooL/githubv4" 15 | "golang.org/x/oauth2" 16 | ) 17 | 18 | type Client struct { 19 | // GitHub REST API. 20 | c *github.Client 21 | 22 | // GitHub GraphQL API client. 23 | // Used to hide old comments. 24 | // 25 | // TODO I've been looking at the GraphQL API and it seems it could 26 | // be possible to use that to retrieve the comments and changed 27 | // files, however, due to the time-limitation of the hackathon we 28 | // won't explore that option. 29 | g *githubv4.Client 30 | } 31 | 32 | func NewClient(ctx context.Context, cfg Config) (Client, error) { 33 | var token = cfg.Token 34 | 35 | if cfg.AppID > 0 { 36 | slog.Info("Using GitHub app authentication") 37 | t, err := ghinstallation.NewAppsTransport( 38 | httpcache.NewMemoryCacheTransport(), 39 | cfg.AppID, 40 | []byte(cfg.AppPrivateKey), 41 | ) 42 | if err != nil { 43 | return Client{}, fmt.Errorf("creating GitHub application installation transport: %w", err) 44 | } 45 | 46 | c := github.NewClient(&http.Client{Transport: t}) 47 | 48 | tok, res, err := c.Apps.CreateInstallationToken(ctx, cfg.AppInstallationID, nil) 49 | if err != nil { 50 | return Client{}, fmt.Errorf("getting GitHub application installation token: %w", err) 51 | } 52 | if res.StatusCode >= 400 { 53 | return Client{}, fmt.Errorf("unexpected status code from GitHub: %s", res.Status) 54 | } 55 | 56 | token = tok.GetToken() 57 | } else { 58 | slog.Info("Using GitHub token authentication") 59 | } 60 | 61 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 62 | c := oauth2.NewClient(ctx, ts) 63 | 64 | return Client{ 65 | c: github.NewClient(c), 66 | g: githubv4.NewClient(c), 67 | }, nil 68 | } 69 | 70 | func (c Client) Comment(ctx context.Context, org, repo string, nr int, comment string) error { 71 | _, _, err := c.c.Issues.CreateComment(ctx, org, repo, nr, &github.IssueComment{ 72 | Body: github.String(comment), 73 | }) 74 | return err 75 | } 76 | 77 | func (c Client) HideCommentsWithPrefix(ctx context.Context, org, repo string, nr int, prefix string) error { 78 | opts := &github.IssueListCommentsOptions{ 79 | ListOptions: github.ListOptions{ 80 | PerPage: 100, 81 | }, 82 | } 83 | 84 | for { 85 | cs, res, err := c.c.Issues.ListComments(ctx, org, repo, nr, opts) 86 | if err != nil { 87 | return fmt.Errorf("retrieving PR comments: %w", err) 88 | } 89 | 90 | for _, cm := range cs { 91 | if !strings.HasPrefix(cm.GetBody(), prefix) { 92 | continue 93 | } 94 | 95 | // hide comment 96 | var m struct { 97 | MinimizeComment struct { 98 | MinimizedComment struct { 99 | IsMinimized githubv4.Boolean 100 | } 101 | } `graphql:"minimizeComment(input: $input)"` 102 | } 103 | 104 | i := githubv4.MinimizeCommentInput{ 105 | SubjectID: cm.GetNodeID(), 106 | Classifier: githubv4.ReportedContentClassifiersOutdated, 107 | } 108 | 109 | if err := c.g.Mutate(ctx, &m, i, nil); err != nil { 110 | // TODO don't fail here, just handle it better 111 | log.Printf("hiding comment %v: %v", cm.GetHTMLURL(), err) 112 | } 113 | } 114 | 115 | if res.NextPage == 0 { 116 | break 117 | } 118 | opts.Page = res.NextPage 119 | } 120 | 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /pkg/github/config.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | ErrMissingAuth = errors.New("missing GitHub authentication environment variables") 10 | 11 | ErrInvalidAuth = errors.New("invalid GitHub authentication") 12 | ) 13 | 14 | type Config struct { 15 | Token string `envconfig:"GITHUB_TOKEN"` 16 | 17 | AppPrivateKey string `envconfig:"GITHUB_APP_PRIVATE_KEY"` 18 | AppID int64 `envconfig:"GITHUB_APP_ID"` 19 | AppInstallationID int64 `envconfig:"GITHUB_APP_INSTALLATION_ID"` 20 | 21 | Owner string `envconfig:"GITHUB_REPOSITORY_OWNER" default:"grafana"` 22 | Repo string `envconfig:"GITHUB_REPOSITORY_NAME" default:"deployment_tools"` 23 | } 24 | 25 | func (c Config) Validate() error { 26 | if c.Token != "" && c.AppID != 0 { 27 | return fmt.Errorf("%w: only one of token or application configuration are valid", ErrInvalidAuth) 28 | } 29 | if c.Token == "" && c.AppID == 0 { 30 | return fmt.Errorf("%w: missing token or application configuration", ErrMissingAuth) 31 | } 32 | 33 | if c.AppID > 0 && (c.AppPrivateKey == "" || c.AppInstallationID == 0) { 34 | return fmt.Errorf("%w: incomplete application configuration", ErrInvalidAuth) 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/github/config_test.go: -------------------------------------------------------------------------------- 1 | package github_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/kelseyhightower/envconfig" 8 | 9 | "github.com/grafana/kost/pkg/github" 10 | ) 11 | 12 | func TestConfigValidate(t *testing.T) { 13 | // clear GitHub environment variables 14 | // keys := []string{"GITHUB_TOKEN", "GITHUB_APP_PRIVATE_KEY", "GITHUB_APP_ID", "GITHUB_APP_INSTALLATION_ID", "GITHUB_REPOSITORY_OWNER", "GITHUB_REPOSITORY_NAME"} 15 | 16 | tests := map[string]struct { 17 | env map[string]string 18 | err error 19 | }{ 20 | "missing authentication": { 21 | map[string]string{}, github.ErrMissingAuth, 22 | }, 23 | 24 | "mixed authentications": { 25 | map[string]string{ 26 | "GITHUB_TOKEN": "abc123", 27 | "GITHUB_APP_ID": "1024", 28 | }, 29 | github.ErrInvalidAuth, 30 | }, 31 | 32 | "missing application private key": { 33 | map[string]string{ 34 | "GITHUB_APP_ID": "1024", 35 | "GITHUB_APP_INSTALLATION_ID": "2048", 36 | }, 37 | github.ErrInvalidAuth, 38 | }, 39 | 40 | "missing application installation ID": { 41 | map[string]string{ 42 | "GITHUB_APP_ID": "1024", 43 | "GITHUB_APP_PRIVATE_KEY": "def456", 44 | }, 45 | github.ErrInvalidAuth, 46 | }, 47 | 48 | "valid PAT": { 49 | map[string]string{ 50 | "GITHUB_TOKEN": "abc123", 51 | }, 52 | nil, 53 | }, 54 | 55 | "valid application configuration": { 56 | map[string]string{ 57 | "GITHUB_APP_ID": "1024", 58 | "GITHUB_APP_PRIVATE_KEY": "def456", 59 | "GITHUB_APP_INSTALLATION_ID": "2048", 60 | }, 61 | nil, 62 | }, 63 | } 64 | 65 | for n, tt := range tests { 66 | t.Run(n, func(t *testing.T) { 67 | for k, v := range tt.env { 68 | t.Setenv(k, v) 69 | } 70 | 71 | cfg := github.Config{} 72 | 73 | if err := envconfig.Process("", &cfg); err != nil { 74 | t.Fatalf("unexpected error: %v", err) 75 | } 76 | 77 | err := cfg.Validate() 78 | 79 | if !errors.Is(err, tt.err) { 80 | t.Fatalf("expecting validation to fail with %v, got %v", tt.err, err) 81 | } 82 | 83 | t.Log("validation error message:", err) 84 | }) 85 | } 86 | } 87 | --------------------------------------------------------------------------------