├── .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 "$(