├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── builder_daemonset.go ├── builder_daemonset_test.go ├── builder_deployment.go ├── builder_deployment_test.go ├── builder_hpa.go ├── builder_hpa_test.go ├── builder_replicaset.go ├── builder_replicaset_test.go ├── builder_statefulset.go ├── builder_statefulset_test.go ├── builder_volumeclaim.go ├── builder_volumeclaim_test.go ├── config.go ├── config_test.go ├── cost.go ├── k8s_decoder.go ├── manifests.go ├── manifests_test.go ├── resource_price.go ├── resource_price_test.go ├── testdata │ ├── hpa │ │ ├── hpa-v1.yaml │ │ ├── hpa-v2beta1.yaml │ │ └── hpa-v2beta2.yaml │ └── manifests │ │ ├── nginx.yaml │ │ └── test.yaml ├── types.go └── types_test.go ├── go.mod ├── go.sum ├── main.go ├── samples ├── k8s-cost-estimator-github │ ├── .gitignore │ ├── defaults-conf.yaml │ ├── templates │ │ └── cloudbuild.yaml.tpl │ └── wordpress │ │ ├── fluentd-elasticsearch-deamonset.yaml │ │ ├── mysql-statefulset.yaml │ │ ├── wordpress_deployment.yaml │ │ ├── wordpress_hpa.yaml │ │ ├── wordpress_service.yaml │ │ └── wordpress_volumeclaim.yaml ├── k8s-cost-estimator-gitlab │ ├── .gitignore │ ├── defaults-conf.yaml │ ├── templates │ │ ├── .gitlab-ci.yml.tpl │ │ └── gitlab-runner-values.yaml.tpl │ └── wordpress │ │ ├── fluentd-elasticsearch-deamonset.yaml │ │ ├── mysql-statefulset.yaml │ │ ├── wordpress_deployment.yaml │ │ ├── wordpress_hpa.yaml │ │ ├── wordpress_service.yaml │ │ └── wordpress_volumeclaim.yaml └── k8s-cost-estimator-local │ ├── app-v1 │ ├── deamonset.yaml │ ├── deployment-with-hpa.yaml │ ├── deployment.yaml │ ├── pvc.yaml │ └── statefulset.yaml │ ├── app-v2 │ ├── deamonset.yaml │ ├── deployment-with-hpa.yaml │ ├── deployment.yaml │ ├── pvc.yaml │ └── statefulset.yaml │ └── example-conf.yaml └── util └── slice.go /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | ssh/ 3 | credentials.json 4 | github-output.json 5 | github-output.diff 6 | gitlab-output.json 7 | gitlab-output.diff 8 | markdown-output.md -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code Reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google/conduct/). -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM golang:1.15.1-alpine3.12 AS builder 16 | WORKDIR /app 17 | # Install dependencies in go.mod and go.sum 18 | COPY go.mod go.sum ./ 19 | RUN go mod download 20 | # Copy rest of the application source code 21 | COPY . ./ 22 | # Compile the application 23 | RUN go build -mod=readonly -v -o /k8s-cost-estimator 24 | 25 | 26 | FROM alpine:3.12 27 | WORKDIR /app 28 | # Install utilities needed durin ci/cd process 29 | RUN apk update && apk upgrade && \ 30 | apk add --no-cache bash git curl jq && \ 31 | rm /var/cache/apk/* 32 | # copy applicatrion binary 33 | COPY --from=builder /k8s-cost-estimator /usr/local/bin/k8s-cost-estimator 34 | #ENTRYPOINT ["k8s-cost-estimator"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archived 2 | 3 | This repository has been archived. Please see the 4 | [Google Kubernetes Engine (GKE) Samples repository](https://github.com/GoogleCloudPlatform/kubernetes-engine-samples/tree/main/cost-optimization/gke-shift-left-cost) 5 | instead. 6 | 7 | ## Disclaimer 8 | 9 | This is not an officially supported Google product. 10 | -------------------------------------------------------------------------------- /api/builder_daemonset.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | 20 | appsV1 "k8s.io/api/apps/v1" 21 | ) 22 | 23 | //decodeDaemonSet reads k8s DaemonSet yaml and trasform to DaemonSet object - mostly used by unit tests 24 | func decodeDaemonSet(data []byte, conf CostimatorConfig) (DaemonSet, error) { 25 | obj, groupVersionKind, err := decode(data) 26 | if err != nil { 27 | return DaemonSet{}, fmt.Errorf("Error Decoding. Check if your GroupVersionKind is defined in api/k8s_decoder.go. Root cause %+v", err) 28 | } 29 | return buildDaemonSet(obj, groupVersionKind, conf) 30 | } 31 | 32 | //buildDaemonSet reads k8s DaemonSet object and trasform to DaemonSet object 33 | func buildDaemonSet(obj interface{}, groupVersionKind GroupVersionKind, conf CostimatorConfig) (DaemonSet, error) { 34 | switch obj.(type) { 35 | default: 36 | return DaemonSet{}, fmt.Errorf("APIVersion and Kind not Implemented: %+v", groupVersionKind) 37 | case *appsV1.DaemonSet: 38 | return buildDaemonSetV1(obj.(*appsV1.DaemonSet), conf), nil 39 | } 40 | } 41 | 42 | func buildDaemonSetV1(deploy *appsV1.DaemonSet, conf CostimatorConfig) DaemonSet { 43 | conf = populateConfigNotProvided(conf) 44 | containers := buildContainers(deploy.Spec.Template.Spec.Containers, conf) 45 | return DaemonSet{ 46 | APIVersionKindName: buildAPIVersionKindName(deploy.APIVersion, deploy.Kind, deploy.GetNamespace(), deploy.GetName()), 47 | NodesCount: conf.ClusterConf.NodesCount, 48 | Containers: containers, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /api/builder_deployment.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | 20 | appsV1 "k8s.io/api/apps/v1" 21 | ) 22 | 23 | //decodeDeployment reads k8s deployment yaml and trasform to Deployment object - mainly used by tests 24 | func decodeDeployment(data []byte, conf CostimatorConfig) (Deployment, error) { 25 | obj, groupVersionKind, err := decode(data) 26 | if err != nil { 27 | return Deployment{}, fmt.Errorf("Error Decoding. Check if your GroupVersionKind is defined in api/k8s_decoder.go. Root cause %+v", err) 28 | } 29 | return buildDeployment(obj, groupVersionKind, conf) 30 | } 31 | 32 | //buildDeployment reads k8s deployment object and trasform to Deployment object 33 | func buildDeployment(obj interface{}, groupVersionKind GroupVersionKind, conf CostimatorConfig) (Deployment, error) { 34 | switch obj.(type) { 35 | default: 36 | return Deployment{}, fmt.Errorf("APIVersion and Kind not Implemented: %+v", groupVersionKind) 37 | case *appsV1.Deployment: 38 | return buildDeploymentV1(obj.(*appsV1.Deployment), conf), nil 39 | } 40 | } 41 | 42 | func buildDeploymentV1(deploy *appsV1.Deployment, conf CostimatorConfig) Deployment { 43 | conf = populateConfigNotProvided(conf) 44 | containers := buildContainers(deploy.Spec.Template.Spec.Containers, conf) 45 | var replicas int32 = 1 46 | if deploy.Spec.Replicas != (*int32)(nil) { 47 | replicas = *deploy.Spec.Replicas 48 | } 49 | return Deployment{ 50 | APIVersionKindName: buildAPIVersionKindName(deploy.APIVersion, deploy.Kind, deploy.GetNamespace(), deploy.GetName()), 51 | Replicas: replicas, 52 | Containers: containers, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /api/builder_deployment_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | "strings" 20 | "testing" 21 | ) 22 | 23 | func TestDeploymentAPINotImplemented(t *testing.T) { 24 | yaml := ` 25 | apiVersion: apps/v1222 26 | kind: Deployment 27 | metadata: 28 | name: my-nginx 29 | spec: 30 | replicas: 4 31 | template: 32 | metadata: 33 | labels: 34 | run: my-nginx 35 | spec: 36 | containers: 37 | - name: my-nginx 38 | image: nginx 39 | ports: 40 | - containerPort: 80 41 | resources: 42 | requests: 43 | memory: "64Mi" 44 | cpu: "250m" 45 | limits: 46 | memory: "64M" 47 | cpu: 1` 48 | 49 | _, err := decodeDeployment([]byte(yaml), CostimatorConfig{}) 50 | if err == nil || !strings.HasPrefix(err.Error(), "Error Decoding.") { 51 | t.Error(fmt.Errorf("Should have return an APIVersion error, but returned '%+v'", err)) 52 | } 53 | } 54 | 55 | func TestDeploymentBasicV1(t *testing.T) { 56 | yaml := ` 57 | apiVersion: apps/v1 58 | kind: Deployment 59 | metadata: 60 | name: my-nginx 61 | spec: 62 | replicas: 4 63 | template: 64 | metadata: 65 | labels: 66 | run: my-nginx 67 | spec: 68 | containers: 69 | - name: my-nginx 70 | image: nginx 71 | ports: 72 | - containerPort: 80 73 | resources: 74 | requests: 75 | memory: "64Mi" 76 | cpu: "250m" 77 | limits: 78 | memory: "64M" 79 | cpu: 1` 80 | 81 | deploy, err := decodeDeployment([]byte(yaml), CostimatorConfig{}) 82 | if err != nil { 83 | t.Error(err) 84 | return 85 | } 86 | 87 | expectedAPIVersionKindName := "apps/v1|Deployment|default|my-nginx" 88 | if got := deploy.APIVersionKindName; got != expectedAPIVersionKindName { 89 | t.Errorf("Expected APIVersionKindName %+v, got %+v", expectedAPIVersionKindName, got) 90 | } 91 | 92 | expectedKindName := "|Deployment|default|my-nginx" 93 | if got := deploy.getKindName(); got != expectedKindName { 94 | t.Errorf("Expected KindName %+v, got %+v", expectedKindName, got) 95 | } 96 | 97 | expected := int32(4) 98 | if got := deploy.Replicas; got != expected { 99 | t.Errorf("Expected Replicas %+v, got %+v", expected, got) 100 | } 101 | 102 | expectedRequestsCPU := int64(250) 103 | expectedRequestsMemory := int64(67108864) 104 | container := deploy.Containers[0] 105 | requests := container.Requests 106 | if requests.CPU != expectedRequestsCPU { 107 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 108 | } 109 | if requests.Memory != expectedRequestsMemory { 110 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 111 | } 112 | 113 | expectedLimitsCPU := int64(1000) 114 | expectedLimitsMemory := int64(64000000) 115 | limits := container.Limits 116 | if limits.CPU != expectedLimitsCPU { 117 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 118 | } 119 | if limits.Memory != expectedLimitsMemory { 120 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 121 | } 122 | 123 | } 124 | 125 | func TestDeploymentBasicV1beta1(t *testing.T) { 126 | yaml := ` 127 | apiVersion: apps/v1beta1 128 | kind: Deployment 129 | metadata: 130 | name: my-nginx 131 | spec: 132 | replicas: 4 133 | template: 134 | metadata: 135 | labels: 136 | run: my-nginx 137 | spec: 138 | containers: 139 | - name: my-nginx 140 | image: nginx 141 | ports: 142 | - containerPort: 80 143 | resources: 144 | requests: 145 | memory: "64Mi" 146 | cpu: "250m" 147 | limits: 148 | memory: "64M" 149 | cpu: 1` 150 | 151 | deploy, err := decodeDeployment([]byte(yaml), CostimatorConfig{}) 152 | if err != nil { 153 | t.Error(err) 154 | return 155 | } 156 | 157 | expectedAPIVersionKindName := "apps/v1beta1|Deployment|default|my-nginx" 158 | if got := deploy.APIVersionKindName; got != expectedAPIVersionKindName { 159 | t.Errorf("Expected APIVersionKindName %+v, got %+v", expectedAPIVersionKindName, got) 160 | } 161 | 162 | expectedKindName := "|Deployment|default|my-nginx" 163 | if got := deploy.getKindName(); got != expectedKindName { 164 | t.Errorf("Expected KindName %+v, got %+v", expectedKindName, got) 165 | } 166 | 167 | expected := int32(4) 168 | if got := deploy.Replicas; got != expected { 169 | t.Errorf("Expected Replicas %+v, got %+v", expected, got) 170 | } 171 | 172 | expectedRequestsCPU := int64(250) 173 | expectedRequestsMemory := int64(67108864) 174 | container := deploy.Containers[0] 175 | requests := container.Requests 176 | if requests.CPU != expectedRequestsCPU { 177 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 178 | } 179 | if requests.Memory != expectedRequestsMemory { 180 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 181 | } 182 | 183 | expectedLimitsCPU := int64(1000) 184 | expectedLimitsMemory := int64(64000000) 185 | limits := container.Limits 186 | if limits.CPU != expectedLimitsCPU { 187 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 188 | } 189 | if limits.Memory != expectedLimitsMemory { 190 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 191 | } 192 | 193 | } 194 | 195 | func TestDeploymentBasicV1beta2(t *testing.T) { 196 | yaml := ` 197 | apiVersion: apps/v1beta2 198 | kind: Deployment 199 | metadata: 200 | name: my-nginx 201 | spec: 202 | replicas: 4 203 | template: 204 | metadata: 205 | labels: 206 | run: my-nginx 207 | spec: 208 | containers: 209 | - name: my-nginx 210 | image: nginx 211 | ports: 212 | - containerPort: 80 213 | resources: 214 | requests: 215 | memory: "64Mi" 216 | cpu: "250m" 217 | limits: 218 | memory: "64M" 219 | cpu: 1` 220 | 221 | deploy, err := decodeDeployment([]byte(yaml), CostimatorConfig{}) 222 | if err != nil { 223 | t.Error(err) 224 | return 225 | } 226 | 227 | expectedAPIVersionKindName := "apps/v1beta2|Deployment|default|my-nginx" 228 | if got := deploy.APIVersionKindName; got != expectedAPIVersionKindName { 229 | t.Errorf("Expected APIVersionKindName %+v, got %+v", expectedAPIVersionKindName, got) 230 | } 231 | 232 | expectedKindName := "|Deployment|default|my-nginx" 233 | if got := deploy.getKindName(); got != expectedKindName { 234 | t.Errorf("Expected KindName %+v, got %+v", expectedKindName, got) 235 | } 236 | 237 | expected := int32(4) 238 | if got := deploy.Replicas; got != expected { 239 | t.Errorf("Expected Replicas %+v, got %+v", expected, got) 240 | } 241 | 242 | expectedRequestsCPU := int64(250) 243 | expectedRequestsMemory := int64(67108864) 244 | container := deploy.Containers[0] 245 | requests := container.Requests 246 | if requests.CPU != expectedRequestsCPU { 247 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 248 | } 249 | if requests.Memory != expectedRequestsMemory { 250 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 251 | } 252 | 253 | expectedLimitsCPU := int64(1000) 254 | expectedLimitsMemory := int64(64000000) 255 | limits := container.Limits 256 | if limits.CPU != expectedLimitsCPU { 257 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 258 | } 259 | if limits.Memory != expectedLimitsMemory { 260 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 261 | } 262 | 263 | } 264 | 265 | func TestDeploymentNoReplicas(t *testing.T) { 266 | yaml := ` 267 | apiVersion: apps/v1beta1 268 | kind: Deployment 269 | metadata: 270 | name: my-nginx 271 | spec: 272 | template: 273 | metadata: 274 | labels: 275 | run: my-nginx 276 | spec: 277 | containers: 278 | - name: my-nginx 279 | image: nginx 280 | ports: 281 | - containerPort: 80` 282 | 283 | deploy, err := decodeDeployment([]byte(yaml), CostimatorConfig{}) 284 | if err != nil { 285 | t.Error(err) 286 | return 287 | } 288 | 289 | if got := deploy.Replicas; got != 1 { 290 | t.Errorf("Expected 1 Replicas, got %+v", got) 291 | } 292 | } 293 | 294 | func TestDeploymentNoResources(t *testing.T) { 295 | yaml := ` 296 | apiVersion: apps/v1 297 | kind: Deployment 298 | metadata: 299 | name: my-nginx 300 | spec: 301 | replicas: 2 302 | template: 303 | metadata: 304 | labels: 305 | run: my-nginx 306 | spec: 307 | containers: 308 | - name: my-nginx 309 | image: nginx 310 | ports: 311 | - containerPort: 80` 312 | 313 | deploy, err := decodeDeployment([]byte(yaml), CostimatorConfig{}) 314 | if err != nil { 315 | t.Error(err) 316 | return 317 | } 318 | 319 | expectedKey := "apps/v1|Deployment|default|my-nginx" 320 | if got := deploy.APIVersionKindName; got != expectedKey { 321 | t.Errorf("Expected Key %+v, got %+v", expectedKey, got) 322 | } 323 | 324 | expectedReplicas := int32(2) 325 | if got := deploy.Replicas; got != expectedReplicas { 326 | t.Errorf("Expected Replicas %+v, got %+v", expectedReplicas, got) 327 | } 328 | 329 | container := deploy.Containers[0] 330 | defaults := ConfigDefaults() 331 | 332 | expectedRequestsCPU := defaults.ResourceConf.DefaultCPUinMillis 333 | expectedRequestsMemory := defaults.ResourceConf.DefaultMemoryinBytes 334 | requests := container.Requests 335 | if requests.CPU != expectedRequestsCPU { 336 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 337 | } 338 | if requests.Memory != expectedRequestsMemory { 339 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 340 | } 341 | 342 | expectedLimitsCPU := defaults.ResourceConf.DefaultCPUinMillis * 3 343 | expectedLimitsMemory := defaults.ResourceConf.DefaultMemoryinBytes * 3 344 | limits := container.Limits 345 | if limits.CPU != expectedLimitsCPU { 346 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 347 | } 348 | if limits.Memory != expectedLimitsMemory { 349 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 350 | } 351 | } 352 | 353 | func TestDeploymentNoLimits(t *testing.T) { 354 | yaml := ` 355 | apiVersion: apps/v1 356 | kind: Deployment 357 | metadata: 358 | name: my-nginx 359 | spec: 360 | replicas: 2 361 | template: 362 | metadata: 363 | labels: 364 | run: my-nginx 365 | spec: 366 | containers: 367 | - name: my-nginx 368 | image: nginx 369 | ports: 370 | - containerPort: 80 371 | resources: 372 | requests: 373 | memory: "64M" 374 | cpu: "500m"` 375 | 376 | deploy, err := decodeDeployment([]byte(yaml), CostimatorConfig{}) 377 | if err != nil { 378 | t.Error(err) 379 | return 380 | } 381 | 382 | expectedKey := "apps/v1|Deployment|default|my-nginx" 383 | if got := deploy.APIVersionKindName; got != expectedKey { 384 | t.Errorf("Expected Key %+v, got %+v", expectedKey, got) 385 | } 386 | 387 | expectedReplicas := int32(2) 388 | if got := deploy.Replicas; got != expectedReplicas { 389 | t.Errorf("Expected Replicas %+v, got %+v", expectedReplicas, got) 390 | } 391 | 392 | container := deploy.Containers[0] 393 | 394 | expectedRequestsCPU := int64(500) 395 | expectedRequestsMemory := int64(64000000) 396 | requests := container.Requests 397 | if requests.CPU != expectedRequestsCPU { 398 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 399 | } 400 | if requests.Memory != expectedRequestsMemory { 401 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 402 | } 403 | 404 | expectedLimitsCPU := expectedRequestsCPU * 3 405 | expectedLimitsMemory := expectedRequestsMemory * 3 406 | limits := container.Limits 407 | if limits.CPU != expectedLimitsCPU { 408 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 409 | } 410 | if limits.Memory != expectedLimitsMemory { 411 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 412 | } 413 | } 414 | 415 | func TestDeploymentNoRequests(t *testing.T) { 416 | yaml := ` 417 | apiVersion: apps/v1 418 | kind: Deployment 419 | metadata: 420 | name: my-nginx 421 | spec: 422 | replicas: 2 423 | template: 424 | metadata: 425 | labels: 426 | run: my-nginx 427 | spec: 428 | containers: 429 | - name: my-nginx 430 | image: nginx 431 | ports: 432 | - containerPort: 80 433 | resources: 434 | limits: 435 | memory: "64M" 436 | cpu: "500m"` 437 | 438 | deploy, err := decodeDeployment([]byte(yaml), CostimatorConfig{}) 439 | if err != nil { 440 | t.Error(err) 441 | return 442 | } 443 | 444 | container := deploy.Containers[0] 445 | requests := container.Requests 446 | limits := container.Limits 447 | 448 | expectedLimitsCPU := int64(500) 449 | expectedLimitsMemory := int64(64000000) 450 | if requests.CPU != expectedLimitsCPU { 451 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedLimitsCPU, requests.CPU) 452 | } 453 | if requests.Memory != expectedLimitsMemory { 454 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedLimitsMemory, requests.Memory) 455 | } 456 | if limits.CPU != expectedLimitsCPU { 457 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 458 | } 459 | if limits.Memory != expectedLimitsMemory { 460 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 461 | } 462 | } 463 | 464 | func TestDeploymentManyContainers(t *testing.T) { 465 | yaml := ` 466 | apiVersion: apps/v1 467 | kind: Deployment 468 | metadata: 469 | name: my-nginx 470 | spec: 471 | replicas: 2 472 | template: 473 | metadata: 474 | labels: 475 | run: my-nginx 476 | spec: 477 | containers: 478 | - name: my-nginx 479 | image: nginx 480 | resources: 481 | requests: 482 | memory: "64Mi" 483 | cpu: "250m" 484 | - name: busybox 485 | image: busybox 486 | resources: 487 | requests: 488 | memory: "64Mi" 489 | cpu: "250m" 490 | initContainers: 491 | - name: busybox 492 | image: busybox 493 | resources: 494 | requests: 495 | memory: "64Mi" 496 | cpu: "250m"` 497 | 498 | deploy, err := decodeDeployment([]byte(yaml), CostimatorConfig{}) 499 | if err != nil { 500 | t.Error(err) 501 | return 502 | } 503 | 504 | if len(deploy.Containers) != 2 { 505 | t.Errorf("Should have ignored initContainers") 506 | } 507 | 508 | expectedRequestsCPU := float64(0.5) 509 | expectedRequestsMemory := float64(134217728) 510 | cpuReq, _, memReq, _ := totalContainers(deploy.Containers) 511 | if cpuReq != expectedRequestsCPU { 512 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, cpuReq) 513 | } 514 | if memReq != expectedRequestsMemory { 515 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, memReq) 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /api/builder_hpa.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | 20 | v1 "k8s.io/api/autoscaling/v1" 21 | "k8s.io/api/autoscaling/v2beta1" 22 | "k8s.io/api/autoscaling/v2beta2" 23 | ) 24 | 25 | //decodeHPA reads k8s HorizontalPodAutoScaler yaml and trasform to HPA object - mostly used by tests 26 | func decodeHPA(data []byte) (HPA, error) { 27 | obj, groupVersionKind, err := decode(data) 28 | if err != nil { 29 | return HPA{}, fmt.Errorf("Error Decoding. Check if your GroupVersionKind is defined in api/k8s_decoder.go. Root cause %+v", err) 30 | } 31 | return buildHPA(obj, groupVersionKind) 32 | } 33 | 34 | //buildHPA reads k8s HorizontalPodAutoScaler object and trasform to HPA object 35 | func buildHPA(obj interface{}, groupVersionKind GroupVersionKind) (HPA, error) { 36 | switch obj.(type) { 37 | case *v2beta2.HorizontalPodAutoscaler: 38 | return buildHPAV2beta2(obj.(*v2beta2.HorizontalPodAutoscaler)), nil 39 | case *v2beta1.HorizontalPodAutoscaler: 40 | return buildHPAV2beta1(obj.(*v2beta1.HorizontalPodAutoscaler)), nil 41 | case *v1.HorizontalPodAutoscaler: 42 | return buildHPAV1(obj.(*v1.HorizontalPodAutoscaler)), nil 43 | default: 44 | return HPA{}, fmt.Errorf("APIVersion and Kind not Implemented: %+v", groupVersionKind) 45 | } 46 | } 47 | 48 | func buildHPAV2beta2(hpa *v2beta2.HorizontalPodAutoscaler) HPA { 49 | targetCPUPercentage := int32(0) 50 | netrics := hpa.Spec.Metrics 51 | for i := 0; i < len(netrics); i++ { 52 | metric := netrics[i] 53 | if metric.Type == v2beta2.ResourceMetricSourceType { 54 | res := metric.Resource 55 | target := res.Target 56 | if res.Name == "cpu" && target.AverageUtilization != (*int32)(nil) { 57 | targetCPUPercentage = *target.AverageUtilization 58 | } 59 | } 60 | } 61 | 62 | var minReplicas int32 = 1 63 | if hpa.Spec.MinReplicas != (*int32)(nil) { 64 | minReplicas = *hpa.Spec.MinReplicas 65 | } 66 | 67 | namespace := "default" 68 | if hpa.GetNamespace() != "" { 69 | namespace = hpa.GetNamespace() 70 | } 71 | 72 | targetRef := hpa.Spec.ScaleTargetRef 73 | return HPA{ 74 | APIVersionKindName: fmt.Sprintf("%s|%s|%s|%s", hpa.APIVersion, hpa.Kind, namespace, hpa.GetName()), 75 | TargetRef: fmt.Sprintf("%s|%s|%s|%s", targetRef.APIVersion, targetRef.Kind, namespace, targetRef.Name), 76 | MinReplicas: minReplicas, 77 | MaxReplicas: hpa.Spec.MaxReplicas, 78 | TargetCPUPercentage: targetCPUPercentage, 79 | } 80 | } 81 | 82 | func buildHPAV2beta1(hpa *v2beta1.HorizontalPodAutoscaler) HPA { 83 | targetCPUPercentage := int32(0) 84 | netrics := hpa.Spec.Metrics 85 | for i := 0; i < len(netrics); i++ { 86 | metric := netrics[i] 87 | if metric.Type == v2beta1.ResourceMetricSourceType { 88 | res := metric.Resource 89 | if res.Name == "cpu" && res.TargetAverageUtilization != (*int32)(nil) { 90 | targetCPUPercentage = *res.TargetAverageUtilization 91 | } 92 | } 93 | } 94 | 95 | var minReplicas int32 = 1 96 | if hpa.Spec.MinReplicas != (*int32)(nil) { 97 | minReplicas = *hpa.Spec.MinReplicas 98 | } 99 | 100 | namespace := "default" 101 | if hpa.GetNamespace() != "" { 102 | namespace = hpa.GetNamespace() 103 | } 104 | 105 | targetRef := hpa.Spec.ScaleTargetRef 106 | return HPA{ 107 | APIVersionKindName: fmt.Sprintf("%s|%s|%s|%s", hpa.APIVersion, hpa.Kind, namespace, hpa.GetName()), 108 | TargetRef: fmt.Sprintf("%s|%s|%s|%s", targetRef.APIVersion, targetRef.Kind, namespace, targetRef.Name), 109 | MinReplicas: minReplicas, 110 | MaxReplicas: hpa.Spec.MaxReplicas, 111 | TargetCPUPercentage: targetCPUPercentage, 112 | } 113 | } 114 | 115 | func buildHPAV1(hpa *v1.HorizontalPodAutoscaler) HPA { 116 | var targetCPUPercentage int32 = 0 117 | if hpa.Spec.TargetCPUUtilizationPercentage != (*int32)(nil) { 118 | targetCPUPercentage = *hpa.Spec.TargetCPUUtilizationPercentage 119 | } 120 | var minReplicas int32 = 1 121 | if hpa.Spec.MinReplicas != (*int32)(nil) { 122 | minReplicas = *hpa.Spec.MinReplicas 123 | } 124 | targetRef := hpa.Spec.ScaleTargetRef 125 | return HPA{ 126 | APIVersionKindName: buildAPIVersionKindName(hpa.APIVersion, hpa.Kind, hpa.GetNamespace(), hpa.GetName()), 127 | TargetRef: buildAPIVersionKindName(targetRef.APIVersion, targetRef.Kind, hpa.GetNamespace(), targetRef.Name), 128 | MinReplicas: minReplicas, 129 | MaxReplicas: hpa.Spec.MaxReplicas, 130 | TargetCPUPercentage: targetCPUPercentage, 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /api/builder_hpa_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "io/ioutil" 21 | "strings" 22 | "testing" 23 | ) 24 | 25 | func TestHPAAPINotImplemented(t *testing.T) { 26 | yaml := `apiVersion: autoscaling/V10000 27 | kind: HorizontalPodAutoscaler 28 | metadata: 29 | name: php-apache 30 | spec: 31 | maxReplicas: 20 32 | minReplicas: 10 33 | scaleTargetRef: 34 | apiVersion: apps/v1 35 | kind: Deployment 36 | name: php-apache 37 | metrics: 38 | - type: Resource 39 | resource: 40 | name: cpu 41 | target: 42 | type: Utilization 43 | averageUtilization: 60` 44 | 45 | _, err := decodeHPA([]byte(yaml)) 46 | if err == nil || !strings.HasPrefix(err.Error(), "Error Decoding.") { 47 | t.Error(fmt.Errorf("Should have return an APIVersion error, but returned '%+v'", err)) 48 | } 49 | } 50 | 51 | func TestHPABasicV2beta2(t *testing.T) { 52 | yaml := `apiVersion: autoscaling/v2beta2 53 | kind: HorizontalPodAutoscaler 54 | metadata: 55 | name: php-apache 56 | spec: 57 | maxReplicas: 20 58 | minReplicas: 10 59 | scaleTargetRef: 60 | apiVersion: apps/v1 61 | kind: Deployment 62 | name: php-apache 63 | metrics: 64 | - type: Resource 65 | resource: 66 | name: cpu 67 | target: 68 | type: Utilization 69 | averageUtilization: 60` 70 | 71 | hpa, err := decodeHPA([]byte(yaml)) 72 | if err != nil { 73 | t.Error(err) 74 | return 75 | } 76 | 77 | expectedKey := "autoscaling/v2beta2|HorizontalPodAutoscaler|default|php-apache" 78 | if got := hpa.APIVersionKindName; got != expectedKey { 79 | t.Errorf("Expected Key %+v, got %+v", expectedKey, got) 80 | } 81 | 82 | expectedRef := "apps/v1|Deployment|default|php-apache" 83 | if got := hpa.TargetRef; got != expectedRef { 84 | t.Errorf("Expected Target referency %+v, got %+v", expectedRef, got) 85 | } 86 | 87 | expectedMinReplicas := int32(10) 88 | if got := hpa.MinReplicas; got != expectedMinReplicas { 89 | t.Errorf("Expected Min Replicas %+v, got %+v", expectedMinReplicas, got) 90 | } 91 | 92 | expectedMaxReplicas := int32(20) 93 | if got := hpa.MaxReplicas; got != expectedMaxReplicas { 94 | t.Errorf("Expected Max Replicas %+v, got %+v", expectedMaxReplicas, got) 95 | } 96 | 97 | expectedCPU := int32(60) 98 | if got := hpa.TargetCPUPercentage; got != expectedCPU { 99 | t.Errorf("Expected target CPU %+v, got %+v", expectedCPU, got) 100 | } 101 | } 102 | 103 | func TestHPANoMinReplicasV2beta2(t *testing.T) { 104 | yaml := `apiVersion: autoscaling/v2beta2 105 | kind: HorizontalPodAutoscaler 106 | metadata: 107 | name: php-apache 108 | spec: 109 | maxReplicas: 20 110 | scaleTargetRef: 111 | apiVersion: apps/v1 112 | kind: Deployment 113 | name: php-apache 114 | metrics: 115 | - type: Resource 116 | resource: 117 | name: cpu 118 | target: 119 | type: Utilization 120 | averageUtilization: 60` 121 | 122 | hpa, err := decodeHPA([]byte(yaml)) 123 | if err != nil { 124 | t.Error(err) 125 | return 126 | } 127 | 128 | expectedMinReplicas := int32(1) 129 | if got := hpa.MinReplicas; got != expectedMinReplicas { 130 | t.Errorf("Expected Min Replicas %+v, got %+v", expectedMinReplicas, got) 131 | } 132 | } 133 | 134 | func TestHPANoTargetCPUV2beta2(t *testing.T) { 135 | yaml := `apiVersion: autoscaling/v2beta2 136 | kind: HorizontalPodAutoscaler 137 | metadata: 138 | name: php-apache 139 | spec: 140 | maxReplicas: 20 141 | minReplicas: 10 142 | scaleTargetRef: 143 | apiVersion: apps/v1 144 | kind: Deployment 145 | name: php-apache` 146 | 147 | hpa, _ := decodeHPA([]byte(yaml)) 148 | if hpa.TargetCPUPercentage != 0 { 149 | t.Error("Target CPU should be zero") 150 | } 151 | } 152 | 153 | func TestHPABasicV2beta1(t *testing.T) { 154 | yaml := ` 155 | apiVersion: autoscaling/v2beta1 156 | kind: HorizontalPodAutoscaler 157 | metadata: 158 | name: frontend-scaler 159 | spec: 160 | scaleTargetRef: 161 | kind: Deployment 162 | name: frobinator-frontend 163 | minReplicas: 2 164 | maxReplicas: 10 165 | metrics: 166 | - type: Resource 167 | resource: 168 | name: cpu 169 | targetAverageUtilization: 80` 170 | 171 | hpa, err := decodeHPA([]byte(yaml)) 172 | if err != nil { 173 | t.Error(err) 174 | return 175 | } 176 | 177 | expectedKey := "autoscaling/v2beta1|HorizontalPodAutoscaler|default|frontend-scaler" 178 | if got := hpa.APIVersionKindName; got != expectedKey { 179 | t.Errorf("Expected Key %+v, got %+v", expectedKey, got) 180 | } 181 | 182 | expectedRef := "|Deployment|default|frobinator-frontend" 183 | if got := hpa.TargetRef; got != expectedRef { 184 | t.Errorf("Expected Target referency %+v, got %+v", expectedRef, got) 185 | } 186 | 187 | expectedMinReplicas := int32(2) 188 | if got := hpa.MinReplicas; got != expectedMinReplicas { 189 | t.Errorf("Expected Min Replicas %+v, got %+v", expectedMinReplicas, got) 190 | } 191 | 192 | expectedMaxReplicas := int32(10) 193 | if got := hpa.MaxReplicas; got != expectedMaxReplicas { 194 | t.Errorf("Expected Max Replicas %+v, got %+v", expectedMaxReplicas, got) 195 | } 196 | 197 | expectedCPU := int32(80) 198 | if got := hpa.TargetCPUPercentage; got != expectedCPU { 199 | t.Errorf("Expected target CPU %+v, got %+v", expectedCPU, got) 200 | } 201 | } 202 | 203 | func TestHPANoMinReplicasV2beta1(t *testing.T) { 204 | yaml := `apiVersion: autoscaling/v2beta1 205 | kind: HorizontalPodAutoscaler 206 | metadata: 207 | name: php-apache 208 | spec: 209 | maxReplicas: 20 210 | scaleTargetRef: 211 | apiVersion: apps/v1 212 | kind: Deployment 213 | name: php-apache 214 | maxReplicas: 10 215 | metrics: 216 | - type: Resource 217 | resource: 218 | name: cpu 219 | targetAverageUtilization: 80` 220 | 221 | hpa, err := decodeHPA([]byte(yaml)) 222 | if err != nil { 223 | t.Error(err) 224 | return 225 | } 226 | 227 | expectedMinReplicas := int32(1) 228 | if got := hpa.MinReplicas; got != expectedMinReplicas { 229 | t.Errorf("Expected Min Replicas %+v, got %+v", expectedMinReplicas, got) 230 | } 231 | } 232 | 233 | func TestHPANoTargetCPUVV2Beta1(t *testing.T) { 234 | yaml := `apiVersion: autoscaling/v2beta1 235 | kind: HorizontalPodAutoscaler 236 | metadata: 237 | name: php-apache 238 | spec: 239 | maxReplicas: 20 240 | scaleTargetRef: 241 | apiVersion: apps/v1 242 | kind: Deployment 243 | name: php-apache 244 | maxReplicas: 10` 245 | 246 | hpa, _ := decodeHPA([]byte(yaml)) 247 | if hpa.TargetCPUPercentage != 0 { 248 | t.Error("Target CPU should be zero") 249 | } 250 | } 251 | 252 | func TestHPABasicV1(t *testing.T) { 253 | yaml := ` 254 | apiVersion: autoscaling/v1 255 | kind: HorizontalPodAutoscaler 256 | metadata: 257 | labels: 258 | app: adservice 259 | name: adservice 260 | spec: 261 | minReplicas: 5 262 | maxReplicas: 20 263 | scaleTargetRef: 264 | kind: Deployment 265 | name: adservice 266 | targetCPUUtilizationPercentage: 80` 267 | 268 | hpa, err := decodeHPA([]byte(yaml)) 269 | if err != nil { 270 | t.Error(err) 271 | return 272 | } 273 | 274 | expectedKey := "autoscaling/v1|HorizontalPodAutoscaler|default|adservice" 275 | if got := hpa.APIVersionKindName; got != expectedKey { 276 | t.Errorf("Expected Key %+v, got %+v", expectedKey, got) 277 | } 278 | 279 | expectedRef := "|Deployment|default|adservice" 280 | if got := hpa.TargetRef; got != expectedRef { 281 | t.Errorf("Expected Target referency %+v, got %+v", expectedRef, got) 282 | } 283 | 284 | expectedMinReplicas := int32(5) 285 | if got := hpa.MinReplicas; got != expectedMinReplicas { 286 | t.Errorf("Expected Min Replicas %+v, got %+v", expectedMinReplicas, got) 287 | } 288 | 289 | expectedMaxReplicas := int32(20) 290 | if got := hpa.MaxReplicas; got != expectedMaxReplicas { 291 | t.Errorf("Expected Max Replicas %+v, got %+v", expectedMaxReplicas, got) 292 | } 293 | 294 | expectedCPU := int32(80) 295 | if got := hpa.TargetCPUPercentage; got != expectedCPU { 296 | t.Errorf("Expected target CPU %+v, got %+v", expectedCPU, got) 297 | } 298 | } 299 | 300 | func TestHPANoMinReplicasV1(t *testing.T) { 301 | yaml := ` 302 | apiVersion: autoscaling/v1 303 | kind: HorizontalPodAutoscaler 304 | metadata: 305 | labels: 306 | app: adservice 307 | name: adservice 308 | spec: 309 | maxReplicas: 20 310 | scaleTargetRef: 311 | apiVersion: apps/v1 312 | kind: Deployment 313 | name: adservice 314 | targetCPUUtilizationPercentage: 80` 315 | 316 | hpa, err := decodeHPA([]byte(yaml)) 317 | if err != nil { 318 | t.Error(err) 319 | return 320 | } 321 | 322 | expectedKey := "autoscaling/v1|HorizontalPodAutoscaler|default|adservice" 323 | if got := hpa.APIVersionKindName; got != expectedKey { 324 | t.Errorf("Expected Key %+v, got %+v", expectedKey, got) 325 | } 326 | 327 | expectedMinReplicas := int32(1) 328 | if got := hpa.MinReplicas; got != expectedMinReplicas { 329 | t.Errorf("Expected Min Replicas %+v, got %+v", expectedMinReplicas, got) 330 | } 331 | 332 | expectedMaxReplicas := int32(20) 333 | if got := hpa.MaxReplicas; got != expectedMaxReplicas { 334 | t.Errorf("Expected Max Replicas %+v, got %+v", expectedMaxReplicas, got) 335 | } 336 | 337 | expectedCPU := int32(80) 338 | if got := hpa.TargetCPUPercentage; got != expectedCPU { 339 | t.Errorf("Expected target CPU %+v, got %+v", expectedCPU, got) 340 | } 341 | } 342 | 343 | func TestHPANoTargetCPUVV1(t *testing.T) { 344 | yaml := ` 345 | apiVersion: autoscaling/v1 346 | kind: HorizontalPodAutoscaler 347 | metadata: 348 | labels: 349 | app: adservice 350 | name: adservice 351 | spec: 352 | maxReplicas: 20 353 | scaleTargetRef: 354 | apiVersion: apps/v1 355 | kind: Deployment 356 | name: adservice` 357 | 358 | hpa, _ := decodeHPA([]byte(yaml)) 359 | if hpa.TargetCPUPercentage != 0 { 360 | t.Error("Target CPU should be zero") 361 | } 362 | } 363 | 364 | func TestHPADecodeListV2Beta2(t *testing.T) { 365 | hpas := readHPAsFromFile("./testdata/hpa/hpa-v2beta2.yaml", t) 366 | for _, hpa := range hpas { 367 | if hpa.APIVersionKindName != "autoscaling/v2beta2|HorizontalPodAutoscaler|default|cartservice-memory-hpa" && 368 | hpa.TargetCPUPercentage == 0 { 369 | t.Errorf("HPA should have resource cpu utilization: %+v", hpa) 370 | } 371 | } 372 | } 373 | 374 | func TestHPADecodeListV2Beta1(t *testing.T) { 375 | hpas := readHPAsFromFile("./testdata/hpa/hpa-v2beta1.yaml", t) 376 | for _, hpa := range hpas { 377 | if hpa.APIVersionKindName != "autoscaling/v2beta1|HorizontalPodAutoscaler|default|cartservice-memory-hpa" && 378 | hpa.TargetCPUPercentage == 0 { 379 | t.Errorf("HPA should have resource cpu utilization: %+v", hpa) 380 | } 381 | } 382 | } 383 | 384 | func TestHPADecodeListV1(t *testing.T) { 385 | hpas := readHPAsFromFile("./testdata/hpa/hpa-v1.yaml", t) 386 | noUtilizationDefined := 0 387 | for _, hpa := range hpas { 388 | if hpa.TargetCPUPercentage == 0 { 389 | noUtilizationDefined++ 390 | } 391 | } 392 | if noUtilizationDefined != 1 { 393 | t.Errorf("HPA should have resource cpu utilization for all but one") 394 | } 395 | } 396 | 397 | func readHPAsFromFile(file string, t *testing.T) []HPA { 398 | data, err := ioutil.ReadFile(file) 399 | if err != nil { 400 | t.Errorf("Error reading file: %+v", err) 401 | } 402 | 403 | hpas := []HPA{} 404 | objects := bytes.Split(data, []byte("---")) 405 | for _, object := range objects { 406 | hpa, err := decodeHPA(object) 407 | if err != nil { 408 | t.Errorf("Error decoding file: %+v", err) 409 | } 410 | hpas = append(hpas, hpa) 411 | } 412 | return hpas 413 | } 414 | -------------------------------------------------------------------------------- /api/builder_replicaset.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | 20 | appsV1 "k8s.io/api/apps/v1" 21 | ) 22 | 23 | //decodeReplicaSet reads k8s replicaSet yaml and trasform to ReplicaSet object - mainly used by tests 24 | func decodeReplicaSet(data []byte, conf CostimatorConfig) (ReplicaSet, error) { 25 | obj, groupVersionKind, err := decode(data) 26 | if err != nil { 27 | return ReplicaSet{}, fmt.Errorf("Error Decoding. Check if your GroupVersionKind is defined in api/k8s_decoder.go. Root cause %+v", err) 28 | } 29 | return buildReplicaSet(obj, groupVersionKind, conf) 30 | } 31 | 32 | //buildReplicaSet reads k8s replicaSet object and trasform to ReplicaSet object 33 | func buildReplicaSet(obj interface{}, groupVersionKind GroupVersionKind, conf CostimatorConfig) (ReplicaSet, error) { 34 | switch obj.(type) { 35 | default: 36 | return ReplicaSet{}, fmt.Errorf("APIVersion and Kind not Implemented: %+v", groupVersionKind) 37 | case *appsV1.ReplicaSet: 38 | return buildReplicaSetV1(obj.(*appsV1.ReplicaSet), conf), nil 39 | } 40 | } 41 | 42 | func buildReplicaSetV1(replicaset *appsV1.ReplicaSet, conf CostimatorConfig) ReplicaSet { 43 | conf = populateConfigNotProvided(conf) 44 | containers := buildContainers(replicaset.Spec.Template.Spec.Containers, conf) 45 | var replicas int32 = 1 46 | if replicaset.Spec.Replicas != (*int32)(nil) { 47 | replicas = *replicaset.Spec.Replicas 48 | } 49 | return ReplicaSet{ 50 | APIVersionKindName: buildAPIVersionKindName(replicaset.APIVersion, replicaset.Kind, replicaset.GetNamespace(), replicaset.GetName()), 51 | Replicas: replicas, 52 | Containers: containers, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /api/builder_replicaset_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | "strings" 20 | "testing" 21 | ) 22 | 23 | func TestReplicaSetAPINotImplemented(t *testing.T) { 24 | yaml := ` 25 | apiVersion: apps/v1222 26 | kind: ReplicaSet 27 | metadata: 28 | name: frontend 29 | spec: 30 | replicas: 3 31 | selector: 32 | matchLabels: 33 | tier: frontend 34 | template: 35 | metadata: 36 | labels: 37 | tier: frontend 38 | spec: 39 | containers: 40 | - name: php-redis 41 | image: gcr.io/google_samples/gb-frontend:v3` 42 | 43 | _, err := decodeReplicaSet([]byte(yaml), CostimatorConfig{}) 44 | if err == nil || !strings.HasPrefix(err.Error(), "Error Decoding.") { 45 | t.Error(fmt.Errorf("Should have return an APIVersion error, but returned '%+v'", err)) 46 | } 47 | } 48 | 49 | func TestReplicaSetBasicV1(t *testing.T) { 50 | yaml := ` 51 | apiVersion: apps/v1 52 | kind: ReplicaSet 53 | metadata: 54 | name: frontend 55 | spec: 56 | replicas: 3 57 | selector: 58 | matchLabels: 59 | tier: frontend 60 | template: 61 | metadata: 62 | labels: 63 | tier: frontend 64 | spec: 65 | containers: 66 | - name: php-redis 67 | image: gcr.io/google_samples/gb-frontend:v3 68 | resources: 69 | requests: 70 | memory: "64Mi" 71 | cpu: "250m" 72 | limits: 73 | memory: "64M" 74 | cpu: 1` 75 | 76 | replicaset, err := decodeReplicaSet([]byte(yaml), CostimatorConfig{}) 77 | if err != nil { 78 | t.Error(err) 79 | return 80 | } 81 | 82 | expectedAPIVersionKindName := "apps/v1|ReplicaSet|default|frontend" 83 | if got := replicaset.APIVersionKindName; got != expectedAPIVersionKindName { 84 | t.Errorf("Expected APIVersionKindName %+v, got %+v", expectedAPIVersionKindName, got) 85 | } 86 | 87 | expectedKindName := "|ReplicaSet|default|frontend" 88 | if got := replicaset.getKindName(); got != expectedKindName { 89 | t.Errorf("Expected KindName %+v, got %+v", expectedKindName, got) 90 | } 91 | 92 | expected := int32(3) 93 | if got := replicaset.Replicas; got != expected { 94 | t.Errorf("Expected Replicas %+v, got %+v", expected, got) 95 | } 96 | 97 | expectedRequestsCPU := int64(250) 98 | expectedRequestsMemory := int64(67108864) 99 | container := replicaset.Containers[0] 100 | requests := container.Requests 101 | if requests.CPU != expectedRequestsCPU { 102 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 103 | } 104 | if requests.Memory != expectedRequestsMemory { 105 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 106 | } 107 | 108 | expectedLimitsCPU := int64(1000) 109 | expectedLimitsMemory := int64(64000000) 110 | limits := container.Limits 111 | if limits.CPU != expectedLimitsCPU { 112 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 113 | } 114 | if limits.Memory != expectedLimitsMemory { 115 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 116 | } 117 | 118 | } 119 | 120 | func TestReplicaSetBasicV1beta1(t *testing.T) { 121 | yaml := ` 122 | apiVersion: apps/v1beta1 123 | kind: ReplicaSet 124 | metadata: 125 | name: frontend 126 | spec: 127 | replicas: 3 128 | selector: 129 | matchLabels: 130 | tier: frontend 131 | template: 132 | metadata: 133 | labels: 134 | tier: frontend 135 | spec: 136 | containers: 137 | - name: php-redis 138 | image: gcr.io/google_samples/gb-frontend:v3 139 | resources: 140 | requests: 141 | memory: "64Mi" 142 | cpu: "250m" 143 | limits: 144 | memory: "64M" 145 | cpu: 1` 146 | 147 | replicaset, err := decodeReplicaSet([]byte(yaml), CostimatorConfig{}) 148 | if err != nil { 149 | t.Error(err) 150 | return 151 | } 152 | 153 | expectedAPIVersionKindName := "apps/v1beta1|ReplicaSet|default|frontend" 154 | if got := replicaset.APIVersionKindName; got != expectedAPIVersionKindName { 155 | t.Errorf("Expected APIVersionKindName %+v, got %+v", expectedAPIVersionKindName, got) 156 | } 157 | 158 | expectedKindName := "|ReplicaSet|default|frontend" 159 | if got := replicaset.getKindName(); got != expectedKindName { 160 | t.Errorf("Expected KindName %+v, got %+v", expectedKindName, got) 161 | } 162 | 163 | expected := int32(3) 164 | if got := replicaset.Replicas; got != expected { 165 | t.Errorf("Expected Replicas %+v, got %+v", expected, got) 166 | } 167 | 168 | expectedRequestsCPU := int64(250) 169 | expectedRequestsMemory := int64(67108864) 170 | container := replicaset.Containers[0] 171 | requests := container.Requests 172 | if requests.CPU != expectedRequestsCPU { 173 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 174 | } 175 | if requests.Memory != expectedRequestsMemory { 176 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 177 | } 178 | 179 | expectedLimitsCPU := int64(1000) 180 | expectedLimitsMemory := int64(64000000) 181 | limits := container.Limits 182 | if limits.CPU != expectedLimitsCPU { 183 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 184 | } 185 | if limits.Memory != expectedLimitsMemory { 186 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 187 | } 188 | 189 | } 190 | 191 | func TestReplicaSetBasicV1beta2(t *testing.T) { 192 | yaml := ` 193 | apiVersion: apps/v1beta2 194 | kind: ReplicaSet 195 | metadata: 196 | name: frontend 197 | spec: 198 | replicas: 3 199 | selector: 200 | matchLabels: 201 | tier: frontend 202 | template: 203 | metadata: 204 | labels: 205 | tier: frontend 206 | spec: 207 | containers: 208 | - name: php-redis 209 | image: gcr.io/google_samples/gb-frontend:v3 210 | resources: 211 | requests: 212 | memory: "64Mi" 213 | cpu: "250m" 214 | limits: 215 | memory: "64M" 216 | cpu: 1` 217 | 218 | replicaset, err := decodeReplicaSet([]byte(yaml), CostimatorConfig{}) 219 | if err != nil { 220 | t.Error(err) 221 | return 222 | } 223 | 224 | expectedAPIVersionKindName := "apps/v1beta2|ReplicaSet|default|frontend" 225 | if got := replicaset.APIVersionKindName; got != expectedAPIVersionKindName { 226 | t.Errorf("Expected APIVersionKindName %+v, got %+v", expectedAPIVersionKindName, got) 227 | } 228 | 229 | expectedKindName := "|ReplicaSet|default|frontend" 230 | if got := replicaset.getKindName(); got != expectedKindName { 231 | t.Errorf("Expected KindName %+v, got %+v", expectedKindName, got) 232 | } 233 | 234 | expected := int32(3) 235 | if got := replicaset.Replicas; got != expected { 236 | t.Errorf("Expected Replicas %+v, got %+v", expected, got) 237 | } 238 | 239 | expectedRequestsCPU := int64(250) 240 | expectedRequestsMemory := int64(67108864) 241 | container := replicaset.Containers[0] 242 | requests := container.Requests 243 | if requests.CPU != expectedRequestsCPU { 244 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 245 | } 246 | if requests.Memory != expectedRequestsMemory { 247 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 248 | } 249 | 250 | expectedLimitsCPU := int64(1000) 251 | expectedLimitsMemory := int64(64000000) 252 | limits := container.Limits 253 | if limits.CPU != expectedLimitsCPU { 254 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 255 | } 256 | if limits.Memory != expectedLimitsMemory { 257 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 258 | } 259 | 260 | } 261 | 262 | func TestReplicaSetNoReplicas(t *testing.T) { 263 | yaml := ` 264 | apiVersion: apps/v1 265 | kind: ReplicaSet 266 | metadata: 267 | name: frontend 268 | spec: 269 | selector: 270 | matchLabels: 271 | tier: frontend 272 | template: 273 | metadata: 274 | labels: 275 | tier: frontend 276 | spec: 277 | containers: 278 | - name: php-redis 279 | image: gcr.io/google_samples/gb-frontend:v3` 280 | 281 | replicaset, err := decodeReplicaSet([]byte(yaml), CostimatorConfig{}) 282 | if err != nil { 283 | t.Error(err) 284 | return 285 | } 286 | 287 | if got := replicaset.Replicas; got != 1 { 288 | t.Errorf("Expected 1 Replicas, got %+v", got) 289 | } 290 | } 291 | 292 | func TestReplicaSetNoResources(t *testing.T) { 293 | yaml := ` 294 | apiVersion: apps/v1 295 | kind: ReplicaSet 296 | metadata: 297 | name: frontend 298 | spec: 299 | replicas: 3 300 | selector: 301 | matchLabels: 302 | tier: frontend 303 | template: 304 | metadata: 305 | labels: 306 | tier: frontend 307 | spec: 308 | containers: 309 | - name: php-redis 310 | image: gcr.io/google_samples/gb-frontend:v3` 311 | 312 | replicaset, err := decodeReplicaSet([]byte(yaml), CostimatorConfig{}) 313 | if err != nil { 314 | t.Error(err) 315 | return 316 | } 317 | 318 | expectedKey := "apps/v1|ReplicaSet|default|frontend" 319 | if got := replicaset.APIVersionKindName; got != expectedKey { 320 | t.Errorf("Expected Key %+v, got %+v", expectedKey, got) 321 | } 322 | 323 | expectedReplicas := int32(3) 324 | if got := replicaset.Replicas; got != expectedReplicas { 325 | t.Errorf("Expected Replicas %+v, got %+v", expectedReplicas, got) 326 | } 327 | 328 | container := replicaset.Containers[0] 329 | defaults := ConfigDefaults() 330 | 331 | expectedRequestsCPU := defaults.ResourceConf.DefaultCPUinMillis 332 | expectedRequestsMemory := defaults.ResourceConf.DefaultMemoryinBytes 333 | requests := container.Requests 334 | if requests.CPU != expectedRequestsCPU { 335 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 336 | } 337 | if requests.Memory != expectedRequestsMemory { 338 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 339 | } 340 | 341 | expectedLimitsCPU := defaults.ResourceConf.DefaultCPUinMillis * 3 342 | expectedLimitsMemory := defaults.ResourceConf.DefaultMemoryinBytes * 3 343 | limits := container.Limits 344 | if limits.CPU != expectedLimitsCPU { 345 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 346 | } 347 | if limits.Memory != expectedLimitsMemory { 348 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 349 | } 350 | } 351 | 352 | func TestReplicaSetNoLimits(t *testing.T) { 353 | yaml := ` 354 | apiVersion: apps/v1 355 | kind: ReplicaSet 356 | metadata: 357 | name: frontend 358 | spec: 359 | replicas: 3 360 | selector: 361 | matchLabels: 362 | tier: frontend 363 | template: 364 | metadata: 365 | labels: 366 | tier: frontend 367 | spec: 368 | containers: 369 | - name: php-redis 370 | image: gcr.io/google_samples/gb-frontend:v3 371 | resources: 372 | requests: 373 | memory: "64M" 374 | cpu: "500m"` 375 | 376 | replicaset, err := decodeReplicaSet([]byte(yaml), CostimatorConfig{}) 377 | if err != nil { 378 | t.Error(err) 379 | return 380 | } 381 | 382 | container := replicaset.Containers[0] 383 | 384 | expectedRequestsCPU := int64(500) 385 | expectedRequestsMemory := int64(64000000) 386 | requests := container.Requests 387 | if requests.CPU != expectedRequestsCPU { 388 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, requests.CPU) 389 | } 390 | if requests.Memory != expectedRequestsMemory { 391 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, requests.Memory) 392 | } 393 | 394 | expectedLimitsCPU := expectedRequestsCPU * 3 395 | expectedLimitsMemory := expectedRequestsMemory * 3 396 | limits := container.Limits 397 | if limits.CPU != expectedLimitsCPU { 398 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 399 | } 400 | if limits.Memory != expectedLimitsMemory { 401 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 402 | } 403 | } 404 | 405 | func TestReplicaSetNoRequests(t *testing.T) { 406 | yaml := ` 407 | apiVersion: apps/v1 408 | kind: ReplicaSet 409 | metadata: 410 | name: frontend 411 | spec: 412 | replicas: 3 413 | selector: 414 | matchLabels: 415 | tier: frontend 416 | template: 417 | metadata: 418 | labels: 419 | tier: frontend 420 | spec: 421 | containers: 422 | - name: php-redis 423 | image: gcr.io/google_samples/gb-frontend:v3 424 | resources: 425 | limits: 426 | memory: "64M" 427 | cpu: "500m"` 428 | 429 | replicaset, err := decodeReplicaSet([]byte(yaml), CostimatorConfig{}) 430 | if err != nil { 431 | t.Error(err) 432 | return 433 | } 434 | 435 | container := replicaset.Containers[0] 436 | requests := container.Requests 437 | limits := container.Limits 438 | 439 | expectedLimitsCPU := int64(500) 440 | expectedLimitsMemory := int64(64000000) 441 | if requests.CPU != expectedLimitsCPU { 442 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedLimitsCPU, requests.CPU) 443 | } 444 | if requests.Memory != expectedLimitsMemory { 445 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedLimitsMemory, requests.Memory) 446 | } 447 | if limits.CPU != expectedLimitsCPU { 448 | t.Errorf("Expected Limits CPU %+v, got %+v", expectedLimitsCPU, limits.CPU) 449 | } 450 | if limits.Memory != expectedLimitsMemory { 451 | t.Errorf("Expected Limits Memory %+v, got %+v", expectedLimitsMemory, limits.Memory) 452 | } 453 | } 454 | 455 | func TestReplicaSetManyContainers(t *testing.T) { 456 | yaml := ` 457 | apiVersion: apps/v1 458 | kind: ReplicaSet 459 | metadata: 460 | name: frontend 461 | spec: 462 | replicas: 3 463 | selector: 464 | matchLabels: 465 | tier: frontend 466 | template: 467 | metadata: 468 | labels: 469 | tier: frontend 470 | spec: 471 | containers: 472 | - name: my-nginx 473 | image: nginx 474 | resources: 475 | requests: 476 | memory: "64Mi" 477 | cpu: "250m" 478 | - name: busybox 479 | image: busybox 480 | resources: 481 | requests: 482 | memory: "64Mi" 483 | cpu: "250m" 484 | initContainers: 485 | - name: busybox 486 | image: busybox 487 | resources: 488 | requests: 489 | memory: "64Mi" 490 | cpu: "250m"` 491 | 492 | replicaset, err := decodeReplicaSet([]byte(yaml), CostimatorConfig{}) 493 | if err != nil { 494 | t.Error(err) 495 | return 496 | } 497 | 498 | if len(replicaset.Containers) != 2 { 499 | t.Errorf("Should have ignored initContainers") 500 | } 501 | 502 | expectedRequestsCPU := float64(0.5) 503 | expectedRequestsMemory := float64(134217728) 504 | cpuReq, _, memReq, _ := totalContainers(replicaset.Containers) 505 | if cpuReq != expectedRequestsCPU { 506 | t.Errorf("Expected Requests CPU %+v, got %+v", expectedRequestsCPU, cpuReq) 507 | } 508 | if memReq != expectedRequestsMemory { 509 | t.Errorf("Expected Requests Memory %+v, got %+v", expectedRequestsMemory, memReq) 510 | } 511 | } 512 | -------------------------------------------------------------------------------- /api/builder_statefulset.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | 20 | appsV1 "k8s.io/api/apps/v1" 21 | ) 22 | 23 | //decodeStatefulSet reads k8s StatefulSet yaml and trasform to StatefulSet object - mostly used by tests 24 | func decodeStatefulSet(data []byte, conf CostimatorConfig) (StatefulSet, error) { 25 | obj, groupVersionKind, err := decode(data) 26 | if err != nil { 27 | return StatefulSet{}, fmt.Errorf("Error Decoding. Check if your GroupVersionKind is defined in api/k8s_decoder.go. Root cause %+v", err) 28 | } 29 | return buildStatefulSet(obj, groupVersionKind, conf) 30 | } 31 | 32 | //buildStatefulSet reads k8s StatefulSet object and trasform to StatefulSet object 33 | func buildStatefulSet(obj interface{}, groupVersionKind GroupVersionKind, conf CostimatorConfig) (StatefulSet, error) { 34 | switch obj.(type) { 35 | default: 36 | return StatefulSet{}, fmt.Errorf("APIVersion and Kind not Implemented: %+v", groupVersionKind) 37 | case *appsV1.StatefulSet: 38 | return buildStatefulSetV1(obj.(*appsV1.StatefulSet), conf) 39 | } 40 | } 41 | 42 | func buildStatefulSetV1(statefulset *appsV1.StatefulSet, conf CostimatorConfig) (StatefulSet, error) { 43 | conf = populateConfigNotProvided(conf) 44 | containers := buildContainers(statefulset.Spec.Template.Spec.Containers, conf) 45 | var replicas int32 = 1 46 | if statefulset.Spec.Replicas != (*int32)(nil) { 47 | replicas = *statefulset.Spec.Replicas 48 | } 49 | 50 | volumeClaims := []*VolumeClaim{} 51 | for _, vct := range statefulset.Spec.VolumeClaimTemplates { 52 | groupVersionKind := GroupVersionKind{Kind: VolumeClaimKind} 53 | pvc, err := buildVolumeClaim(&vct, groupVersionKind, conf) 54 | if err != nil { 55 | return StatefulSet{}, err 56 | } 57 | volumeClaims = append(volumeClaims, &pvc) 58 | } 59 | 60 | return StatefulSet{ 61 | APIVersionKindName: buildAPIVersionKindName(statefulset.APIVersion, statefulset.Kind, statefulset.GetNamespace(), statefulset.GetName()), 62 | Replicas: replicas, 63 | Containers: containers, 64 | VolumeClaims: volumeClaims, 65 | }, nil 66 | } 67 | -------------------------------------------------------------------------------- /api/builder_volumeclaim.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | 20 | coreV1 "k8s.io/api/core/v1" 21 | ) 22 | 23 | const storageClassStandard = "standard" 24 | 25 | //decodeVolumeClaim reads k8s PersistentVolumeClaim yaml and trasform to VolumeClaim object - mostly used by tests 26 | func decodeVolumeClaim(data []byte, conf CostimatorConfig) (VolumeClaim, error) { 27 | obj, groupVersionKind, err := decode(data) 28 | if err != nil { 29 | return VolumeClaim{}, fmt.Errorf("Error Decoding. Check if your GroupVersionKind is defined in api/k8s_decoder.go. Root cause %+v", err) 30 | } 31 | return buildVolumeClaim(obj, groupVersionKind, conf) 32 | } 33 | 34 | //buildVolumeClaim reads k8s PersistentVolumeClaim object and trasform to VolumeClaim object 35 | func buildVolumeClaim(obj interface{}, groupVersionKind GroupVersionKind, conf CostimatorConfig) (VolumeClaim, error) { 36 | switch obj.(type) { 37 | default: 38 | return VolumeClaim{}, fmt.Errorf("APIVersion and Kind not Implemented: %+v", groupVersionKind) 39 | case *coreV1.PersistentVolumeClaim: 40 | return buildVolumeClaimV1(obj.(*coreV1.PersistentVolumeClaim), conf), nil 41 | } 42 | } 43 | 44 | func buildVolumeClaimV1(volume *coreV1.PersistentVolumeClaim, conf CostimatorConfig) VolumeClaim { 45 | conf = populateConfigNotProvided(conf) 46 | storageClass := storageClassStandard 47 | if volume.Spec.StorageClassName != (*string)(nil) && *volume.Spec.StorageClassName != "" { 48 | storageClass = *volume.Spec.StorageClassName 49 | } 50 | res := volume.Spec.Resources 51 | requests := res.Requests.Storage().Value() 52 | limits := res.Limits.Storage().Value() 53 | // If Requests is omitted for a container, it defaults to Limits if that is explicitly specified 54 | if requests == 0 { 55 | requests = limits 56 | } 57 | if limits == 0 { 58 | limits = requests 59 | } 60 | return VolumeClaim{ 61 | APIVersionKindName: buildAPIVersionKindName(volume.APIVersion, VolumeClaimKind, volume.GetNamespace(), volume.GetName()), 62 | StorageClass: storageClass, 63 | Requests: Resource{Storage: requests}, 64 | Limits: Resource{Storage: limits}, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /api/builder_volumeclaim_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | "strings" 20 | "testing" 21 | ) 22 | 23 | func TestVolumeClaimAPINotImplemented(t *testing.T) { 24 | yaml := ` 25 | kind: PersistentVolumeClaim 26 | apiVersion: v2222 27 | metadata: 28 | name: my-volumeclaim 29 | spec: 30 | accessModes: 31 | - ReadWriteOnce 32 | resources: 33 | requests: 34 | storage: 360Gi` 35 | 36 | _, err := decodeVolumeClaim([]byte(yaml), CostimatorConfig{}) 37 | if err == nil || !strings.HasPrefix(err.Error(), "Error Decoding.") { 38 | t.Error(fmt.Errorf("Should have return an APIVersion error, but returned '%+v'", err)) 39 | } 40 | } 41 | 42 | func TestVolumeClaimBasicV1(t *testing.T) { 43 | yaml := ` 44 | kind: PersistentVolumeClaim 45 | apiVersion: v1 46 | metadata: 47 | name: my-volumeclaim 48 | spec: 49 | accessModes: 50 | - ReadWriteOnce 51 | resources: 52 | requests: 53 | storage: 360Gi` 54 | 55 | volume, err := decodeVolumeClaim([]byte(yaml), CostimatorConfig{}) 56 | if err != nil { 57 | t.Error(err) 58 | return 59 | } 60 | 61 | expectedAPIVersionKindName := "v1|PersistentVolumeClaim|default|my-volumeclaim" 62 | if got := volume.APIVersionKindName; got != expectedAPIVersionKindName { 63 | t.Errorf("Expected APIVersionKindName %+v, got %+v", expectedAPIVersionKindName, got) 64 | } 65 | 66 | if got := volume.StorageClass; got != storageClassStandard { 67 | t.Errorf("Expected StorageClassName %+v, got %+v", storageClassStandard, got) 68 | } 69 | 70 | expectedStorage := int64(386547056640) 71 | requests := volume.Requests 72 | if got := requests.Storage; got != expectedStorage { 73 | t.Errorf("Expected Requests Storage %+v, got %+v", expectedStorage, got) 74 | } 75 | limits := volume.Limits 76 | if got := limits.Storage; got != expectedStorage { 77 | t.Errorf("Expected Limits Storage %+v, got %+v", expectedStorage, got) 78 | } 79 | } 80 | 81 | func TestVolumeClaimCustomStorageClass(t *testing.T) { 82 | yaml := ` 83 | kind: PersistentVolumeClaim 84 | apiVersion: v1 85 | metadata: 86 | name: my-volumeclaim 87 | spec: 88 | storageClassName: my-storage-class 89 | accessModes: 90 | - ReadWriteOnce 91 | resources: 92 | requests: 93 | storage: 360Gi` 94 | 95 | volume, err := decodeVolumeClaim([]byte(yaml), CostimatorConfig{}) 96 | if err != nil { 97 | t.Error(err) 98 | return 99 | } 100 | 101 | storageClass := "my-storage-class" 102 | if got := volume.StorageClass; got != storageClass { 103 | t.Errorf("Expected StorageClassName %+v, got %+v", storageClass, got) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /api/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | // MachineFamily type 18 | type MachineFamily string 19 | 20 | const ( 21 | // E2 machines 22 | E2 MachineFamily = "E2" 23 | // N1 machines 24 | N1 MachineFamily = "N1" 25 | // N2 machines 26 | N2 MachineFamily = "N2" 27 | // N2D machines 28 | N2D MachineFamily = "N2D" 29 | ) 30 | 31 | // CostimatorConfig Defaults for not provided info in manifests 32 | type CostimatorConfig struct { 33 | ResourceConf ResourceConfig `yaml:"resourceConf,omitempty"` 34 | ClusterConf ClusterConfig `yaml:"clusterConf,omitempty"` 35 | } 36 | 37 | // ResourceConfig is used to setup defaults for resources 38 | type ResourceConfig struct { 39 | MachineFamily MachineFamily `yaml:"machineFamily,omitempty"` 40 | Region string `yaml:"region,omitempty"` 41 | DefaultCPUinMillis int64 `yaml:"defaultCPUinMillis,omitempty"` 42 | DefaultMemoryinBytes int64 `yaml:"defaultMemoryinBytes,omitempty"` 43 | PercentageIncreaseForUnboundedRerouces int64 `yaml:"percentageIncreaseForUnboundedRerouces,omitempty"` 44 | } 45 | 46 | // ClusterConfig is used to setup defaults for cluster 47 | type ClusterConfig struct { 48 | NodesCount int32 `yaml:"nodesCount,omitempty"` 49 | } 50 | 51 | // ConfigDefaults set default values for config 52 | func ConfigDefaults() CostimatorConfig { 53 | return CostimatorConfig{ 54 | ResourceConf: ResourceConfig{ 55 | MachineFamily: E2, 56 | Region: "us-central1", 57 | DefaultCPUinMillis: 250, //250m 58 | DefaultMemoryinBytes: 64000000, //64M 59 | PercentageIncreaseForUnboundedRerouces: 200, 60 | }, 61 | ClusterConf: ClusterConfig{ 62 | NodesCount: 3, 63 | }, 64 | } 65 | } 66 | 67 | func populateConfigNotProvided(conf CostimatorConfig) CostimatorConfig { 68 | ret := ConfigDefaults() 69 | if conf.ResourceConf.MachineFamily != "" { 70 | ret.ResourceConf.MachineFamily = conf.ResourceConf.MachineFamily 71 | } 72 | if conf.ResourceConf.Region != "" { 73 | ret.ResourceConf.Region = conf.ResourceConf.Region 74 | } 75 | if conf.ResourceConf.DefaultCPUinMillis != 0 { 76 | ret.ResourceConf.DefaultCPUinMillis = conf.ResourceConf.DefaultCPUinMillis 77 | } 78 | if conf.ResourceConf.DefaultMemoryinBytes != 0 { 79 | ret.ResourceConf.DefaultMemoryinBytes = conf.ResourceConf.DefaultMemoryinBytes 80 | } 81 | if conf.ResourceConf.PercentageIncreaseForUnboundedRerouces != 0 { 82 | ret.ResourceConf.PercentageIncreaseForUnboundedRerouces = conf.ResourceConf.PercentageIncreaseForUnboundedRerouces 83 | } 84 | 85 | if conf.ClusterConf.NodesCount != 0 { 86 | ret.ClusterConf.NodesCount = conf.ClusterConf.NodesCount 87 | } 88 | return ret 89 | } 90 | -------------------------------------------------------------------------------- /api/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/google/go-cmp/cmp" 21 | ) 22 | 23 | func TestPopulateDaemonSetConfigNotProvided(t *testing.T) { 24 | defaults := ConfigDefaults() 25 | populated := populateConfigNotProvided(CostimatorConfig{}) 26 | if !cmp.Equal(defaults, populated) { 27 | t.Errorf("Config should be equal, expected: %+v, got: %+v", defaults, populated) 28 | } 29 | 30 | expected := CostimatorConfig{ 31 | ResourceConf: ResourceConfig{ 32 | MachineFamily: N1, 33 | Region: "us-central2", 34 | DefaultCPUinMillis: 300, 35 | DefaultMemoryinBytes: 65000000, 36 | PercentageIncreaseForUnboundedRerouces: 100, 37 | }, 38 | ClusterConf: ClusterConfig{ 39 | NodesCount: 5, 40 | }, 41 | } 42 | 43 | populated = populateConfigNotProvided(expected) 44 | if !cmp.Equal(expected, populated) { 45 | t.Errorf("Config should be equal, expected: %+v, got: %+v", expected, populated) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /api/cost.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | "math" 20 | "strings" 21 | 22 | "github.com/leekchan/accounting" 23 | "github.com/olekukonko/tablewriter" 24 | ) 25 | 26 | const ( 27 | upArrow = "↑" 28 | downArrow = "↓" 29 | ) 30 | 31 | var headers = []string{"MIN REQUESTED", 32 | "MIN REQ + HPA CPU BUFFER", 33 | "MAX REQUESTED", 34 | "MIN LIMITED", 35 | "MAX LIMITED"} 36 | 37 | // Cost groups cost range by kinda 38 | type Cost struct { 39 | MonthlyRanges []CostRange 40 | } 41 | 42 | // CostRange represent the range of estimated value 43 | type CostRange struct { 44 | Kind string `json:"kind"` 45 | 46 | MinRequested float64 `json:"minRequested"` 47 | MaxRequested float64 `json:"maxRequested"` 48 | 49 | HPABuffer float64 `json:"hpaBuffer"` //Note: currently only supports CPU Target utilizaiton 50 | 51 | MinLimited float64 `json:"minLimited"` 52 | MaxLimited float64 `json:"maxLimited"` 53 | } 54 | 55 | // DiffCost holds the total difference between two costs 56 | type DiffCost struct { 57 | Summary string 58 | hascostIncd bool 59 | CostCurr Cost 60 | CostPrev Cost 61 | MonthlyDiffRange DiffCostRange 62 | } 63 | 64 | // DiffCostRange holds the total difference between two costs 65 | type DiffCostRange struct { 66 | Kind string 67 | CostCurr CostRange 68 | CostPrev CostRange 69 | DiffValue CostRange 70 | DiffPercentage CostRange 71 | } 72 | 73 | // MonthlyTotal returns the sum for all MonthlyRanges 74 | func (c *Cost) MonthlyTotal() CostRange { 75 | totalMonthlyRange := CostRange{Kind: "MonthlyTotal"} 76 | for _, monthlyRange := range c.MonthlyRanges { 77 | totalMonthlyRange = totalMonthlyRange.Add(monthlyRange) 78 | } 79 | return totalMonthlyRange 80 | } 81 | 82 | // Subtract current total cost from previous total cost 83 | func (c *Cost) Subtract(costPrev Cost) DiffCost { 84 | cr := c.MonthlyTotal() 85 | diff := cr.Subtract(costPrev.MonthlyTotal()) 86 | summary, hascostIncd := diff.status() 87 | return DiffCost{ 88 | Summary: summary, 89 | hascostIncd: hascostIncd, 90 | CostCurr: *c, 91 | CostPrev: costPrev, 92 | MonthlyDiffRange: diff, 93 | } 94 | } 95 | 96 | // ToMarkdown convert to Markdown string 97 | func (c *Cost) ToMarkdown() string { 98 | data := [][]string{} 99 | total := CostRange{Kind: bold("TOTAL")} 100 | for _, mr := range c.MonthlyRanges { 101 | data = append(data, 102 | []string{mr.Kind, 103 | currency(mr.MinRequested), 104 | currency(mr.HPABuffer), 105 | currency(mr.MaxRequested), 106 | currency(mr.MinLimited), 107 | currency(mr.MaxLimited)}) 108 | 109 | total.MinRequested = total.MinRequested + mr.MinRequested 110 | total.HPABuffer = total.HPABuffer + mr.HPABuffer 111 | total.MaxRequested = total.MaxRequested + mr.MaxRequested 112 | total.MinLimited = total.MinLimited + mr.MinLimited 113 | total.MaxLimited = total.MaxLimited + mr.MaxLimited 114 | } 115 | data = append(data, 116 | []string{total.Kind, 117 | bold(currency(total.MinRequested)), 118 | bold(currency(total.HPABuffer)), 119 | bold(currency(total.MaxRequested)), 120 | bold(currency(total.MinLimited)), 121 | bold(currency(total.MaxLimited))}) 122 | 123 | out := &strings.Builder{} 124 | table := tablewriter.NewWriter(out) 125 | table.SetHeader( 126 | []string{"Kind", 127 | headers[0] + " (USD)", 128 | headers[1] + " (USD)", 129 | headers[2] + " (USD)", 130 | headers[3] + " (USD)", 131 | headers[4] + " (USD)"}) 132 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 133 | table.SetCenterSeparator("|") 134 | table.SetColumnAlignment([]int{0, 2, 2, 2, 2, 2}) 135 | table.AppendBulk(data) 136 | table.Render() 137 | return out.String() 138 | } 139 | 140 | func bold(val string) string { 141 | return fmt.Sprintf("**%s**", val) 142 | } 143 | 144 | // Add sums the given costrange to the current costrange 145 | func (c *CostRange) Add(costRange CostRange) CostRange { 146 | ret := CostRange{Kind: c.Kind} 147 | ret.MinRequested = c.MinRequested + costRange.MinRequested 148 | ret.MaxRequested = c.MaxRequested + costRange.MaxRequested 149 | ret.HPABuffer = c.HPABuffer + costRange.HPABuffer 150 | ret.MinLimited = c.MinLimited + costRange.MinLimited 151 | ret.MaxLimited = c.MaxLimited + costRange.MaxLimited 152 | return ret 153 | } 154 | 155 | // Subtract subtracts the given costrange to the current costrange 156 | func (c *CostRange) Subtract(costRangePrev CostRange) DiffCostRange { 157 | 158 | diff := CostRange{Kind: c.Kind} 159 | diff.MinRequested = c.MinRequested - costRangePrev.MinRequested 160 | diff.MaxRequested = c.MaxRequested - costRangePrev.MaxRequested 161 | diff.HPABuffer = c.HPABuffer - costRangePrev.HPABuffer 162 | diff.MinLimited = c.MinLimited - costRangePrev.MinLimited 163 | diff.MaxLimited = c.MaxLimited - costRangePrev.MaxLimited 164 | 165 | diffP := CostRange{Kind: c.Kind} 166 | diffP.MinRequested = diff.MinRequested * 100 / c.MinRequested 167 | diffP.MaxRequested = diff.MaxRequested * 100 / c.MaxRequested 168 | diffP.HPABuffer = diff.HPABuffer * 100 / c.HPABuffer 169 | diffP.MinLimited = diff.MinLimited * 100 / c.MinLimited 170 | diffP.MaxLimited = diff.MaxLimited * 100 / c.MaxLimited 171 | 172 | return DiffCostRange{ 173 | Kind: c.Kind, 174 | CostCurr: *c, 175 | CostPrev: costRangePrev, 176 | DiffValue: diff, 177 | DiffPercentage: diffP, 178 | } 179 | } 180 | 181 | func (c *CostRange) max() float64 { 182 | max := c.MinRequested 183 | if c.MaxRequested > max { 184 | max = c.MaxRequested 185 | } 186 | if c.HPABuffer > max { 187 | max = c.HPABuffer 188 | } 189 | if c.MinLimited > max { 190 | max = c.MinLimited 191 | } 192 | if c.MaxLimited > max { 193 | max = c.MaxLimited 194 | } 195 | return max 196 | } 197 | 198 | // ToMarkdown convert to Markdown string 199 | func (d *DiffCost) ToMarkdown() string { 200 | 201 | current := fmt.Sprintf("## Current Monthly Cost\n\n%s", d.CostCurr.ToMarkdown()) 202 | previous := fmt.Sprintf("## Previous Monthly Cost\n\n%s", d.CostPrev.ToMarkdown()) 203 | diff := d.MonthlyDiffRange.ToMarkdown() 204 | total := fmt.Sprintf("## Difference in Costs\n\n**Summary:** %s\n\n%s", d.Summary, diff) 205 | return fmt.Sprintf("%s\n\n%s\n\n%s", previous, current, total) 206 | } 207 | 208 | //--- 209 | 210 | // PriceMaxDiff ... 211 | type PriceMaxDiff struct { 212 | USD float64 `json:"usd"` 213 | Perc float64 `json:"perc"` 214 | } 215 | 216 | // PriceSummary ... 217 | type PriceSummary struct { 218 | PossiblyCostIncrease bool `json:"possiblyCostIncrease"` 219 | MaxDiff PriceMaxDiff `json:"maxDiff"` 220 | } 221 | 222 | // PriceDetails ... 223 | type PriceDetails struct { 224 | USD CostRange `json:"usd"` 225 | Perc CostRange `json:"perc"` 226 | } 227 | 228 | // PriceDiff ... 229 | type PriceDiff struct { 230 | Summary PriceSummary `json:"summary"` 231 | Details PriceDetails `json:"details"` 232 | } 233 | 234 | // ToPriceDiff struct 235 | func (d *DiffCostRange) ToPriceDiff() PriceDiff { 236 | return PriceDiff{ 237 | Summary: d.ToPriceSummary(), 238 | Details: d.ToPriceDetails(), 239 | } 240 | } 241 | 242 | // ToPriceSummary struct 243 | func (d *DiffCostRange) ToPriceSummary() PriceSummary { 244 | _, costIncrease := d.status() 245 | return PriceSummary{ 246 | PossiblyCostIncrease: costIncrease, 247 | MaxDiff: d.ToPriceMaxDiff(), 248 | } 249 | } 250 | 251 | // ToPriceMaxDiff struct 252 | func (d *DiffCostRange) ToPriceMaxDiff() PriceMaxDiff { 253 | return PriceMaxDiff{ 254 | USD: math.Floor(d.DiffValue.max()*100) / 100, 255 | Perc: math.Floor(d.DiffPercentage.max()*100) / 100, 256 | } 257 | } 258 | 259 | // ToPriceDetails struct 260 | func (d *DiffCostRange) ToPriceDetails() PriceDetails { 261 | return PriceDetails{ 262 | USD: d.DiffValue, 263 | Perc: d.DiffPercentage, 264 | } 265 | } 266 | 267 | // ToMarkdown convert to Markdown string 268 | func (d *DiffCostRange) ToMarkdown() string { 269 | data := [][]string{ 270 | {bold(headers[0]), currency(d.CostPrev.MinRequested), currency(d.CostCurr.MinRequested), currencyDiff(d.DiffValue.MinRequested), percDiff(d.DiffPercentage.MinRequested)}, 271 | {bold(headers[1]), currency(d.CostPrev.HPABuffer), currency(d.CostCurr.HPABuffer), currencyDiff(d.DiffValue.HPABuffer), percDiff(d.DiffPercentage.HPABuffer)}, 272 | {bold(headers[2]), currency(d.CostPrev.MaxRequested), currency(d.CostCurr.MaxRequested), currencyDiff(d.DiffValue.MaxRequested), percDiff(d.DiffPercentage.MaxRequested)}, 273 | {bold(headers[3]), currency(d.CostPrev.MinLimited), currency(d.CostCurr.MinLimited), currencyDiff(d.DiffValue.MinLimited), percDiff(d.DiffPercentage.MinLimited)}, 274 | {bold(headers[4]), currency(d.CostPrev.MaxLimited), currency(d.CostCurr.MaxLimited), currencyDiff(d.DiffValue.MaxLimited), percDiff(d.DiffPercentage.MaxLimited)}, 275 | } 276 | 277 | out := &strings.Builder{} 278 | table := tablewriter.NewWriter(out) 279 | table.SetHeader([]string{"Cost Variation", "Previous (USD)", "Current (USD)", "Difference (USD)", "Difference (%)"}) 280 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 281 | table.SetCenterSeparator("|") 282 | table.SetColumnAlignment([]int{0, 2, 2, 2, 2}) 283 | table.AppendBulk(data) 284 | table.Render() 285 | return out.String() 286 | } 287 | 288 | func (d *DiffCostRange) status() (summary string, costIncrease bool) { 289 | var costInc, costDec []string 290 | 291 | if d.DiffPercentage.MinRequested > 0 { 292 | costInc = append(costInc, headers[0]) 293 | } 294 | if d.DiffPercentage.HPABuffer > 0 { 295 | costInc = append(costInc, headers[1]) 296 | } 297 | if d.DiffPercentage.MaxRequested > 0 { 298 | costInc = append(costInc, headers[2]) 299 | } 300 | if d.DiffPercentage.MinLimited > 0 { 301 | costInc = append(costInc, headers[3]) 302 | } 303 | if d.DiffPercentage.MaxLimited > 0 { 304 | costInc = append(costInc, headers[4]) 305 | } 306 | 307 | if d.DiffPercentage.MinRequested < 0 { 308 | costDec = append(costDec, headers[0]) 309 | } 310 | if d.DiffPercentage.HPABuffer < 0 { 311 | costDec = append(costDec, headers[1]) 312 | } 313 | if d.DiffPercentage.MaxRequested < 0 { 314 | costDec = append(costDec, headers[2]) 315 | } 316 | if d.DiffPercentage.MinLimited < 0 { 317 | costDec = append(costDec, headers[3]) 318 | } 319 | if d.DiffPercentage.MaxLimited < 0 { 320 | costDec = append(costDec, headers[4]) 321 | } 322 | 323 | if len(costInc) > 0 { 324 | costIncrease = true 325 | summary = summary + fmt.Sprintf("There are increase in costs on: '%s'", strings.Join(costInc, "', '")) 326 | } 327 | if len(costDec) > 0 { 328 | start := "There" 329 | if len(summary) > 0 { 330 | start = ". And there" 331 | } 332 | summary = summary + fmt.Sprintf("%s are decrease in costs on: '%s'", start, strings.Join(costDec, "', '")) 333 | } 334 | if len(costInc) == 0 && len(costDec) == 0 { 335 | summary = "No cost change found!" 336 | } 337 | return 338 | } 339 | 340 | // --- helper functions --- 341 | 342 | func currency(value float64) string { 343 | ac := accounting.Accounting{Symbol: "$", Precision: 2} 344 | return ac.FormatMoneyFloat64(value) 345 | } 346 | 347 | func currencyDiff(value float64) string { 348 | ac := accounting.Accounting{Symbol: "$", Precision: 2, FormatZero: " "} 349 | valueFormated := ac.FormatMoneyFloat64(value) 350 | if value > 0 { 351 | return fmt.Sprintf("**+%s (%s)**", valueFormated, upArrow) 352 | } else if value < 0 { 353 | return fmt.Sprintf("%s (%s)", valueFormated, downArrow) 354 | } 355 | return valueFormated 356 | } 357 | 358 | func percDiff(perc float64) string { 359 | if perc != 0 { 360 | percFormated := fmt.Sprintf("%.2f%%", perc) 361 | if perc > 0 { 362 | return fmt.Sprintf("**+%s (%s)**", percFormated, upArrow) 363 | } else if perc < 0 { 364 | return fmt.Sprintf("%s (%s)", percFormated, downArrow) 365 | } 366 | return percFormated 367 | } 368 | return " " 369 | } 370 | -------------------------------------------------------------------------------- /api/k8s_decoder.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | appsV1 "k8s.io/api/apps/v1" 19 | autoscaleV1 "k8s.io/api/autoscaling/v1" 20 | autoscaleV2beta1 "k8s.io/api/autoscaling/v2beta1" 21 | autoscaleV2beta2 "k8s.io/api/autoscaling/v2beta2" 22 | coreV1 "k8s.io/api/core/v1" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | "k8s.io/apimachinery/pkg/runtime/schema" 25 | "k8s.io/apimachinery/pkg/runtime/serializer" 26 | ) 27 | 28 | func decode(data []byte) (runtime.Object, GroupVersionKind, error) { 29 | scheme := buildScheme() 30 | decoder := serializer.NewCodecFactory(scheme).UniversalDeserializer() 31 | obj, gvk, err := decoder.Decode(data, nil, nil) 32 | if err != nil { 33 | return (runtime.Object)(nil), GroupVersionKind{}, err 34 | } 35 | groupVersionKind := GroupVersionKind{ 36 | Group: gvk.Group, 37 | Version: gvk.Version, 38 | Kind: gvk.Kind, 39 | } 40 | return obj, groupVersionKind, err 41 | } 42 | 43 | func buildScheme() *runtime.Scheme { 44 | scheme := runtime.NewScheme() 45 | registryHPAVersions(scheme) 46 | registryDeploymentVersions(scheme) 47 | registryReplicaSetVersions(scheme) 48 | registryStatefulSetVersions(scheme) 49 | registryDeamonSetVersions(scheme) 50 | registryVolumeClaimVersions(scheme) 51 | return scheme 52 | } 53 | 54 | func registryHPAVersions(scheme *runtime.Scheme) { 55 | gvkAutoscaleV1 := schema.GroupVersionKind{ 56 | Group: "autoscaling", 57 | Version: "v1", 58 | Kind: HPAKind, 59 | } 60 | scheme.AddKnownTypeWithName(gvkAutoscaleV1, &autoscaleV1.HorizontalPodAutoscaler{}) 61 | 62 | gvkAutoscaleV2beta1 := schema.GroupVersionKind{ 63 | Group: "autoscaling", 64 | Version: "v2beta1", 65 | Kind: HPAKind, 66 | } 67 | scheme.AddKnownTypeWithName(gvkAutoscaleV2beta1, &autoscaleV2beta1.HorizontalPodAutoscaler{}) 68 | 69 | gvkAutoscaleV2beta2 := schema.GroupVersionKind{ 70 | Group: "autoscaling", 71 | Version: "v2beta2", 72 | Kind: HPAKind, 73 | } 74 | scheme.AddKnownTypeWithName(gvkAutoscaleV2beta2, &autoscaleV2beta2.HorizontalPodAutoscaler{}) 75 | } 76 | 77 | func registryDeploymentVersions(scheme *runtime.Scheme) { 78 | gvkAppsV1 := schema.GroupVersionKind{ 79 | Group: "apps", 80 | Version: "v1", 81 | Kind: DeploymentKind, 82 | } 83 | scheme.AddKnownTypeWithName(gvkAppsV1, &appsV1.Deployment{}) 84 | 85 | gvkAppsV1beta1 := schema.GroupVersionKind{ 86 | Group: "apps", 87 | Version: "v1beta1", 88 | Kind: DeploymentKind, 89 | } 90 | // we load v1, once the fields we are interested have in v1 91 | // This way, we don't need many implementations in builder_deployment.go file 92 | scheme.AddKnownTypeWithName(gvkAppsV1beta1, &appsV1.Deployment{}) 93 | 94 | gvkAppsV1beta2 := schema.GroupVersionKind{ 95 | Group: "apps", 96 | Version: "v1beta2", 97 | Kind: DeploymentKind, 98 | } 99 | // we load v1, once the fields we are interested have in v1 100 | // This way, we don't need many implementations in builder_deployment.go file 101 | scheme.AddKnownTypeWithName(gvkAppsV1beta2, &appsV1.Deployment{}) 102 | } 103 | 104 | func registryReplicaSetVersions(scheme *runtime.Scheme) { 105 | gvkAppsV1 := schema.GroupVersionKind{ 106 | Group: "apps", 107 | Version: "v1", 108 | Kind: ReplicaSetKind, 109 | } 110 | scheme.AddKnownTypeWithName(gvkAppsV1, &appsV1.ReplicaSet{}) 111 | 112 | gvkAppsV1beta1 := schema.GroupVersionKind{ 113 | Group: "apps", 114 | Version: "v1beta1", 115 | Kind: ReplicaSetKind, 116 | } 117 | // we load v1, once the fields we are interested have in v1 118 | // This way, we don't need many implementations in builder_replicaset.go file 119 | scheme.AddKnownTypeWithName(gvkAppsV1beta1, &appsV1.ReplicaSet{}) 120 | 121 | gvkAppsV1beta2 := schema.GroupVersionKind{ 122 | Group: "apps", 123 | Version: "v1beta2", 124 | Kind: ReplicaSetKind, 125 | } 126 | // we load v1, once the fields we are interested have in v1 127 | // This way, we don't need many implementations in builder_replicaset.go file 128 | scheme.AddKnownTypeWithName(gvkAppsV1beta2, &appsV1.ReplicaSet{}) 129 | } 130 | 131 | func registryStatefulSetVersions(scheme *runtime.Scheme) { 132 | gvkAppsV1 := schema.GroupVersionKind{ 133 | Group: "apps", 134 | Version: "v1", 135 | Kind: StatefulSetKind, 136 | } 137 | scheme.AddKnownTypeWithName(gvkAppsV1, &appsV1.StatefulSet{}) 138 | 139 | gvkAppsV1beta1 := schema.GroupVersionKind{ 140 | Group: "apps", 141 | Version: "v1beta1", 142 | Kind: StatefulSetKind, 143 | } 144 | // we load v1, once the fields we are interested have in v1 145 | // This way, we don't need many implementations in builder_statefulset.go file 146 | scheme.AddKnownTypeWithName(gvkAppsV1beta1, &appsV1.StatefulSet{}) 147 | 148 | gvkAppsV1beta2 := schema.GroupVersionKind{ 149 | Group: "apps", 150 | Version: "v1beta2", 151 | Kind: StatefulSetKind, 152 | } 153 | // we load v1, once the fields we are interested have in v1 154 | // This way, we don't need many implementations in builder_statefulset.go file 155 | scheme.AddKnownTypeWithName(gvkAppsV1beta2, &appsV1.StatefulSet{}) 156 | } 157 | 158 | func registryDeamonSetVersions(scheme *runtime.Scheme) { 159 | gvkAppsV1 := schema.GroupVersionKind{ 160 | Group: "apps", 161 | Version: "v1", 162 | Kind: DaemonSetKind, 163 | } 164 | scheme.AddKnownTypeWithName(gvkAppsV1, &appsV1.DaemonSet{}) 165 | 166 | gvkAppsV1beta1 := schema.GroupVersionKind{ 167 | Group: "apps", 168 | Version: "v1beta1", 169 | Kind: DaemonSetKind, 170 | } 171 | // we load v1, once the fields we are interested have in v1 172 | // This way, we don't need many implementations in builder_deamonset.go file 173 | scheme.AddKnownTypeWithName(gvkAppsV1beta1, &appsV1.DaemonSet{}) 174 | 175 | gvkAppsV1beta2 := schema.GroupVersionKind{ 176 | Group: "apps", 177 | Version: "v1beta2", 178 | Kind: DaemonSetKind, 179 | } 180 | // we load v1, once the fields we are interested have in v1 181 | // This way, we don't need many implementations in builder_deamonset.go file 182 | scheme.AddKnownTypeWithName(gvkAppsV1beta2, &appsV1.DaemonSet{}) 183 | } 184 | 185 | func registryVolumeClaimVersions(scheme *runtime.Scheme) { 186 | gvkV1 := schema.GroupVersionKind{ 187 | Version: "v1", 188 | Kind: VolumeClaimKind, 189 | } 190 | scheme.AddKnownTypeWithName(gvkV1, &coreV1.PersistentVolumeClaim{}) 191 | } 192 | -------------------------------------------------------------------------------- /api/manifests.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "bytes" 19 | "fmt" 20 | "io/ioutil" 21 | "os" 22 | "path/filepath" 23 | "strings" 24 | 25 | log "github.com/sirupsen/logrus" 26 | ) 27 | 28 | // Manifests holds all deployments and executes cost estimation 29 | type Manifests struct { 30 | Deployments []*Deployment 31 | deploymentsRef map[string]*Deployment 32 | ReplicaSets []*ReplicaSet 33 | replicaSetsRef map[string]*ReplicaSet 34 | StatefulSets []*StatefulSet 35 | statefulsetsRef map[string]*StatefulSet 36 | DaemonSets []*DaemonSet 37 | VolumeClaims []*VolumeClaim 38 | hpas []HPA 39 | } 40 | 41 | // LoadObjectsFromPath loads all files from folder and subfolder finishing with yaml or yml 42 | func (m *Manifests) LoadObjectsFromPath(path string, conf CostimatorConfig) error { 43 | err := filepath.Walk(path, func(path string, f os.FileInfo, err error) error { 44 | if !f.IsDir() { 45 | if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") { 46 | data, err := ioutil.ReadFile(path) 47 | if err != nil { 48 | return err 49 | } 50 | log.Tracef("Loading yaml file '%s'", path) 51 | return m.LoadObjects(data, conf) 52 | } 53 | log.Tracef("Skipping non yaml file '%s'", path) 54 | } 55 | return nil 56 | }) 57 | 58 | if err != nil { 59 | return err 60 | } 61 | return nil 62 | } 63 | 64 | // LoadObjects allow you to decode and load into Manifests your k8s objects 65 | // For now, it only understands Deployment and HPA 66 | func (m *Manifests) LoadObjects(data []byte, conf CostimatorConfig) error { 67 | objects := bytes.Split(data, []byte("---")) 68 | for _, object := range objects { 69 | err := m.loadObject(object, conf) 70 | if err != nil { 71 | return err 72 | } 73 | } 74 | return nil 75 | } 76 | 77 | // EstimateCost loop through all resources and group it by kind 78 | func (m *Manifests) EstimateCost(pc GCPPriceCatalog) Cost { 79 | m.prepareForCostEstimation() 80 | 81 | monthlyRanges := []CostRange{} 82 | if len(m.Deployments) > 0 { 83 | monthlyRanges = append(monthlyRanges, m.estimateDeploymentCost(&pc)) 84 | } 85 | if len(m.ReplicaSets) > 0 { 86 | monthlyRanges = append(monthlyRanges, m.estimateReplicaSetCost(&pc)) 87 | } 88 | if len(m.StatefulSets) > 0 { 89 | monthlyRanges = append(monthlyRanges, m.estimateStatefulSetCost(&pc)) 90 | } 91 | if len(m.DaemonSets) > 0 { 92 | monthlyRanges = append(monthlyRanges, m.estimateDaemonSetCost(&pc)) 93 | } 94 | if len(m.VolumeClaims) > 0 { 95 | monthlyRanges = append(monthlyRanges, m.estimateVolumeClaimCost(&pc)) 96 | } 97 | 98 | return Cost{ 99 | MonthlyRanges: monthlyRanges, 100 | } 101 | } 102 | 103 | func (m *Manifests) estimateDeploymentCost(rp ResourcePrice) CostRange { 104 | deploymentRange := CostRange{Kind: DeploymentKind} 105 | for _, deploy := range m.Deployments { 106 | deploymentRange = deploymentRange.Add(deploy.estimateCost(rp)) 107 | } 108 | return deploymentRange 109 | } 110 | 111 | func (m *Manifests) estimateReplicaSetCost(rp ResourcePrice) CostRange { 112 | replicasetRange := CostRange{Kind: ReplicaSetKind} 113 | for _, replicaset := range m.ReplicaSets { 114 | replicasetRange = replicasetRange.Add(replicaset.estimateCost(rp)) 115 | } 116 | return replicasetRange 117 | } 118 | 119 | func (m *Manifests) estimateStatefulSetCost(rp ResourcePrice) CostRange { 120 | statefulsetRange := CostRange{Kind: StatefulSetKind} 121 | for _, statefulset := range m.StatefulSets { 122 | statefulsetRange = statefulsetRange.Add(statefulset.estimateCost(rp)) 123 | } 124 | return statefulsetRange 125 | } 126 | 127 | func (m *Manifests) estimateDaemonSetCost(rp ResourcePrice) CostRange { 128 | daemonsetRange := CostRange{Kind: DaemonSetKind} 129 | for _, daemonset := range m.DaemonSets { 130 | daemonsetRange = daemonsetRange.Add(daemonset.estimateCost(rp)) 131 | } 132 | return daemonsetRange 133 | } 134 | 135 | func (m *Manifests) estimateVolumeClaimCost(sp StoragePrice) CostRange { 136 | volumeClaimRange := CostRange{Kind: VolumeClaimKind} 137 | for _, volumeClaim := range m.VolumeClaims { 138 | volumeClaimRange = volumeClaimRange.Add(volumeClaim.estimateCost(sp)) 139 | } 140 | return volumeClaimRange 141 | } 142 | 143 | func (m *Manifests) prepareForCostEstimation() { 144 | for _, hpa := range m.hpas { 145 | key := hpa.TargetRef 146 | if deploy, ok := m.deploymentsRef[key]; ok { 147 | deploy.hpa = hpa 148 | } 149 | if replicaset, ok := m.replicaSetsRef[key]; ok { 150 | replicaset.hpa = hpa 151 | } 152 | if statefulset, ok := m.statefulsetsRef[key]; ok { 153 | statefulset.hpa = hpa 154 | } 155 | } 156 | } 157 | 158 | func (m *Manifests) loadObject(data []byte, conf CostimatorConfig) error { 159 | if ak, bol := isObjectSupported(data); !bol { 160 | log.Debugf("Skipping unsupported k8s object: %+v", ak) 161 | return nil 162 | } 163 | 164 | obj, groupVersionKind, err := decode(data) 165 | if err != nil { 166 | return fmt.Errorf("Error Decoding. Check if your GroupVersionKind is defined in api/k8s_decoder.go. Root cause %+v", err) 167 | } 168 | 169 | switch groupVersionKind.Kind { 170 | case HPAKind: 171 | hpa, err := buildHPA(obj, groupVersionKind) 172 | if err != nil { 173 | return err 174 | } 175 | m.hpas = append(m.hpas, hpa) 176 | case DeploymentKind: 177 | deploy, err := buildDeployment(obj, groupVersionKind, conf) 178 | if err != nil { 179 | return err 180 | } 181 | m.Deployments = append(m.Deployments, &deploy) 182 | if m.deploymentsRef == nil { 183 | m.deploymentsRef = make(map[string]*Deployment) 184 | } 185 | m.deploymentsRef[deploy.APIVersionKindName] = &deploy 186 | m.deploymentsRef[deploy.getKindName()] = &deploy 187 | case ReplicaSetKind: 188 | replicaset, err := buildReplicaSet(obj, groupVersionKind, conf) 189 | if err != nil { 190 | return err 191 | } 192 | m.ReplicaSets = append(m.ReplicaSets, &replicaset) 193 | if m.replicaSetsRef == nil { 194 | m.replicaSetsRef = make(map[string]*ReplicaSet) 195 | } 196 | m.replicaSetsRef[replicaset.APIVersionKindName] = &replicaset 197 | m.replicaSetsRef[replicaset.getKindName()] = &replicaset 198 | case StatefulSetKind: 199 | statefulset, err := buildStatefulSet(obj, groupVersionKind, conf) 200 | if err != nil { 201 | return err 202 | } 203 | m.StatefulSets = append(m.StatefulSets, &statefulset) 204 | if m.statefulsetsRef == nil { 205 | m.statefulsetsRef = make(map[string]*StatefulSet) 206 | } 207 | m.statefulsetsRef[statefulset.APIVersionKindName] = &statefulset 208 | m.statefulsetsRef[statefulset.getKindName()] = &statefulset 209 | 210 | if len(statefulset.VolumeClaims) > 0 { 211 | m.VolumeClaims = append(m.VolumeClaims, statefulset.VolumeClaims...) 212 | } 213 | case DaemonSetKind: 214 | daemonset, err := buildDaemonSet(obj, groupVersionKind, conf) 215 | if err != nil { 216 | return err 217 | } 218 | m.DaemonSets = append(m.DaemonSets, &daemonset) 219 | case VolumeClaimKind: 220 | volume, err := buildVolumeClaim(obj, groupVersionKind, conf) 221 | if err != nil { 222 | return err 223 | } 224 | m.VolumeClaims = append(m.VolumeClaims, &volume) 225 | } 226 | 227 | return nil 228 | } 229 | -------------------------------------------------------------------------------- /api/manifests_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/google/go-cmp/cmp" 21 | ) 22 | 23 | func TestManifests(t *testing.T) { 24 | data := `apiVersion: autoscaling/v2beta2 25 | kind: HorizontalPodAutoscaler 26 | metadata: 27 | name: php-apache 28 | spec: 29 | maxReplicas: 20 30 | minReplicas: 10 31 | scaleTargetRef: 32 | apiVersion: apps/v1 33 | kind: Deployment 34 | name: php-apache 35 | metrics: 36 | - type: Resource 37 | resource: 38 | name: cpu 39 | target: 40 | type: Utilization 41 | averageUtilization: 60 42 | --- 43 | apiVersion: apps/v1beta1 44 | kind: Deployment 45 | metadata: 46 | name: my-nginx 47 | spec: 48 | template: 49 | metadata: 50 | labels: 51 | run: my-nginx 52 | spec: 53 | containers: 54 | - name: my-nginx 55 | image: nginx 56 | ports: 57 | - containerPort: 80 58 | --- 59 | apiVersion: v1 60 | kind: ConfigMap 61 | metadata: 62 | name: my-config 63 | --- 64 | anythingelse: not-a-k8s-object` 65 | 66 | manifests := Manifests{} 67 | manifests.LoadObjects([]byte(data), CostimatorConfig{}) 68 | 69 | if len(manifests.Deployments) != 1 || len(manifests.hpas) != 1 { 70 | t.Errorf("Incorrect number of objectx") 71 | } 72 | } 73 | 74 | func TestPrepareForCostEstimation(t *testing.T) { 75 | data := `apiVersion: autoscaling/v2beta2 76 | kind: HorizontalPodAutoscaler 77 | metadata: 78 | name: my-nginx 79 | spec: 80 | maxReplicas: 20 81 | minReplicas: 10 82 | scaleTargetRef: 83 | kind: Deployment 84 | name: my-nginx 85 | metrics: 86 | - type: Resource 87 | resource: 88 | name: cpu 89 | target: 90 | type: Utilization 91 | averageUtilization: 60 92 | --- 93 | apiVersion: apps/v1beta1 94 | kind: Deployment 95 | metadata: 96 | name: my-nginx 97 | spec: 98 | template: 99 | metadata: 100 | labels: 101 | run: my-nginx 102 | spec: 103 | containers: 104 | - name: my-nginx 105 | image: nginx 106 | ports: 107 | - containerPort: 80` 108 | 109 | manifests := Manifests{} 110 | manifests.LoadObjects([]byte(data), CostimatorConfig{}) 111 | manifests.prepareForCostEstimation() 112 | 113 | deploy := *manifests.Deployments[0] 114 | if deploy.hpa.TargetCPUPercentage != 60 { 115 | t.Errorf("Should have linked HPA to Deployment") 116 | } 117 | } 118 | 119 | func TestLoadObjectsFromPath(t *testing.T) { 120 | manifests := Manifests{} 121 | manifests.LoadObjectsFromPath("./testdata/manifests/", CostimatorConfig{}) 122 | if len(manifests.Deployments) != 2 || len(manifests.hpas) != 1 { 123 | t.Errorf("Incorrect number of objectx") 124 | } 125 | } 126 | 127 | func TestEstimateCost(t *testing.T) { 128 | data := `apiVersion: autoscaling/v2beta2 129 | kind: HorizontalPodAutoscaler 130 | metadata: 131 | name: my-nginx 132 | spec: 133 | maxReplicas: 20 134 | minReplicas: 10 135 | scaleTargetRef: 136 | kind: Deployment 137 | name: my-nginx 138 | metrics: 139 | - type: Resource 140 | resource: 141 | name: cpu 142 | target: 143 | type: Utilization 144 | averageUtilization: 60 145 | --- 146 | apiVersion: apps/v1beta1 147 | kind: Deployment 148 | metadata: 149 | name: my-nginx 150 | spec: 151 | template: 152 | metadata: 153 | labels: 154 | run: my-nginx 155 | spec: 156 | containers: 157 | - name: my-nginx 158 | image: nginx 159 | ports: 160 | - containerPort: 80 161 | resources: 162 | requests: 163 | memory: "8Gi" 164 | cpu: "2"` 165 | 166 | manifests := Manifests{} 167 | err := manifests.LoadObjects([]byte(data), CostimatorConfig{}) 168 | if err != nil { 169 | t.Errorf("Error loading objects: %+v", err) 170 | } 171 | 172 | mock := GCPPriceCatalog{cpuPrice: 16.227823, memoryPrice: 2.0257258e-09} 173 | cost := manifests.EstimateCost(mock) 174 | 175 | actualTotal := cost.MonthlyTotal() 176 | expectedTotal := CostRange{ 177 | Kind: "MonthlyTotal", 178 | MinRequested: 498.5649871826172, 179 | MaxRequested: 997.1299743652344, 180 | HPABuffer: 697.9909820556641, 181 | MinLimited: 1495.6949615478516, 182 | MaxLimited: 2991.389923095703, 183 | } 184 | if !cmp.Equal(actualTotal, expectedTotal) { 185 | t.Errorf("MonthlyTotal should be equal, expected: %+v, got: %+v", expectedTotal, actualTotal) 186 | } 187 | } 188 | 189 | func TestEstimateCostWithoutTargetUtilizaiton(t *testing.T) { 190 | data := `apiVersion: autoscaling/v2beta2 191 | kind: HorizontalPodAutoscaler 192 | metadata: 193 | name: my-nginx 194 | spec: 195 | maxReplicas: 20 196 | minReplicas: 10 197 | scaleTargetRef: 198 | kind: Deployment 199 | name: my-nginx 200 | --- 201 | apiVersion: apps/v1beta1 202 | kind: Deployment 203 | metadata: 204 | name: my-nginx 205 | spec: 206 | template: 207 | metadata: 208 | labels: 209 | run: my-nginx 210 | spec: 211 | containers: 212 | - name: my-nginx 213 | image: nginx 214 | ports: 215 | - containerPort: 80 216 | resources: 217 | requests: 218 | memory: "8Gi" 219 | cpu: "2"` 220 | 221 | manifests := Manifests{} 222 | err := manifests.LoadObjects([]byte(data), CostimatorConfig{}) 223 | if err != nil { 224 | t.Errorf("Error loading objects: %+v", err) 225 | } 226 | 227 | mock := GCPPriceCatalog{cpuPrice: 16.227823, memoryPrice: 2.0257258e-09} 228 | cost := manifests.EstimateCost(mock) 229 | 230 | actualTotal := cost.MonthlyTotal() 231 | expectedTotal := CostRange{ 232 | Kind: "MonthlyTotal", 233 | MinRequested: 498.5649871826172, 234 | MaxRequested: 997.1299743652344, 235 | HPABuffer: 498.5649871826172, 236 | MinLimited: 1495.6949615478516, 237 | MaxLimited: 2991.389923095703, 238 | } 239 | if !cmp.Equal(actualTotal, expectedTotal) { 240 | t.Errorf("MonthlyTotal should be equal, expected: %+v, got: %+v", expectedTotal, actualTotal) 241 | } 242 | } 243 | 244 | func TestEstimateCostManyDeployments(t *testing.T) { 245 | data := `apiVersion: autoscaling/v2beta2 246 | kind: HorizontalPodAutoscaler 247 | metadata: 248 | name: my-nginx 249 | spec: 250 | maxReplicas: 20 251 | minReplicas: 10 252 | scaleTargetRef: 253 | kind: Deployment 254 | name: my-nginx 255 | metrics: 256 | - type: Resource 257 | resource: 258 | name: cpu 259 | target: 260 | type: Utilization 261 | averageUtilization: 60 262 | --- 263 | apiVersion: apps/v1beta1 264 | kind: Deployment 265 | metadata: 266 | name: my-nginx 267 | spec: 268 | template: 269 | metadata: 270 | labels: 271 | run: my-nginx 272 | spec: 273 | containers: 274 | - name: my-nginx 275 | image: nginx 276 | ports: 277 | - containerPort: 80 278 | resources: 279 | requests: 280 | memory: "8Gi" 281 | cpu: "2" 282 | --- 283 | apiVersion: apps/v1 284 | kind: ReplicaSet 285 | metadata: 286 | name: frontend 287 | spec: 288 | selector: 289 | matchLabels: 290 | tier: frontend 291 | template: 292 | metadata: 293 | labels: 294 | tier: frontend 295 | spec: 296 | containers: 297 | - name: php-redis 298 | image: gcr.io/google_samples/gb-frontend:v3 299 | resources: 300 | requests: 301 | memory: "8Gi" 302 | cpu: "2"` 303 | 304 | manifests := Manifests{} 305 | err := manifests.LoadObjects([]byte(data), CostimatorConfig{}) 306 | if err != nil { 307 | t.Errorf("Error loading objects: %+v", err) 308 | } 309 | 310 | mock := GCPPriceCatalog{cpuPrice: 16.227823, memoryPrice: 2.0257258e-09} 311 | cost := manifests.EstimateCost(mock) 312 | 313 | actualTotal := cost.MonthlyTotal() 314 | expectedTotal := CostRange{ 315 | Kind: "MonthlyTotal", 316 | MinRequested: 498.5649871826172 + 49.85649871826172, 317 | MaxRequested: 997.1299743652344 + 49.85649871826172, 318 | HPABuffer: 697.9909820556641 + 49.85649871826172, 319 | MinLimited: 1495.6949615478516 + (49.85649871826172 * 3), 320 | MaxLimited: 2991.389923095703 + (49.85649871826172 * 3), 321 | } 322 | if !cmp.Equal(actualTotal, expectedTotal) { 323 | t.Errorf("MonthlyTotal should be equal, expected: %+v, got: %+v", expectedTotal, actualTotal) 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /api/resource_price.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "strings" 21 | 22 | billing "cloud.google.com/go/billing/apiv1" 23 | "google.golang.org/api/iterator" 24 | "google.golang.org/api/option" 25 | billingpb "google.golang.org/genproto/googleapis/cloud/billing/v1" 26 | ) 27 | 28 | var cpuPrefixes = map[MachineFamily]string{ 29 | N1: "N1 Predefined Instance Core", 30 | N2: "N2 Instance Core", 31 | E2: "E2 Instance Core", 32 | N2D: "N2D AMD Custom Instance Core", 33 | } 34 | 35 | var memoryPrefixes = map[MachineFamily]string{ 36 | N1: "N1 Predefined Instance Ram", 37 | N2: "N2 Instance Ram", 38 | E2: "E2 Instance Ram", 39 | N2D: "N2D AMD Instance Ram", 40 | } 41 | 42 | var pdStandardPrefix = "Regional Storage PD Capacity" 43 | 44 | // NewGCPPriceCatalog creates a gcpResourcePrice struct with Monthly prices for cpu and memory 45 | // If credentials is nil, then the default service account will be used 46 | func NewGCPPriceCatalog(credentials []byte, conf CostimatorConfig) (GCPPriceCatalog, error) { 47 | conf = populateConfigNotProvided(conf) 48 | var client *billing.CloudCatalogClient 49 | var err error 50 | if credentials == nil { 51 | client, err = billing.NewCloudCatalogClient(context.Background()) 52 | } else { 53 | client, err = billing.NewCloudCatalogClient(context.Background(), option.WithCredentialsJSON(credentials)) 54 | } 55 | if err != nil { 56 | return GCPPriceCatalog{}, err 57 | } 58 | return retrievePrices(client, conf) 59 | } 60 | 61 | func retrievePrices(client *billing.CloudCatalogClient, conf CostimatorConfig) (GCPPriceCatalog, error) { 62 | skuIter, err := retrieveAllSKUs(client) 63 | 64 | var cpuPi, memoryPi, storagePdPi *billingpb.PricingInfo 65 | for { 66 | sku, err := skuIter.Next() 67 | if err == iterator.Done || 68 | (cpuPi != nil && memoryPi != nil && storagePdPi != nil) { 69 | break 70 | } 71 | if err != nil { 72 | return GCPPriceCatalog{}, err 73 | } 74 | 75 | if cpuPi == nil && matchCPU(sku, conf) { 76 | cpuPi = sku.GetPricingInfo()[0] 77 | } else if memoryPi == nil && matchMemory(sku, conf) { 78 | memoryPi = sku.GetPricingInfo()[0] 79 | } else if storagePdPi == nil && matchGCEPersistentDisk(sku, conf) { 80 | storagePdPi = sku.GetPricingInfo()[0] 81 | } 82 | } 83 | if err == nil && (cpuPi == nil || memoryPi == nil || storagePdPi == nil) { 84 | return GCPPriceCatalog{}, fmt.Errorf("Couldn't find all Price Infos: %+v", conf) 85 | } 86 | 87 | cpuPrice, err := calculateMonthlyPrice(cpuPi) 88 | if err != nil { 89 | return GCPPriceCatalog{}, err 90 | } 91 | memoryPrice, err := calculateMonthlyPrice(memoryPi) 92 | if err != nil { 93 | return GCPPriceCatalog{}, err 94 | } 95 | pdStandardPrice, err := calculateMonthlyPrice(storagePdPi) 96 | if err != nil { 97 | return GCPPriceCatalog{}, err 98 | } 99 | return GCPPriceCatalog{ 100 | cpuPrice: cpuPrice, 101 | memoryPrice: memoryPrice, 102 | pdStandardPrice: pdStandardPrice}, nil 103 | } 104 | 105 | func retrieveAllSKUs(client *billing.CloudCatalogClient) (*billing.SkuIterator, error) { 106 | ctx := context.Background() 107 | req := &billingpb.ListSkusRequest{ 108 | Parent: "services/6F81-5844-456A", 109 | } 110 | return client.ListSkus(ctx, req), nil 111 | } 112 | 113 | func matchCPU(sku *billingpb.Sku, conf CostimatorConfig) bool { 114 | prefix, _ := cpuPrefixes[conf.ResourceConf.MachineFamily] 115 | return skuMatcher(sku, prefix, conf) 116 | } 117 | 118 | func matchMemory(sku *billingpb.Sku, conf CostimatorConfig) bool { 119 | prefix, _ := memoryPrefixes[conf.ResourceConf.MachineFamily] 120 | return skuMatcher(sku, prefix, conf) 121 | } 122 | 123 | func matchGCEPersistentDisk(sku *billingpb.Sku, conf CostimatorConfig) bool { 124 | return skuMatcher(sku, pdStandardPrefix, conf) 125 | } 126 | 127 | func skuMatcher(sku *billingpb.Sku, skuPrefix string, conf CostimatorConfig) bool { 128 | return strings.HasPrefix(sku.GetDescription(), skuPrefix) && 129 | contains(sku.GetServiceRegions(), conf.ResourceConf.Region) 130 | } 131 | 132 | func calculateMonthlyPrice(pi *billingpb.PricingInfo) (float32, error) { 133 | pe := pi.GetPricingExpression() 134 | pu := pe.GetTieredRates()[0].GetUnitPrice() 135 | 136 | unit := pe.GetUsageUnit() 137 | switch unit { 138 | case "h": 139 | // 1 vcpu core per hour rate 140 | hourlyPrice := float32(pu.GetUnits()) + float32(pu.GetNanos())/1000000000.0 141 | return hourlyPrice * float32(24) * float32(31), nil 142 | case "GiBy.h": 143 | // 1 Byte per hour pricing 144 | hourlyPrice := (float32(pu.GetUnits()) + float32(pu.GetNanos())/1000000000.0) / (1024 * 1024 * 1024) 145 | return hourlyPrice * float32(24) * float32(31), nil 146 | case "GiBy.mo": 147 | // 1 Byte per month pricing 148 | monthlyPrice := (float32(pu.GetUnits()) + float32(pu.GetNanos())/1000000000.0) / (1024 * 1024 * 1024) 149 | return monthlyPrice, nil 150 | default: 151 | return 0, fmt.Errorf("Price UsageUnit Not implemented: %s", unit) 152 | } 153 | } 154 | 155 | func contains(items []string, s string) bool { 156 | for _, item := range items { 157 | if strings.EqualFold(item, s) { 158 | return true 159 | } 160 | } 161 | return false 162 | } 163 | -------------------------------------------------------------------------------- /api/resource_price_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "io/ioutil" 19 | "testing" 20 | ) 21 | 22 | func TestResourcePrice(t *testing.T) { 23 | credentials, err := ioutil.ReadFile("./testdata/credentials.json") 24 | if err != nil { 25 | t.Logf("No credentials found in ./testdata/credentials.json, using default service account.") 26 | } 27 | 28 | rp, err := NewGCPPriceCatalog(credentials, CostimatorConfig{}) 29 | if err != nil || rp.CPUMonthlyPrice() == 0 || rp.MemoryMonthlyPrice() == 0 || rp.PdStandardMonthlyPrice() == 0 { 30 | t.Errorf("Error calling GCP. Make sure you have download your service account to ./testdata/credentials.json "+ 31 | "or run 'gcloud auth application-default login; gcloud services enable cloudbilling.googleapis.com' prior "+ 32 | "executing this specific test. Note enabling billing api can take some time. Cause: %+v", err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/testdata/manifests/nginx.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: autoscaling/v2beta2 16 | kind: HorizontalPodAutoscaler 17 | metadata: 18 | name: my-nginx 19 | spec: 20 | maxReplicas: 20 21 | minReplicas: 10 22 | scaleTargetRef: 23 | kind: Deployment 24 | name: my-nginx 25 | metrics: 26 | - type: Resource 27 | resource: 28 | name: cpu 29 | target: 30 | type: Utilization 31 | averageUtilization: 60 32 | --- 33 | apiVersion: apps/v1beta1 34 | kind: Deployment 35 | metadata: 36 | name: my-nginx 37 | spec: 38 | template: 39 | metadata: 40 | labels: 41 | run: my-nginx 42 | spec: 43 | containers: 44 | - name: my-nginx 45 | image: nginx 46 | ports: 47 | - containerPort: 80 -------------------------------------------------------------------------------- /api/testdata/manifests/test.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: apps/v1beta1 16 | kind: Deployment 17 | metadata: 18 | name: test 19 | spec: 20 | replicas: 4 21 | template: 22 | metadata: 23 | labels: 24 | run: test 25 | spec: 26 | containers: 27 | - name: test 28 | image: nginx 29 | ports: 30 | - containerPort: 80 31 | resources: 32 | requests: 33 | memory: "64Mi" 34 | cpu: "250m" 35 | limits: 36 | memory: "64M" 37 | cpu: 1 -------------------------------------------------------------------------------- /api/types.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | "strings" 20 | 21 | "github.com/fernandorubbo/k8s-cost-estimator/util" 22 | log "github.com/sirupsen/logrus" 23 | "gopkg.in/yaml.v2" 24 | coreV1 "k8s.io/api/core/v1" 25 | ) 26 | 27 | const ( 28 | // HPAKind just to avoid mispeling 29 | HPAKind = "HorizontalPodAutoscaler" 30 | // DeploymentKind is just to avoid mispeling 31 | DeploymentKind = "Deployment" 32 | // ReplicaSetKind is just to avoid mispeling 33 | ReplicaSetKind = "ReplicaSet" 34 | // StatefulSetKind is just to avoid mispeling 35 | StatefulSetKind = "StatefulSet" 36 | // DaemonSetKind is just to avoid mispeling 37 | DaemonSetKind = "DaemonSet" 38 | // VolumeClaimKind is just to avoid mispeling 39 | VolumeClaimKind = "PersistentVolumeClaim" 40 | ) 41 | 42 | // SupportedKinds groups all supported kinds 43 | var SupportedKinds = []string{HPAKind, DeploymentKind, ReplicaSetKind, StatefulSetKind, DaemonSetKind, VolumeClaimKind} 44 | 45 | // GroupVersionKind is the reprsentation of k8s type 46 | // This object is used to to avoid sprawl of dependent library (eg. apimachinary) across the code 47 | // This will allow easy migration to others library (eg. kyaml) in the future once the dependency is all encapulated into k8s_decoder.go 48 | type GroupVersionKind struct { 49 | Group string 50 | Version string 51 | Kind string 52 | } 53 | 54 | // HPA is the simplified reprsentation of k8s HPA 55 | // Client doesn't need to handle different version and the complexity of k8s.io package 56 | type HPA struct { 57 | APIVersionKindName string 58 | TargetRef string 59 | MinReplicas int32 60 | MaxReplicas int32 61 | TargetCPUPercentage int32 62 | } 63 | 64 | // HorizontalScalableResource is a Horizontal Scalable Resource 65 | // Implemented by Deployment, ReplicaSet and StatefulSet 66 | type HorizontalScalableResource interface { 67 | getContainers() []Container 68 | getReplicas() int32 69 | hasHPA() bool 70 | getHPA() HPA 71 | } 72 | 73 | // Deployment is the simplified reprsentation of k8s deployment 74 | // Client doesn't need to handle different version and the complexity of k8s.io package 75 | type Deployment struct { 76 | APIVersionKindName string 77 | Replicas int32 78 | Containers []Container 79 | hpa HPA 80 | } 81 | 82 | func (d *Deployment) estimateCost(rp ResourcePrice) CostRange { 83 | return estimateCost(DeploymentKind, d, rp) 84 | } 85 | 86 | func (d *Deployment) getKindName() string { 87 | return buildKindName(d.APIVersionKindName) 88 | } 89 | 90 | func (d *Deployment) getContainers() []Container { 91 | return d.Containers 92 | } 93 | 94 | func (d *Deployment) getReplicas() int32 { 95 | return d.Replicas 96 | } 97 | 98 | func (d *Deployment) hasHPA() bool { 99 | return d.hpa.APIVersionKindName != "" 100 | } 101 | 102 | func (d *Deployment) getHPA() HPA { 103 | return d.hpa 104 | } 105 | 106 | // ReplicaSet is the simplified reprsentation of k8s replicaset 107 | // Client doesn't need to handle different version and the complexity of k8s.io package 108 | type ReplicaSet struct { 109 | APIVersionKindName string 110 | Replicas int32 111 | Containers []Container 112 | hpa HPA 113 | } 114 | 115 | func (r *ReplicaSet) estimateCost(rp ResourcePrice) CostRange { 116 | return estimateCost(ReplicaSetKind, r, rp) 117 | } 118 | 119 | func (r *ReplicaSet) getKindName() string { 120 | return buildKindName(r.APIVersionKindName) 121 | } 122 | 123 | func (r *ReplicaSet) getContainers() []Container { 124 | return r.Containers 125 | } 126 | 127 | func (r *ReplicaSet) getReplicas() int32 { 128 | return r.Replicas 129 | } 130 | 131 | func (r *ReplicaSet) hasHPA() bool { 132 | return r.hpa.APIVersionKindName != "" 133 | } 134 | 135 | func (r *ReplicaSet) getHPA() HPA { 136 | return r.hpa 137 | } 138 | 139 | // StatefulSet is the simplified reprsentation of k8s StatefulSet 140 | // Client doesn't need to handle different version and the complexity of k8s.io package 141 | type StatefulSet struct { 142 | APIVersionKindName string 143 | Replicas int32 144 | Containers []Container 145 | hpa HPA 146 | VolumeClaims []*VolumeClaim 147 | } 148 | 149 | func (s *StatefulSet) estimateCost(rp ResourcePrice) CostRange { 150 | return estimateCost(StatefulSetKind, s, rp) 151 | } 152 | 153 | func (s *StatefulSet) getKindName() string { 154 | return buildKindName(s.APIVersionKindName) 155 | } 156 | 157 | func (s *StatefulSet) getContainers() []Container { 158 | return s.Containers 159 | } 160 | 161 | func (s *StatefulSet) getReplicas() int32 { 162 | return s.Replicas 163 | } 164 | 165 | func (s *StatefulSet) hasHPA() bool { 166 | return s.hpa.APIVersionKindName != "" 167 | } 168 | 169 | func (s *StatefulSet) getHPA() HPA { 170 | return s.hpa 171 | } 172 | 173 | // DaemonSet is the simplified reprsentation of k8s DaemonSet 174 | // Client doesn't need to handle different version and the complexity of k8s.io package 175 | type DaemonSet struct { 176 | APIVersionKindName string 177 | NodesCount int32 178 | Containers []Container 179 | } 180 | 181 | func (d *DaemonSet) estimateCost(rp ResourcePrice) CostRange { 182 | cost := CostRange{Kind: DaemonSetKind} 183 | cpuReq, cpuLim, memReq, memLim := totalContainers(d.Containers) 184 | 185 | var cpuMonthlyPrice = float64(rp.CPUMonthlyPrice()) 186 | var memoryMonthlyPrice = float64(rp.MemoryMonthlyPrice()) 187 | 188 | nodesCount := float64(d.NodesCount) 189 | cost.MinRequested = (nodesCount * cpuReq * cpuMonthlyPrice) + (nodesCount * memReq * memoryMonthlyPrice) 190 | cost.MaxRequested = cost.MinRequested 191 | cost.HPABuffer = cost.MinRequested 192 | cost.MinLimited = (nodesCount * cpuLim * cpuMonthlyPrice) + (nodesCount * memLim * memoryMonthlyPrice) 193 | cost.MaxLimited = cost.MinLimited 194 | 195 | return postProcessCost(cost) 196 | } 197 | 198 | // VolumeClaim is the simplified reprsentation of k8s VolumeClaim 199 | // Client doesn't need to handle different version and the complexity of k8s.io package 200 | type VolumeClaim struct { 201 | APIVersionKindName string 202 | StorageClass string 203 | Requests Resource 204 | Limits Resource 205 | } 206 | 207 | func (v *VolumeClaim) estimateCost(sp StoragePrice) CostRange { 208 | storageMonthlyPrice := float64(sp.PdStandardMonthlyPrice()) 209 | storageClass := v.StorageClass 210 | if storageClass != storageClassStandard { 211 | log.Infof("Estimation for StorageClass '%s' not implemented for PersistentVolumeClaim. Using standard (GCE Regional Persistent Disk) instead", storageClass) 212 | storageMonthlyPrice = float64(sp.PdStandardMonthlyPrice()) 213 | } 214 | 215 | cost := CostRange{Kind: VolumeClaimKind} 216 | cost.MinRequested = (float64(v.Requests.Storage) * storageMonthlyPrice) 217 | cost.MaxRequested = cost.MinRequested 218 | cost.HPABuffer = cost.MinRequested 219 | cost.MinLimited = (float64(v.Limits.Storage) * storageMonthlyPrice) 220 | cost.MaxLimited = cost.MinLimited 221 | 222 | return postProcessCost(cost) 223 | } 224 | 225 | // Container is the simplified representation of k8s Container 226 | // Client doesn't need to handle different version and the complexity of k8s.io package 227 | type Container struct { 228 | Requests Resource 229 | Limits Resource 230 | } 231 | 232 | // Resource is the simplified reprsentation of k8s Resource 233 | // Client doesn't need to handle different version and the complexity of k8s.io package 234 | type Resource struct { 235 | CPU int64 236 | Memory int64 237 | Storage int64 238 | } 239 | 240 | // -------- Price Catalog --------- 241 | 242 | //ResourcePrice interface 243 | type ResourcePrice interface { 244 | CPUMonthlyPrice() float32 245 | MemoryMonthlyPrice() float32 246 | } 247 | 248 | //StoragePrice interface 249 | type StoragePrice interface { 250 | PdStandardMonthlyPrice() float32 251 | } 252 | 253 | //GCPPriceCatalog implementation to make call to GCP CloudCatalog 254 | type GCPPriceCatalog struct { 255 | cpuPrice float32 256 | memoryPrice float32 257 | pdStandardPrice float32 258 | } 259 | 260 | // CPUMonthlyPrice returns the GCP CPU price in USD 261 | func (pc *GCPPriceCatalog) CPUMonthlyPrice() float32 { 262 | return pc.cpuPrice 263 | } 264 | 265 | // MemoryMonthlyPrice returns the GCP Memory price in USD 266 | func (pc *GCPPriceCatalog) MemoryMonthlyPrice() float32 { 267 | return pc.memoryPrice 268 | } 269 | 270 | // PdStandardMonthlyPrice returns the GCP Storage PD price in USD 271 | func (pc *GCPPriceCatalog) PdStandardMonthlyPrice() float32 { 272 | return pc.pdStandardPrice 273 | } 274 | 275 | // --- utility functions --- 276 | 277 | func buildAPIVersionKindName(apiVersion, kind, ns, name string) string { 278 | namespace := "default" 279 | if ns != "" { 280 | namespace = ns 281 | } 282 | return fmt.Sprintf("%s|%s|%s|%s", apiVersion, kind, namespace, name) 283 | } 284 | 285 | func buildKindName(apiVersionKindName string) string { 286 | index := strings.Index(apiVersionKindName, "|") 287 | return apiVersionKindName[index:] 288 | } 289 | 290 | func estimateCost(kind string, r HorizontalScalableResource, rp ResourcePrice) CostRange { 291 | cost := CostRange{Kind: kind} 292 | cpuReq, cpuLim, memReq, memLim := totalContainers(r.getContainers()) 293 | 294 | var cpuMonthlyPrice = float64(rp.CPUMonthlyPrice()) 295 | var memoryMonthlyPrice = float64(rp.MemoryMonthlyPrice()) 296 | 297 | if r.hasHPA() { 298 | hpa := r.getHPA() 299 | targetCPUPercentage := hpa.TargetCPUPercentage 300 | minReplicas := float64(hpa.MinReplicas) 301 | maxReplicas := float64(hpa.MaxReplicas) 302 | 303 | cost.MinRequested = (minReplicas * cpuReq * cpuMonthlyPrice) + (minReplicas * memReq * memoryMonthlyPrice) 304 | cost.MaxRequested = (maxReplicas * cpuReq * cpuMonthlyPrice) + (maxReplicas * memReq * memoryMonthlyPrice) 305 | 306 | cpuBuffer := minReplicas 307 | if targetCPUPercentage > 0 { 308 | buff := float64(100-targetCPUPercentage) / 100 309 | cpuBuffer = minReplicas + (buff * minReplicas) 310 | } 311 | cost.HPABuffer = (cpuBuffer * cpuReq * cpuMonthlyPrice) + (cpuBuffer * memReq * memoryMonthlyPrice) 312 | 313 | cost.MinLimited = (minReplicas * cpuLim * cpuMonthlyPrice) + (minReplicas * memLim * memoryMonthlyPrice) 314 | cost.MaxLimited = (maxReplicas * cpuLim * cpuMonthlyPrice) + (maxReplicas * memLim * memoryMonthlyPrice) 315 | 316 | } else { 317 | replicas := float64(r.getReplicas()) 318 | cost.MinRequested = (replicas * cpuReq * cpuMonthlyPrice) + (replicas * memReq * memoryMonthlyPrice) 319 | cost.MaxRequested = cost.MinRequested 320 | cost.HPABuffer = cost.MinRequested 321 | cost.MinLimited = (replicas * cpuLim * cpuMonthlyPrice) + (replicas * memLim * memoryMonthlyPrice) 322 | cost.MaxLimited = cost.MinLimited 323 | } 324 | 325 | return postProcessCost(cost) 326 | } 327 | 328 | func postProcessCost(cost CostRange) CostRange { 329 | // just to make sure limit will not be smaller than requested 330 | if cost.MinLimited < cost.MinRequested { 331 | cost.MinLimited = cost.MinRequested 332 | } 333 | if cost.MaxLimited < cost.MaxRequested { 334 | cost.MaxLimited = cost.MaxRequested 335 | } 336 | return cost 337 | } 338 | 339 | func buildContainers(cont []coreV1.Container, conf CostimatorConfig) []Container { 340 | containers := []Container{} 341 | for i := 0; i < len(cont); i++ { 342 | requests := cont[i].Resources.Requests 343 | requestsCPU := requests[coreV1.ResourceCPU] 344 | requestsMemory := requests[coreV1.ResourceMemory] 345 | limits := cont[i].Resources.Limits 346 | limitsCPU := limits[coreV1.ResourceCPU] 347 | limitsMemory := limits[coreV1.ResourceMemory] 348 | 349 | requestsCPUinMilli := requestsCPU.MilliValue() 350 | requestsMemoryinMilli := requestsMemory.Value() 351 | limitsCPUinMilli := limitsCPU.MilliValue() 352 | limitsMemoryinMilli := limitsMemory.Value() 353 | // If Requests is omitted for a container, it defaults to Limits if that is explicitly specified 354 | if requestsCPUinMilli == 0 { 355 | requestsCPUinMilli = limitsCPUinMilli 356 | } 357 | if requestsMemoryinMilli == 0 { 358 | requestsMemoryinMilli = limitsMemoryinMilli 359 | } 360 | // otherwise to an config-defined value. 361 | if requestsCPUinMilli == 0 { 362 | requestsCPUinMilli = conf.ResourceConf.DefaultCPUinMillis 363 | } 364 | if requestsMemoryinMilli == 0 { 365 | requestsMemoryinMilli = conf.ResourceConf.DefaultMemoryinBytes 366 | } 367 | // Give a percentage increase for umbounded resources 368 | if limitsCPUinMilli == 0 { 369 | limitsCPUinMilli = requestsCPUinMilli + (conf.ResourceConf.PercentageIncreaseForUnboundedRerouces * requestsCPUinMilli / 100) 370 | } 371 | if limitsMemoryinMilli == 0 { 372 | limitsMemoryinMilli = requestsMemoryinMilli + (conf.ResourceConf.PercentageIncreaseForUnboundedRerouces * requestsMemoryinMilli / 100) 373 | } 374 | 375 | container := Container{ 376 | Requests: Resource{ 377 | CPU: requestsCPUinMilli, 378 | Memory: requestsMemoryinMilli, 379 | }, 380 | Limits: Resource{ 381 | CPU: limitsCPUinMilli, 382 | Memory: limitsMemoryinMilli, 383 | }, 384 | } 385 | containers = append(containers, container) 386 | } 387 | return containers 388 | } 389 | 390 | func totalContainers(containers []Container) (cpuReq float64, cpuLim float64, memReq float64, memLim float64) { 391 | for _, container := range containers { 392 | cpuReq = cpuReq + float64(container.Requests.CPU) 393 | cpuLim = cpuLim + float64(container.Limits.CPU) 394 | memReq = memReq + float64(container.Requests.Memory) // bytes 395 | memLim = memLim + float64(container.Limits.Memory) // bytes 396 | } 397 | cpuReq = cpuReq / 1000 // from milis to # of cores 398 | cpuLim = cpuLim / 1000 // from milis to # of cores 399 | return 400 | } 401 | 402 | func isObjectSupported(data []byte) (string, bool) { 403 | ak := struct { 404 | APIVersion string `yaml:"apiVersion,omitempty"` 405 | Kind string `yaml:"kind,omitempty"` 406 | }{} 407 | err := yaml.Unmarshal(data, &ak) 408 | if err != nil { 409 | return fmt.Sprintf("%+v", ak), false 410 | } 411 | return fmt.Sprintf("%+v", ak), isKindSupported(ak.Kind) 412 | } 413 | 414 | func isKindSupported(kind string) bool { 415 | return util.Contains(SupportedKinds, kind) 416 | } 417 | -------------------------------------------------------------------------------- /api/types_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package api 16 | 17 | import "testing" 18 | 19 | func TestDeploymentGetKindName(t *testing.T) { 20 | d := Deployment{APIVersionKindName: "version|kind|namespace|name"} 21 | want := "|kind|namespace|name" 22 | if got := d.getKindName(); got != want { 23 | t.Errorf("Deployment.getKindName() = %v, want %v", got, want) 24 | } 25 | } 26 | 27 | func TestDeploymentEstimateCostWithoutHPA(t *testing.T) { 28 | rp := &GCPPriceCatalog{ 29 | cpuPrice: 4, 30 | memoryPrice: 2, 31 | } 32 | deploy := Deployment{ 33 | Replicas: 2, 34 | Containers: []Container{ 35 | { 36 | Requests: Resource{ 37 | CPU: 1000, // 1 vCPU 38 | Memory: 10000, // bytes 39 | }, 40 | Limits: Resource{ 41 | CPU: 2000, // 2 vCPU 42 | Memory: 20000, // bytes 43 | }, 44 | }, 45 | }, 46 | hpa: HPA{}, 47 | } 48 | cr := deploy.estimateCost(rp) 49 | 50 | want := DeploymentKind 51 | if got := cr.Kind; got != want { 52 | t.Errorf("Kind is %v, want %v", got, want) 53 | } 54 | 55 | cost := (4.0 + 20000.0) * 2 56 | if got := cr.MinRequested; got != cost { 57 | t.Errorf("MinRequested is %v, want %v", got, cost) 58 | } 59 | if got := cr.MaxRequested; got != cost { 60 | t.Errorf("MaxRequested is %v, want %v", got, cost) 61 | } 62 | if got := cr.HPABuffer; got != cost { 63 | t.Errorf("HPABuffer is %v, want %v", got, cost) 64 | } 65 | 66 | cost = (8.0 + 40000.0) * 2 67 | if got := cr.MinLimited; got != cost { 68 | t.Errorf("MinLimited is %v, want %v", got, cost) 69 | } 70 | if got := cr.MaxLimited; got != cost { 71 | t.Errorf("MaxLimited is %v, want %v", got, cost) 72 | } 73 | } 74 | 75 | func TestDeploymentEstimateCostWithoutHpaNoLimits(t *testing.T) { 76 | rp := &GCPPriceCatalog{ 77 | cpuPrice: 4, 78 | memoryPrice: 2, 79 | } 80 | deploy := Deployment{ 81 | Replicas: 2, 82 | Containers: []Container{ 83 | { 84 | Requests: Resource{ 85 | CPU: 1000, // 1 vCPU 86 | Memory: 10000, // bytes 87 | }, 88 | Limits: Resource{ 89 | CPU: 0, // 0 vCPU 90 | Memory: 0, // bytes 91 | }, 92 | }, 93 | }, 94 | hpa: HPA{}, 95 | } 96 | cr := deploy.estimateCost(rp) 97 | 98 | want := DeploymentKind 99 | if got := cr.Kind; got != want { 100 | t.Errorf("Kind is %v, want %v", got, want) 101 | } 102 | 103 | cost := (4.0 + 20000.0) * 2 104 | if got := cr.MinRequested; got != cost { 105 | t.Errorf("MinRequested is %v, want %v", got, cost) 106 | } 107 | if got := cr.MaxRequested; got != cost { 108 | t.Errorf("MaxRequested is %v, want %v", got, cost) 109 | } 110 | if got := cr.HPABuffer; got != cost { 111 | t.Errorf("HPABuffer is %v, want %v", got, cost) 112 | } 113 | if got := cr.MinLimited; got != cost { 114 | t.Errorf("MinLimited is %v, want %v", got, cost) 115 | } 116 | if got := cr.MaxLimited; got != cost { 117 | t.Errorf("MaxLimited is %v, want %v", got, cost) 118 | } 119 | } 120 | func TestDeploymentEstimateCostWithHPA(t *testing.T) { 121 | rp := &GCPPriceCatalog{ 122 | cpuPrice: 4, 123 | memoryPrice: 2, 124 | } 125 | deploy := Deployment{ 126 | Replicas: 2, 127 | Containers: []Container{ 128 | { 129 | Requests: Resource{ 130 | CPU: 1000, // 1 vCPU 131 | Memory: 10000, // bytes 132 | }, 133 | Limits: Resource{ 134 | CPU: 2000, // 2 vCPU 135 | Memory: 20000, // bytes 136 | }, 137 | }, 138 | }, 139 | hpa: HPA{ 140 | APIVersionKindName: "HPA", 141 | MinReplicas: 1, 142 | MaxReplicas: 3, 143 | TargetCPUPercentage: 60}, 144 | } 145 | cr := deploy.estimateCost(rp) 146 | 147 | want := DeploymentKind 148 | if got := cr.Kind; got != want { 149 | t.Errorf("Kind is %v, want %v", got, want) 150 | } 151 | 152 | minRequested := 4.0 + 20000.0 153 | if got := cr.MinRequested; got != minRequested { 154 | t.Errorf("MinRequested is %v, want %v", got, minRequested) 155 | } 156 | maxRequested := (4.0 + 20000.0) * 3 157 | if got := cr.MaxRequested; got != maxRequested { 158 | t.Errorf("MaxRequested is %v, want %v", got, maxRequested) 159 | } 160 | hpaBuffer := cr.MinRequested + (cr.MinRequested * 0.4) 161 | if got := cr.HPABuffer; got != hpaBuffer { 162 | t.Errorf("HPABuffer is %v, want %v", got, hpaBuffer) 163 | } 164 | 165 | minLimited := 8.0 + 40000.0 166 | if got := cr.MinLimited; got != minLimited { 167 | t.Errorf("MinLimited is %v, want %v", got, minLimited) 168 | } 169 | maxLimited := (8.0 + 40000.0) * 3.0 170 | if got := cr.MaxLimited; got != maxLimited { 171 | t.Errorf("MaxLimited is %v, want %v", got, maxLimited) 172 | } 173 | } 174 | 175 | func TestStatefulSetGetKindName(t *testing.T) { 176 | s := StatefulSet{APIVersionKindName: "version|kind|namespace|name"} 177 | want := "|kind|namespace|name" 178 | if got := s.getKindName(); got != want { 179 | t.Errorf("StatefulSet.getKindName() = %v, want %v", got, want) 180 | } 181 | } 182 | 183 | func TestStatefulEstimateCostWithoutHPA(t *testing.T) { 184 | rp := &GCPPriceCatalog{ 185 | cpuPrice: 4, 186 | memoryPrice: 2, 187 | } 188 | statefulset := StatefulSet{ 189 | Replicas: 2, 190 | Containers: []Container{ 191 | { 192 | Requests: Resource{ 193 | CPU: 1000, // 1 vCPU 194 | Memory: 10000, // bytes 195 | }, 196 | Limits: Resource{ 197 | CPU: 2000, // 2 vCPU 198 | Memory: 20000, // bytes 199 | }, 200 | }, 201 | }, 202 | hpa: HPA{}, 203 | } 204 | cr := statefulset.estimateCost(rp) 205 | 206 | want := StatefulSetKind 207 | if got := cr.Kind; got != want { 208 | t.Errorf("Kind is %v, want %v", got, want) 209 | } 210 | 211 | cost := (4.0 + 20000.0) * 2 212 | if got := cr.MinRequested; got != cost { 213 | t.Errorf("MinRequested is %v, want %v", got, cost) 214 | } 215 | if got := cr.MaxRequested; got != cost { 216 | t.Errorf("MaxRequested is %v, want %v", got, cost) 217 | } 218 | if got := cr.HPABuffer; got != cost { 219 | t.Errorf("HPABuffer is %v, want %v", got, cost) 220 | } 221 | 222 | cost = (8.0 + 40000.0) * 2 223 | if got := cr.MinLimited; got != cost { 224 | t.Errorf("MinLimited is %v, want %v", got, cost) 225 | } 226 | if got := cr.MaxLimited; got != cost { 227 | t.Errorf("MaxLimited is %v, want %v", got, cost) 228 | } 229 | } 230 | 231 | func TestStatefulEstimateCostWithHPA(t *testing.T) { 232 | rp := &GCPPriceCatalog{ 233 | cpuPrice: 4, 234 | memoryPrice: 2, 235 | } 236 | statefulset := StatefulSet{ 237 | Replicas: 2, 238 | Containers: []Container{ 239 | { 240 | Requests: Resource{ 241 | CPU: 1000, // 1 vCPU 242 | Memory: 10000, // bytes 243 | }, 244 | Limits: Resource{ 245 | CPU: 2000, // 2 vCPU 246 | Memory: 20000, // bytes 247 | }, 248 | }, 249 | }, 250 | hpa: HPA{}, 251 | } 252 | cr := statefulset.estimateCost(rp) 253 | 254 | want := StatefulSetKind 255 | if got := cr.Kind; got != want { 256 | t.Errorf("Kind is %v, want %v", got, want) 257 | } 258 | 259 | cost := (4.0 + 20000.0) * 2 260 | if got := cr.MinRequested; got != cost { 261 | t.Errorf("MinRequested is %v, want %v", got, cost) 262 | } 263 | if got := cr.MaxRequested; got != cost { 264 | t.Errorf("MaxRequested is %v, want %v", got, cost) 265 | } 266 | if got := cr.HPABuffer; got != cost { 267 | t.Errorf("HPABuffer is %v, want %v", got, cost) 268 | } 269 | 270 | cost = (8.0 + 40000.0) * 2 271 | if got := cr.MinLimited; got != cost { 272 | t.Errorf("MinLimited is %v, want %v", got, cost) 273 | } 274 | if got := cr.MaxLimited; got != cost { 275 | t.Errorf("MaxLimited is %v, want %v", got, cost) 276 | } 277 | } 278 | 279 | func TestDaemonSetEstimateCost(t *testing.T) { 280 | rp := &GCPPriceCatalog{ 281 | cpuPrice: 4, 282 | memoryPrice: 2, 283 | } 284 | daemonset := DaemonSet{ 285 | NodesCount: 3, 286 | Containers: []Container{ 287 | { 288 | Requests: Resource{ 289 | CPU: 1000, // 1 vCPU 290 | Memory: 10000, // bytes 291 | }, 292 | Limits: Resource{ 293 | CPU: 2000, // 2 vCPU 294 | Memory: 20000, // bytes 295 | }, 296 | }, 297 | }, 298 | } 299 | cr := daemonset.estimateCost(rp) 300 | 301 | want := DaemonSetKind 302 | if got := cr.Kind; got != want { 303 | t.Errorf("Kind is %v, want %v", got, want) 304 | } 305 | 306 | cost := (4.0 + 20000.0) * 3 307 | if got := cr.MinRequested; got != cost { 308 | t.Errorf("MinRequested is %v, want %v", got, cost) 309 | } 310 | if got := cr.MaxRequested; got != cost { 311 | t.Errorf("MaxRequested is %v, want %v", got, cost) 312 | } 313 | if got := cr.HPABuffer; got != cost { 314 | t.Errorf("HPABuffer is %v, want %v", got, cost) 315 | } 316 | 317 | cost = (8.0 + 40000.0) * 3 318 | if got := cr.MinLimited; got != cost { 319 | t.Errorf("MinLimited is %v, want %v", got, cost) 320 | } 321 | if got := cr.MaxLimited; got != cost { 322 | t.Errorf("MaxLimited is %v, want %v", got, cost) 323 | } 324 | } 325 | 326 | func TestVolumeClaimEstimateCost(t *testing.T) { 327 | rp := &GCPPriceCatalog{ 328 | pdStandardPrice: 2, 329 | } 330 | volume := VolumeClaim{ 331 | StorageClass: storageClassStandard, 332 | Requests: Resource{ 333 | Storage: 10000, // bytes 334 | }, 335 | Limits: Resource{ 336 | Storage: 0, // bytes 337 | }, 338 | } 339 | cr := volume.estimateCost(rp) 340 | 341 | want := VolumeClaimKind 342 | if got := cr.Kind; got != want { 343 | t.Errorf("Kind is %v, want %v", got, want) 344 | } 345 | 346 | cost := 20000.0 347 | if got := cr.MinRequested; got != cost { 348 | t.Errorf("MinRequested is %v, want %v", got, cost) 349 | } 350 | if got := cr.MaxRequested; got != cost { 351 | t.Errorf("MaxRequested is %v, want %v", got, cost) 352 | } 353 | if got := cr.HPABuffer; got != cost { 354 | t.Errorf("HPABuffer is %v, want %v", got, cost) 355 | } 356 | if got := cr.MinLimited; got != cost { 357 | t.Errorf("MinLimited is %v, want %v", got, cost) 358 | } 359 | if got := cr.MaxLimited; got != cost { 360 | t.Errorf("MaxLimited is %v, want %v", got, cost) 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fernandorubbo/k8s-cost-estimator 2 | 3 | go 1.15 4 | 5 | require ( 6 | cloud.google.com/go v0.72.0 7 | github.com/google/go-cmp v0.5.2 8 | github.com/leekchan/accounting v1.0.0 9 | github.com/olekukonko/tablewriter v0.0.4 10 | github.com/sirupsen/logrus v1.7.0 11 | google.golang.org/api v0.35.0 12 | google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb 13 | gopkg.in/yaml.v2 v2.2.8 14 | k8s.io/api v0.19.4 15 | k8s.io/apimachinery v0.19.4 16 | sigs.k8s.io/yaml v1.2.0 17 | ) 18 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/json" 19 | "flag" 20 | "fmt" 21 | "io/ioutil" 22 | "os" 23 | "path" 24 | "strings" 25 | 26 | "github.com/fernandorubbo/k8s-cost-estimator/api" 27 | log "github.com/sirupsen/logrus" 28 | "sigs.k8s.io/yaml" 29 | ) 30 | 31 | const version = "v0.0.1" 32 | 33 | var ( 34 | k8sPath = flag.String("k8s", "", "Required. Path to k8s manifests folder") 35 | k8sPrevPath = flag.String("k8s-prev", "", "Optional. Path to the previous K8s manifests folder. Useful to compare prices.") 36 | outputFile = flag.String("output", "", "Optional. Output file path. If not provided, console is used") 37 | environ = flag.String("environ", "LOCAL", "Optional. Where your code is running at. Used to know determine the output file format: GITHUB | GITLAB | LOCAL") 38 | authKey = flag.String("auth-key", "", "Optional. The GCP service account JSON key filepath. If not provided, default service account is used (Run 'gcloud auth application-default login' to set your user as the default service account)") 39 | configFile = flag.String("config", "", "Optional. The defaults configuration YAML filepath to set: machine family, region and compute resources not provided in k8s manifests") 40 | verbosity = flag.String("v", "panic", "Optional. Verbosity: panic|fatal|error|warn|info|debug|trace. Default panic") 41 | ) 42 | 43 | func init() { 44 | flag.Parse() 45 | 46 | level, err := log.ParseLevel(*verbosity) 47 | exitOnError("Invalid 'verbosity' parameter", err) 48 | if *environ == "GITLAB" { 49 | log.SetFormatter(&log.JSONFormatter{ 50 | DisableTimestamp: true, 51 | FieldMap: log.FieldMap{ 52 | log.FieldKeyLevel: "severity", 53 | }, 54 | }) 55 | } 56 | log.SetOutput(os.Stdout) 57 | log.SetLevel(level) 58 | 59 | // required flags 60 | validateK8sPath(*k8sPath, "k8s") 61 | } 62 | 63 | func main() { 64 | log.Infof("Starting cost estimation (version %s)...", version) 65 | 66 | config := readConfigFromFile() 67 | priceCatalog := newGCPPriceCatalog(config) 68 | currentCost := estimateCost(*k8sPath, config, priceCatalog) 69 | if isPreviousPathProvided() { 70 | log.Infof("Comparing current cost against previous version. Paths: '%s' vs '%s'", *k8sPath, *k8sPrevPath) 71 | previousCosts := estimateCost(*k8sPrevPath, config, priceCatalog) 72 | diffCost := currentCost.Subtract(previousCosts) 73 | outputDiff(diffCost) 74 | } else { 75 | output(currentCost.ToMarkdown()) 76 | } 77 | 78 | log.Info("Finished cost estimation!") 79 | } 80 | 81 | func readConfigFromFile() api.CostimatorConfig { 82 | conf := api.ConfigDefaults() 83 | if *configFile != "" { 84 | data, err := ioutil.ReadFile(*configFile) 85 | exitOnError("Unable to read 'config' file", err) 86 | err = yaml.Unmarshal(data, &conf) 87 | exitOnError("Unable to umarshal 'config' file", err) 88 | } else { 89 | log.Debugf("Parameter 'config' not provided. Using default config.") 90 | } 91 | return conf 92 | } 93 | 94 | func newGCPPriceCatalog(config api.CostimatorConfig) api.GCPPriceCatalog { 95 | log.Debug("Retriving Price Catalog from GCP...") 96 | credentials := readAuthKeyFromFile() 97 | priceCatalog, err := api.NewGCPPriceCatalog(credentials, config) 98 | exitOnError("Unable to read Pricing Catalog from GCP", err) 99 | return priceCatalog 100 | } 101 | 102 | func readAuthKeyFromFile() []byte { 103 | var credentials []byte 104 | if *authKey != "" { 105 | var err error 106 | credentials, err = ioutil.ReadFile(*authKey) 107 | exitOnError("Unable to read auth-key file", err) 108 | } else { 109 | log.Info("auth-key not provided. Using default service account.") 110 | } 111 | return credentials 112 | } 113 | 114 | func validateK8sPath(k8sPath string, flag string) { 115 | if !isK8sPathProvided(k8sPath, flag) { 116 | exit(fmt.Sprintf("%s is required", flag)) 117 | } 118 | } 119 | 120 | func isPreviousPathProvided() bool { 121 | return isK8sPathProvided(*k8sPrevPath, "k8s-prev") 122 | } 123 | 124 | func isK8sPathProvided(k8sPath string, flag string) bool { 125 | if k8sPath == "" { 126 | return false 127 | } 128 | 129 | f, err := os.Stat(k8sPath) 130 | if os.IsNotExist(err) { 131 | exit(fmt.Sprintf("%s provided does not exists", flag)) 132 | } 133 | if !(f.IsDir() || strings.HasSuffix(f.Name(), ".yaml") || strings.HasSuffix(f.Name(), ".yml")) { 134 | exit(fmt.Sprintf("%s provided must be a folder or a yaml file", flag)) 135 | } 136 | return true 137 | } 138 | 139 | func estimateCost(path string, conf api.CostimatorConfig, pc api.GCPPriceCatalog) api.Cost { 140 | log.Infof("Estimating monthly cost for k8s objects in path '%s'...", path) 141 | manifests := api.Manifests{} 142 | err := manifests.LoadObjectsFromPath(path, conf) 143 | if err != nil { 144 | exitOnError(fmt.Sprintf("Unable estimate cost for %s", path), err) 145 | } 146 | return manifests.EstimateCost(pc) 147 | } 148 | 149 | func outputDiff(diffCost api.DiffCost) { 150 | output(diffCost.ToMarkdown()) 151 | 152 | if *outputFile == "" { 153 | return 154 | } 155 | saveDiffFile(diffCost) 156 | } 157 | 158 | func output(markdown string) { 159 | fmt.Printf("\n%s\n", markdown) 160 | 161 | if *outputFile == "" { 162 | return 163 | } 164 | 165 | switch strings.ToUpper(*environ) { 166 | case "GITHUB": 167 | log.Debugf("Saving Github file at '%s'", *outputFile) 168 | saveGithubFile(markdown) 169 | case "GITLAB": 170 | log.Debugf("Saving Gitlab file at '%s'", *outputFile) 171 | saveGithubFile(markdown) 172 | default: 173 | log.Debugf("Saving Markdown file at '%s'", *outputFile) 174 | saveMarkdownFile(markdown) 175 | } 176 | } 177 | 178 | func saveDiffFile(diffCost api.DiffCost) { 179 | ext := path.Ext(*outputFile) 180 | diffOutputFile := (*outputFile)[0:len(*outputFile)-len(ext)] + ".diff" 181 | log.Debugf("Saving Diff file at '%s'", diffOutputFile) 182 | 183 | f, err := os.Create(diffOutputFile) 184 | exitOnError(fmt.Sprintf("Creating Diff file %s", diffOutputFile), err) 185 | defer f.Close() 186 | pd := diffCost.MonthlyDiffRange.ToPriceDiff() 187 | err = json.NewEncoder(f).Encode(pd) 188 | exitOnError(fmt.Sprintf("Writting Diff file %s", diffOutputFile), err) 189 | } 190 | 191 | func saveGithubFile(markdown string) { 192 | type github struct { 193 | Body string `json:"body"` 194 | } 195 | 196 | gh := &github{ 197 | Body: markdown, 198 | } 199 | f, err := os.Create(*outputFile) 200 | exitOnError(fmt.Sprintf("Creating output file %s", *outputFile), err) 201 | defer f.Close() 202 | err = json.NewEncoder(f).Encode(gh) 203 | exitOnError(fmt.Sprintf("Writting output file %s", *outputFile), err) 204 | } 205 | 206 | func saveMarkdownFile(markdown string) { 207 | err := ioutil.WriteFile(*outputFile, []byte(markdown), 0644) 208 | exitOnError(fmt.Sprintf("Writing output file %s", *outputFile), err) 209 | } 210 | 211 | func exitOnError(message string, err error) { 212 | if err != nil { 213 | exitWithError(message, err) 214 | } 215 | } 216 | 217 | func exitWithError(message string, err error) { 218 | fmt.Printf("\nError: %s\nCause: %+v\n\nSee parameters options below:\n", err, message) 219 | flag.PrintDefaults() 220 | os.Exit(-1) 221 | } 222 | 223 | func exit(message string) { 224 | fmt.Printf("\nError: %s\n\nSee parameters options below:\n", message) 225 | flag.PrintDefaults() 226 | os.Exit(-1) 227 | } 228 | -------------------------------------------------------------------------------- /samples/k8s-cost-estimator-github/.gitignore: -------------------------------------------------------------------------------- 1 | ssh -------------------------------------------------------------------------------- /samples/k8s-cost-estimator-github/defaults-conf.yaml: -------------------------------------------------------------------------------- 1 | deploymentConf: 2 | defaultCPUinMillis: 250 3 | defaultMemoryinBytes: 64000000 4 | percentageIncreaseForUnboundedRerouces: 200 5 | resourceConf: 6 | machineFamily: E2 7 | region: us-central1 -------------------------------------------------------------------------------- /samples/k8s-cost-estimator-github/templates/cloudbuild.yaml.tpl: -------------------------------------------------------------------------------- 1 | steps: 2 | 3 | - name: us-central1-docker.pkg.dev/GCP_PROJECT_ID/docker-repo/k8s-cost-estimator:v0.0.1 4 | entrypoint: 'bash' 5 | args: 6 | - '-c' 7 | - | 8 | set -e 9 | 10 | echo "" 11 | echo "*************************************************************************" 12 | echo "** Checking out '$_BASE_BRANCH' branch ..." 13 | echo "*************************************************************************" 14 | git config --global user.email "GITHUB_EMAIL" && git config --global user.name "GITHUB_USER" 15 | mkdir previous 16 | git clone https://github.com/GITHUB_USER/k8s-cost-estimator-github.git previous/ 17 | cd previous 18 | git checkout $_BASE_BRANCH 19 | cd .. 20 | 21 | echo "" 22 | echo "*************************************************************************" 23 | echo "** Estimating cost difference between current and previous versions..." 24 | echo "*************************************************************************" 25 | k8s-cost-estimator --k8s wordpress --k8s-prev previous/wordpress --output output.json --environ=GITHUB 26 | 27 | echo "" 28 | echo "***************************************************************************************************************" 29 | echo "** Updating Pull Request '$_PR_NUMBER' ..." 30 | echo "***************************************************************************************************************" 31 | createObject() { 32 | url=$$1 33 | body=$$2 34 | resp=$(curl -w "\nSTATUS_CODE:%{http_code}\n" -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer $_GITHUB_TOKEN" -d "$$body" $$url) 35 | httpStatusCode=$([[ $$resp =~ [[:space:]]*STATUS_CODE:([0-9]{3}) ]] && echo $${BASH_REMATCH[1]}) 36 | if [ $$httpStatusCode != "201" ] 37 | then 38 | echo "Error creating object!" 39 | echo "\- URL: $$url " 40 | echo "\- BODY: $$body " 41 | echo "\- RESPONSE: $$resp " 42 | exit -1 43 | fi 44 | } 45 | 46 | comments_url="https://api.github.com/repos/GITHUB_USER/k8s-cost-estimator-github/issues/$_PR_NUMBER/comments" 47 | comments_body="$(cat output.json)" 48 | createObject $$comments_url "$$comments_body" 49 | 50 | COST_USD_THRESHOLD=$_GITHUB_FINOPS_COST_USD_THRESHOLD 51 | POSSIBLY_COST_INCREASE=$(cat output.diff | jq ".summary.maxDiff.usd") 52 | if (( $(echo "$$POSSIBLY_COST_INCREASE > $$COST_USD_THRESHOLD" | bc -l) )) 53 | then 54 | echo "" 55 | echo "****************************************************************************************" 56 | echo "** Possible cost increase bigger than \$ $$COST_USD_THRESHOLD USD detected. Requesting FinOps approval ..." 57 | echo "****************************************************************************************" 58 | reviewers_url="https://api.github.com/repos/GITHUB_USER/k8s-cost-estimator-github/pulls/$_PR_NUMBER/requested_reviewers" 59 | reviewers_body="{\"reviewers\":[\"$_GITHUB_FINOPS_REVIEWER_USER\"]}" 60 | createObject $$reviewers_url "$$reviewers_body" 61 | else 62 | echo "" 63 | echo "****************************************************************************************************************" 64 | echo "** No cost increase bigger than \$ $$COST_USD_THRESHOLD USD detected. FinOps approval is NOT required in this situation!" 65 | echo "****************************************************************************************************************" 66 | fi -------------------------------------------------------------------------------- /samples/k8s-cost-estimator-github/wordpress/fluentd-elasticsearch-deamonset.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: DaemonSet 3 | metadata: 4 | name: fluentd-elasticsearch 5 | namespace: kube-system 6 | labels: 7 | k8s-app: fluentd-logging 8 | spec: 9 | selector: 10 | matchLabels: 11 | name: fluentd-elasticsearch 12 | template: 13 | metadata: 14 | labels: 15 | name: fluentd-elasticsearch 16 | spec: 17 | tolerations: 18 | # this toleration is to have the daemonset runnable on master nodes 19 | # remove it if your masters can't run pods 20 | - key: node-role.kubernetes.io/master 21 | effect: NoSchedule 22 | containers: 23 | - name: fluentd-elasticsearch 24 | image: quay.io/fluentd_elasticsearch/fluentd:v2.5.2 25 | resources: 26 | limits: 27 | memory: 200Mi 28 | requests: 29 | cpu: 100m 30 | memory: 200Mi 31 | volumeMounts: 32 | - name: varlog 33 | mountPath: /var/log 34 | - name: varlibdockercontainers 35 | mountPath: /var/lib/docker/containers 36 | readOnly: true 37 | terminationGracePeriodSeconds: 30 38 | volumes: 39 | - name: varlog 40 | hostPath: 41 | path: /var/log 42 | - name: varlibdockercontainers 43 | hostPath: 44 | path: /var/lib/docker/containers -------------------------------------------------------------------------------- /samples/k8s-cost-estimator-github/wordpress/mysql-statefulset.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: mysql 5 | labels: 6 | app: mysql 7 | data: 8 | primary.cnf: | 9 | # Apply this config only on the primary. 10 | [mysqld] 11 | log-bin 12 | replica.cnf: | 13 | # Apply this config only on replicas. 14 | [mysqld] 15 | super-read-only 16 | 17 | --- 18 | 19 | # Headless service for stable DNS entries of StatefulSet members. 20 | apiVersion: v1 21 | kind: Service 22 | metadata: 23 | name: mysql 24 | labels: 25 | app: mysql 26 | spec: 27 | ports: 28 | - name: mysql 29 | port: 3306 30 | clusterIP: None 31 | selector: 32 | app: mysql 33 | 34 | --- 35 | 36 | # Client service for connecting to any MySQL instance for reads. 37 | # For writes, you must instead connect to the primary: mysql-0.mysql. 38 | apiVersion: v1 39 | kind: Service 40 | metadata: 41 | name: mysql-read 42 | labels: 43 | app: mysql 44 | spec: 45 | ports: 46 | - name: mysql 47 | port: 3306 48 | selector: 49 | app: mysql 50 | 51 | --- 52 | 53 | apiVersion: apps/v1 54 | kind: StatefulSet 55 | metadata: 56 | name: mysql 57 | spec: 58 | selector: 59 | matchLabels: 60 | app: mysql 61 | serviceName: mysql 62 | replicas: 3 63 | template: 64 | metadata: 65 | labels: 66 | app: mysql 67 | spec: 68 | initContainers: 69 | - name: init-mysql 70 | image: mysql:5.7 71 | command: 72 | - bash 73 | - "-c" 74 | - | 75 | set -ex 76 | # Generate mysql server-id from pod ordinal index. 77 | [[ `hostname` =~ -([0-9]+)$ ]] || exit 1 78 | ordinal=${BASH_REMATCH[1]} 79 | echo [mysqld] > /mnt/conf.d/server-id.cnf 80 | # Add an offset to avoid reserved server-id=0 value. 81 | echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf 82 | # Copy appropriate conf.d files from config-map to emptyDir. 83 | if [[ $ordinal -eq 0 ]]; then 84 | cp /mnt/config-map/primary.cnf /mnt/conf.d/ 85 | else 86 | cp /mnt/config-map/replica.cnf /mnt/conf.d/ 87 | fi 88 | volumeMounts: 89 | - name: conf 90 | mountPath: /mnt/conf.d 91 | - name: config-map 92 | mountPath: /mnt/config-map 93 | - name: clone-mysql 94 | image: gcr.io/google-samples/xtrabackup:1.0 95 | command: 96 | - bash 97 | - "-c" 98 | - | 99 | set -ex 100 | # Skip the clone if data already exists. 101 | [[ -d /var/lib/mysql/mysql ]] && exit 0 102 | # Skip the clone on primary (ordinal index 0). 103 | [[ `hostname` =~ -([0-9]+)$ ]] || exit 1 104 | ordinal=${BASH_REMATCH[1]} 105 | [[ $ordinal -eq 0 ]] && exit 0 106 | # Clone data from previous peer. 107 | ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql 108 | # Prepare the backup. 109 | xtrabackup --prepare --target-dir=/var/lib/mysql 110 | volumeMounts: 111 | - name: data 112 | mountPath: /var/lib/mysql 113 | subPath: mysql 114 | - name: conf 115 | mountPath: /etc/mysql/conf.d 116 | containers: 117 | - name: mysql 118 | image: mysql:5.7 119 | env: 120 | - name: MYSQL_ALLOW_EMPTY_PASSWORD 121 | value: "1" 122 | ports: 123 | - name: mysql 124 | containerPort: 3306 125 | volumeMounts: 126 | - name: data 127 | mountPath: /var/lib/mysql 128 | subPath: mysql 129 | - name: conf 130 | mountPath: /etc/mysql/conf.d 131 | resources: 132 | requests: 133 | cpu: 500m 134 | memory: 1Gi 135 | livenessProbe: 136 | exec: 137 | command: ["mysqladmin", "ping"] 138 | initialDelaySeconds: 30 139 | periodSeconds: 10 140 | timeoutSeconds: 5 141 | readinessProbe: 142 | exec: 143 | # Check we can execute queries over TCP (skip-networking is off). 144 | command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"] 145 | initialDelaySeconds: 5 146 | periodSeconds: 2 147 | timeoutSeconds: 1 148 | - name: xtrabackup 149 | image: gcr.io/google-samples/xtrabackup:1.0 150 | ports: 151 | - name: xtrabackup 152 | containerPort: 3307 153 | command: 154 | - bash 155 | - "-c" 156 | - | 157 | set -ex 158 | cd /var/lib/mysql 159 | 160 | # Determine binlog position of cloned data, if any. 161 | if [[ -f xtrabackup_slave_info && "x$( change_master_to.sql.in 165 | # Ignore xtrabackup_binlog_info in this case (it's useless). 166 | rm -f xtrabackup_slave_info xtrabackup_binlog_info 167 | elif [[ -f xtrabackup_binlog_info ]]; then 168 | # We're cloning directly from primary. Parse binlog position. 169 | [[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1 170 | rm -f xtrabackup_binlog_info xtrabackup_slave_info 171 | echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\ 172 | MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in 173 | fi 174 | 175 | # Check if we need to complete a clone by starting replication. 176 | if [[ -f change_master_to.sql.in ]]; then 177 | echo "Waiting for mysqld to be ready (accepting connections)" 178 | until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done 179 | 180 | echo "Initializing replication from clone position" 181 | mysql -h 127.0.0.1 \ 182 | -e "$( $GITLAB_FINOPS_COST_USD_THRESHOLD" | bc -l) )) 59 | then 60 | echo "" 61 | echo "****************************************************************************************" 62 | echo "** Possible cost increase bigger than \$$GITLAB_FINOPS_COST_USD_THRESHOLD USD detected. Requesting FinOps approval ..." 63 | echo "****************************************************************************************" 64 | reviewers_url="https://gitlab.com/api/v4/projects/$CI_MERGE_REQUEST_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/approval_rules" 65 | reviewers_body="{\"name\":\"Require FinOps Approval\", \"approvals_required\":1, \"user_ids\":[$GITLAB_FINOPS_REVIEWER_ID]}" 66 | createObject $reviewers_url "$reviewers_body" 67 | else 68 | echo "" 69 | echo "****************************************************************************************************************" 70 | echo "** No cost increase bigger than \$$GITLAB_FINOPS_COST_USD_THRESHOLD USD detected. FinOps approval is NOT required in this situation!" 71 | echo "****************************************************************************************************************" 72 | fi -------------------------------------------------------------------------------- /samples/k8s-cost-estimator-gitlab/templates/gitlab-runner-values.yaml.tpl: -------------------------------------------------------------------------------- 1 | gitlabUrl: https://gitlab.com 2 | runnerRegistrationToken: "GITLAB_RUNNER_TOKEN" 3 | fullnameOverride: gitlab-runner 4 | logLevel: info 5 | rbac: 6 | create: true 7 | resources: ["pods", "pods/exec", "secrets"] 8 | verbs: ["get", "list", "watch", "create", "patch", "delete"] 9 | serviceAccountAnnotations: { 10 | "iam.gke.io/gcp-service-account":"gitlab-runner@GCP_PROJECT_ID.iam.gserviceaccount.com" 11 | } 12 | runners: 13 | tags: "k8s-cost-estimator-runner" 14 | requestConcurrency: 10 15 | serviceAccountName: gitlab-runner -------------------------------------------------------------------------------- /samples/k8s-cost-estimator-gitlab/wordpress/fluentd-elasticsearch-deamonset.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: apps/v1 16 | kind: DaemonSet 17 | metadata: 18 | name: fluentd-elasticsearch 19 | namespace: kube-system 20 | labels: 21 | k8s-app: fluentd-logging 22 | spec: 23 | selector: 24 | matchLabels: 25 | name: fluentd-elasticsearch 26 | template: 27 | metadata: 28 | labels: 29 | name: fluentd-elasticsearch 30 | spec: 31 | tolerations: 32 | # this toleration is to have the daemonset runnable on master nodes 33 | # remove it if your masters can't run pods 34 | - key: node-role.kubernetes.io/master 35 | effect: NoSchedule 36 | containers: 37 | - name: fluentd-elasticsearch 38 | image: quay.io/fluentd_elasticsearch/fluentd:v2.5.2 39 | resources: 40 | limits: 41 | memory: 200Mi 42 | requests: 43 | cpu: 100m 44 | memory: 200Mi 45 | volumeMounts: 46 | - name: varlog 47 | mountPath: /var/log 48 | - name: varlibdockercontainers 49 | mountPath: /var/lib/docker/containers 50 | readOnly: true 51 | terminationGracePeriodSeconds: 30 52 | volumes: 53 | - name: varlog 54 | hostPath: 55 | path: /var/log 56 | - name: varlibdockercontainers 57 | hostPath: 58 | path: /var/lib/docker/containers -------------------------------------------------------------------------------- /samples/k8s-cost-estimator-gitlab/wordpress/mysql-statefulset.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: v1 16 | kind: ConfigMap 17 | metadata: 18 | name: mysql 19 | labels: 20 | app: mysql 21 | data: 22 | primary.cnf: | 23 | # Apply this config only on the primary. 24 | [mysqld] 25 | log-bin 26 | replica.cnf: | 27 | # Apply this config only on replicas. 28 | [mysqld] 29 | super-read-only 30 | 31 | --- 32 | 33 | # Headless service for stable DNS entries of StatefulSet members. 34 | apiVersion: v1 35 | kind: Service 36 | metadata: 37 | name: mysql 38 | labels: 39 | app: mysql 40 | spec: 41 | ports: 42 | - name: mysql 43 | port: 3306 44 | clusterIP: None 45 | selector: 46 | app: mysql 47 | 48 | --- 49 | 50 | # Client service for connecting to any MySQL instance for reads. 51 | # For writes, you must instead connect to the primary: mysql-0.mysql. 52 | apiVersion: v1 53 | kind: Service 54 | metadata: 55 | name: mysql-read 56 | labels: 57 | app: mysql 58 | spec: 59 | ports: 60 | - name: mysql 61 | port: 3306 62 | selector: 63 | app: mysql 64 | 65 | --- 66 | 67 | apiVersion: apps/v1 68 | kind: StatefulSet 69 | metadata: 70 | name: mysql 71 | spec: 72 | selector: 73 | matchLabels: 74 | app: mysql 75 | serviceName: mysql 76 | replicas: 3 77 | template: 78 | metadata: 79 | labels: 80 | app: mysql 81 | spec: 82 | initContainers: 83 | - name: init-mysql 84 | image: mysql:5.7 85 | command: 86 | - bash 87 | - "-c" 88 | - | 89 | set -ex 90 | # Generate mysql server-id from pod ordinal index. 91 | [[ `hostname` =~ -([0-9]+)$ ]] || exit 1 92 | ordinal=${BASH_REMATCH[1]} 93 | echo [mysqld] > /mnt/conf.d/server-id.cnf 94 | # Add an offset to avoid reserved server-id=0 value. 95 | echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf 96 | # Copy appropriate conf.d files from config-map to emptyDir. 97 | if [[ $ordinal -eq 0 ]]; then 98 | cp /mnt/config-map/primary.cnf /mnt/conf.d/ 99 | else 100 | cp /mnt/config-map/replica.cnf /mnt/conf.d/ 101 | fi 102 | volumeMounts: 103 | - name: conf 104 | mountPath: /mnt/conf.d 105 | - name: config-map 106 | mountPath: /mnt/config-map 107 | - name: clone-mysql 108 | image: gcr.io/google-samples/xtrabackup:1.0 109 | command: 110 | - bash 111 | - "-c" 112 | - | 113 | set -ex 114 | # Skip the clone if data already exists. 115 | [[ -d /var/lib/mysql/mysql ]] && exit 0 116 | # Skip the clone on primary (ordinal index 0). 117 | [[ `hostname` =~ -([0-9]+)$ ]] || exit 1 118 | ordinal=${BASH_REMATCH[1]} 119 | [[ $ordinal -eq 0 ]] && exit 0 120 | # Clone data from previous peer. 121 | ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql 122 | # Prepare the backup. 123 | xtrabackup --prepare --target-dir=/var/lib/mysql 124 | volumeMounts: 125 | - name: data 126 | mountPath: /var/lib/mysql 127 | subPath: mysql 128 | - name: conf 129 | mountPath: /etc/mysql/conf.d 130 | containers: 131 | - name: mysql 132 | image: mysql:5.7 133 | env: 134 | - name: MYSQL_ALLOW_EMPTY_PASSWORD 135 | value: "1" 136 | ports: 137 | - name: mysql 138 | containerPort: 3306 139 | volumeMounts: 140 | - name: data 141 | mountPath: /var/lib/mysql 142 | subPath: mysql 143 | - name: conf 144 | mountPath: /etc/mysql/conf.d 145 | resources: 146 | requests: 147 | cpu: 500m 148 | memory: 1Gi 149 | livenessProbe: 150 | exec: 151 | command: ["mysqladmin", "ping"] 152 | initialDelaySeconds: 30 153 | periodSeconds: 10 154 | timeoutSeconds: 5 155 | readinessProbe: 156 | exec: 157 | # Check we can execute queries over TCP (skip-networking is off). 158 | command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"] 159 | initialDelaySeconds: 5 160 | periodSeconds: 2 161 | timeoutSeconds: 1 162 | - name: xtrabackup 163 | image: gcr.io/google-samples/xtrabackup:1.0 164 | ports: 165 | - name: xtrabackup 166 | containerPort: 3307 167 | command: 168 | - bash 169 | - "-c" 170 | - | 171 | set -ex 172 | cd /var/lib/mysql 173 | 174 | # Determine binlog position of cloned data, if any. 175 | if [[ -f xtrabackup_slav_info && "x$( change_master_to.sql.in 179 | # Ignore xtrabackup_binlog_info in this case (it's useless). 180 | rm -f xtrabackup_slav_info xtrabackup_binlog_info 181 | elif [[ -f xtrabackup_binlog_info ]]; then 182 | # We're cloning directly from primary. Parse binlog position. 183 | [[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1 184 | rm -f xtrabackup_binlog_info xtrabackup_slav_info 185 | echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\ 186 | MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in 187 | fi 188 | 189 | # Check if we need to complete a clone by starting replication. 190 | if [[ -f change_master_to.sql.in ]]; then 191 | echo "Waiting for mysqld to be ready (accepting connections)" 192 | until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done 193 | 194 | echo "Initializing replication from clone position" 195 | mysql -h 127.0.0.1 \ 196 | -e "$(