From 908111587c2611b1f0e453ccd9166f74b611ef52 Mon Sep 17 00:00:00 2001 From: Jesus Parada Date: Mon, 3 Jun 2024 14:59:55 -0600 Subject: [PATCH] FOPTS-3387 Added changes to create aggregation query param for the Allocation API --- CHANGELOG.md | 4 ++ README.md | 56 ++++++++++-------- helm-chart/Chart.yaml | 4 +- helm-chart/README.md | 6 +- .../cbi-oi-kubecost-exporter-1.15.0.tgz | Bin 0 -> 5623 bytes helm-chart/values.yaml | 4 +- index.yaml | 48 +++++++++------ main.go | 8 ++- main_test.go | 2 +- 9 files changed, 79 insertions(+), 53 deletions(-) create mode 100644 helm-chart/cbi-oi-kubecost-exporter-1.15.0.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md index 6782cb5..cfe92a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v1.15.0 + +- Added some modifications building aggregation query param to address Kubecost Allocation API changes made for versions 2.3 and higher. + ## v1.14.0 - Improved OpenCost support. Added description of settings for integration with OpenCost in README.md diff --git a/README.md b/README.md index e889b16..f3163e1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ Kubecost Flexera Exporter is a utility to collect cost allocation data. It is a command line tool that automates the transfer of Kubernetes cluster cost allocation data to Cloud Cost Optimization. This tool generates a CSV file for each day of the current (and optionally previous) month in a format compatible with the Flexera One platform and then uploads files into Cloud Cost Optimization via CBI connect. Kubecost Flexera Exporter utilizes Kubecost Allocation API to request cost allocation data. The majority of Kubecost Allocation API parameters are exposed as exporter settings, matching Kubecost API parameters are listed in the exporter setting descriptions. +## Supported Kubecost Allocation API Versions + +This exporter is able to work with 1.x, 2.3 or higher versions. + +With versions from 2.0 to 2.2.x there could be some unexpected results when using the Allocation API, especially using the shareIdle parameter which in those versions is not correctly implemented. That is why it is not recommended to use any of these versions. + +NOTE: For versions 2.3 and higher, the properties received from the Kubecost Allocation API depend on the level of aggregation being used, smaller levels of granularity will include higher levels but not vice versa. For instance if you set aggregation as pod, exporter will include data for cluster, namespace, controller, controllerKind, node and pod but if you set aggregation as namespace, exporter won't include data for controller, controllerKind, node and pod. + ## OpenCost Support The Kubecost Flexera Exporter now also supports the [OpenCost API](https://www.opencost.io/docs/integrations/api), which is largely compatible with the [Kubecost Allocation API](https://docs.kubecost.com/apis/apis-overview/api-allocation). This means you can easily switch between Kubecost and OpenCost for retrieving Kubernetes cost allocation data, depending on your preference or requirements. @@ -40,27 +48,27 @@ go install github.com/flexera-public/cbi-oi-kubecost-exporter The app is configured using environment variables defined in a .env file. The following configuration options are available: -| Environment Variable | Description | -| --- | --- | -| FILE_PATH | The path where the generated CSV files are stored. Default is "/var/kubecost" | -| FILE_ROTATION | Indicates whether to delete files generated for previous months. Default is true. Note: current and previous months data is kept. | -| BILL_CONNECT_ID | The ID of the bill connect to which to upload the data. Default value is "cbi-oi-kubecost-1". To learn more about Bill Connect, and how to obtain your BILL_CONNECT_ID, please refer to [Creating Kubecost CBI Bill Connect](https://docs.flexera.com/flexera/EN/Optima/CreateKubecostBillConnect.htm) in the Flexera documentation. | -| ORG_ID | The ID of your Flexera One organization, please refer to [Organization ID Unique Identifier](https://docs.flexera.com/flexera/EN/FlexeraAPI/APIKeyConcepts.htm#gettingstarted_2697534192_1120261) in the Flexera documentation. | -| REFRESH_TOKEN | The refresh token used to obtain an access token for the Flexera One API. Please refer to [Generating a Refresh Token](https://docs.flexera.com/flexera/EN/FlexeraAPI/GenerateRefreshToken.htm) in the Flexera documentation. | +| Environment Variable | Description | +| --- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| FILE_PATH | The path where the generated CSV files are stored. Default is "/var/kubecost" | +| FILE_ROTATION | Indicates whether to delete files generated for previous months. Default is true. Note: current and previous months data is kept. | +| BILL_CONNECT_ID | The ID of the bill connect to which to upload the data. Default value is "cbi-oi-kubecost-1". To learn more about Bill Connect, and how to obtain your BILL_CONNECT_ID, please refer to [Creating Kubecost CBI Bill Connect](https://docs.flexera.com/flexera/EN/Optima/CreateKubecostBillConnect.htm) in the Flexera documentation. | +| ORG_ID | The ID of your Flexera One organization, please refer to [Organization ID Unique Identifier](https://docs.flexera.com/flexera/EN/FlexeraAPI/APIKeyConcepts.htm#gettingstarted_2697534192_1120261) in the Flexera documentation. | +| REFRESH_TOKEN | The refresh token used to obtain an access token for the Flexera One API. Please refer to [Generating a Refresh Token](https://docs.flexera.com/flexera/EN/FlexeraAPI/GenerateRefreshToken.htm) in the Flexera documentation. | | SERVICE_APP_CLIENT_ID | The service account client ID used to obtain an access token for the Flexera One API. Please refer to [Using a Service Account](https://docs.flexera.com/flexera/EN/FlexeraAPI/ServiceAccounts.htm?Highlight=service%20account) in the Flexera documentation. This parameter is incompatible with REFRESH_TOKEN, use only one of them. | -| SERVICE_APP_CLIENT_SECRET | The service account client secret used to obtain an access token for the Flexera One API. Please refer to [Using a Service Account](https://docs.flexera.com/flexera/EN/FlexeraAPI/ServiceAccounts.htm?Highlight=service%20account) in the Flexera documentation. | -| SHARD | The zone of your Flexera One account. Valid values are NAM, EU or AU. | -| INCLUDE_PREVIOUS_MONTH | Indicates whether to collect and export previous month. Default is false. This parameter is incompatible with REFRESH_TOKEN, use only one of them. | -| REQUEST_TIMEOUT | Indicates the timeout per each request in minutes. | -| KUBECOST_HOST | The hostname of the Kubecost instance. Default is "kubecost-cost-analyzer.kubecost.svc.cluster.local:9090". | -| KUBECOST_API_PATH | The base path for the Kubecost API endpoint. Default is "/model/" | -| AGGREGATION | The level of granularity to use when aggregating the cost data. Valid values are namespace, controller, or pod. Default is pod. Note: Exporter collects namespace labels regardless of set aggregation level and includes them into entity labels. | -| IDLE | Indicates whether to include cost of idle resources. Valid values are true and false. Default is true. | -| IDLE_BY_NODE | Indicates whether idle allocations are created on a per node basis. Valid values are true and false. Default is false. | -| SHARE_IDLE | Indicates whether allocate idle cost proportionally across non-idle resources. Default is false. | -| SHARE_NAMESPACES | Comma-separated list of namespaces to share costs with the remaining non-idle, unshared allocations. Default = kube-system,cadvisor | -| SHARE_TENANCY_COSTS | Indicates whether to share the cost of cluster overhead assets across tenants of those resources. Default is true. | -| MULTIPLIER | Optional multiplier for costs. Default is 1. | +| SERVICE_APP_CLIENT_SECRET | The service account client secret used to obtain an access token for the Flexera One API. Please refer to [Using a Service Account](https://docs.flexera.com/flexera/EN/FlexeraAPI/ServiceAccounts.htm?Highlight=service%20account) in the Flexera documentation. | +| SHARD | The zone of your Flexera One account. Valid values are NAM, EU or AU. | +| INCLUDE_PREVIOUS_MONTH | Indicates whether to collect and export previous month. Default is false. This parameter is incompatible with REFRESH_TOKEN, use only one of them. | +| REQUEST_TIMEOUT | Indicates the timeout per each request in minutes. | +| KUBECOST_HOST | The hostname of the Kubecost instance. Default is "kubecost-cost-analyzer.kubecost.svc.cluster.local:9090". | +| KUBECOST_API_PATH | The base path for the Kubecost API endpoint. Default is "/model/" | +| AGGREGATION | The level of granularity to use when aggregating the cost data. Valid values are namespace, controller, node or pod. Default is pod. Note: Exporter collects namespace labels regardless of set aggregation level and includes them into entity labels. | +| IDLE | Indicates whether to include cost of idle resources. Valid values are true and false. Default is true. | +| IDLE_BY_NODE | Indicates whether idle allocations are created on a per node basis. Valid values are true and false. Default is false. | +| SHARE_IDLE | Indicates whether allocate idle cost proportionally across non-idle resources. Default is false. | +| SHARE_NAMESPACES | Comma-separated list of namespaces to share costs with the remaining non-idle, unshared allocations. Default = kube-system,cadvisor | +| SHARE_TENANCY_COSTS | Indicates whether to share the cost of cluster overhead assets across tenants of those resources. Default is true. | +| MULTIPLIER | Optional multiplier for costs. Default is 1. | #### Execution @@ -152,7 +160,7 @@ You should see 200/201s in the logs, which indicates that the exporter is workin ### Helm configuration Values | Key | Type | Default | Description | -| --- | --- | --- | --- | +|-----|------|---------|-------------| | activeDeadlineSeconds | int | `10800` | The maximum duration in seconds for the cron job to complete | | cronSchedule | string | `"0 */6 * * *"` | Setting up a cronJob scheduler to run an export task at the desired time. | | env | object | `{}` | Pod environment variables. Example using envs to use proxy: {"NO_PROXY": ".svc,.cluster.local", "HTTP_PROXY": "http://proxy.example.com:80", "HTTPS_PROXY": "http://proxy.example.com:80"} | @@ -160,16 +168,16 @@ You should see 200/201s in the logs, which indicates that the exporter is workin | fileRotation | bool | `true` | Indicates whether to delete files generated for previous months. Default is true. Note: current and previous months data is kept. | | flexera.billConnectId | string | `"cbi-oi-kubecost-1"` | The ID of the bill connect to which to upload the data. To learn more about Bill Connect, and how to obtain your BILL_CONNECT_ID, please refer to [Creating Kubecost CBI Bill Connect](https://docs.flexera.com/flexera/EN/Optima/CreateKubecostBillConnect.htm) in the Flexera documentation. | | flexera.orgId | string | `""` | The ID of your Flexera One organization, please refer to [Organization ID Unique Identifier](https://docs.flexera.com/flexera/EN/FlexeraAPI/APIKeyConcepts.htm#gettingstarted_2697534192_1120261) in the Flexera documentation. | -| flexera.refreshToken | string | `""` | The refresh token used to obtain an access token for the Flexera One API. Please refer to [Generating a Refresh Token](https://docs.flexera.com/flexera/EN/FlexeraAPI/GenerateRefreshToken.htm) in the Flexera documentation. You can provide the refresh token in two ways: 1. Directly as a string: refreshToken: "your_token_here" 2. Reference it from a Kubernetes secret: refreshToken: valueFrom: secretKeyRef: name: flexera-secrets # Name of the Kubernetes secret key: refresh_token # Key in the secret containing the refresh token | +| flexera.refreshToken | string | `""` | The refresh token used to obtain an access token for the Flexera One API. Please refer to [Generating a Refresh Token](https://docs.flexera.com/flexera/EN/FlexeraAPI/GenerateRefreshToken.htm) in the Flexera documentation. You can provide the refresh token in two ways: 1. Directly as a string: refreshToken: "your_token_here" 2. Reference it from a Kubernetes secret: refreshToken: valueFrom: secretKeyRef: name: flexera-secrets # Name of the Kubernetes secret key: refresh_token # Key in the secret containing the refresh token | | flexera.serviceAppClientId | string | `""` | The service account client ID used to obtain an access token for the Flexera One API. Please refer to [Using a Service Account](https://docs.flexera.com/flexera/EN/FlexeraAPI/ServiceAccounts.htm?Highlight=service%20account) in the Flexera documentation. This parameter is incompatible with **refreshToken**, use only one of them. | | flexera.serviceAppClientSecret | string | `""` | The service account client secret used to obtain an access token for the Flexera One API. Please refer to [Using a Service Account](https://docs.flexera.com/flexera/EN/FlexeraAPI/ServiceAccounts.htm?Highlight=service%20account) in the Flexera documentation. This parameter is incompatible with **refreshToken**, use only one of them. | | flexera.shard | string | `"NAM"` | The zone of your Flexera One account. Valid values are NAM, EU or AU. | | image.pullPolicy | string | `"Always"` | | | image.repository | string | `"public.ecr.aws/flexera/cbi-oi-kubecost-exporter"` | | -| image.tag | string | `"1.13"` | | +| image.tag | string | `"1.15"` | | | imagePullSecrets | list | `[]` | | | includePreviousMonth | bool | `false` | Indicates whether to collect and export previous month. | -| kubecost.aggregation | string | `"pod"` | The level of granularity to use when aggregating the cost data. Valid values are namespace, controller, or pod. | +| kubecost.aggregation | string | `"pod"` | The level of granularity to use when aggregating the cost data. Valid values are namespace, controller, node, or pod. | | kubecost.apiPath | string | `"/model/"` | The base path for the Kubecost API endpoint. | | kubecost.host | string | `"kubecost-cost-analyzer.kubecost.svc.cluster.local:9090"` | Default kubecost-cost-analyzer service host on the current cluster. For current cluster is serviceName.namespaceName.svc.cluster.local | | kubecost.idle | bool | `true` | Indicates whether to include cost of idle resources. | diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index 273b1c1..a364289 100644 --- a/helm-chart/Chart.yaml +++ b/helm-chart/Chart.yaml @@ -6,10 +6,10 @@ description: Kubecost exporter helm chart for Kubernetes # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.14.0 +version: 1.15.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "1.14" +appVersion: "1.15" diff --git a/helm-chart/README.md b/helm-chart/README.md index f16fa14..163fd0f 100644 --- a/helm-chart/README.md +++ b/helm-chart/README.md @@ -1,6 +1,6 @@ # cbi-oi-kubecost-exporter -![Version: 1.14.0](https://img.shields.io/badge/Version-1.14.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.14](https://img.shields.io/badge/AppVersion-1.14-informational?style=flat-square) +![Version: 1.15.0](https://img.shields.io/badge/Version-1.15.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.15](https://img.shields.io/badge/AppVersion-1.15-informational?style=flat-square) ### Kubecost exporter helm chart for Kubernetes @@ -98,10 +98,10 @@ You should see 200/201s in the logs, which indicates that the exporter is workin | flexera.shard | string | `"NAM"` | The zone of your Flexera One account. Valid values are NAM, EU or AU. | | image.pullPolicy | string | `"Always"` | | | image.repository | string | `"public.ecr.aws/flexera/cbi-oi-kubecost-exporter"` | | -| image.tag | string | `"1.14"` | | +| image.tag | string | `"1.15"` | | | imagePullSecrets | list | `[]` | | | includePreviousMonth | bool | `false` | Indicates whether to collect and export previous month. | -| kubecost.aggregation | string | `"pod"` | The level of granularity to use when aggregating the cost data. Valid values are namespace, controller, or pod. | +| kubecost.aggregation | string | `"pod"` | The level of granularity to use when aggregating the cost data. Valid values are namespace, controller, node, or pod. | | kubecost.apiPath | string | `"/model/"` | The base path for the Kubecost API endpoint. | | kubecost.host | string | `"kubecost-cost-analyzer.kubecost.svc.cluster.local:9090"` | Default kubecost-cost-analyzer service host on the current cluster. For current cluster is serviceName.namespaceName.svc.cluster.local | | kubecost.idle | bool | `true` | Indicates whether to include cost of idle resources. | diff --git a/helm-chart/cbi-oi-kubecost-exporter-1.15.0.tgz b/helm-chart/cbi-oi-kubecost-exporter-1.15.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..89546336139cbdc00103a4d94bf7a5aa224cd5ef GIT binary patch literal 5623 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