From 5fc94fdaee8ea9c67467a58130d9eff7b57b6e0c Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 18 Dec 2024 19:48:54 +0100 Subject: [PATCH] feat: Add KubecostExtractors controller (#320) * add kubecostextractors controller * fetch cluster data * add recommendations * add comment * bump docker image * add container * set one mont window * refactor to use svc dns --------- Co-authored-by: michaeljguarino --- Dockerfile | 2 +- PROJECT | 5 + api/v1alpha1/kubecostextractor_types.go | 77 ++++ api/v1alpha1/zz_generated.deepcopy.go | 98 ++++- ...loyments.plural.sh_kubecostextractors.yaml | 176 ++++++++ ...deployments.plural.sh_upgradeinsights.yaml | 2 - .../deployment-operator/templates/rbac.yaml | 6 +- cmd/agent/kubernetes.go | 12 + ...loyments.plural.sh_kubecostextractors.yaml | 176 ++++++++ ...deployments.plural.sh_upgradeinsights.yaml | 2 - config/rbac/role.yaml | 3 + config/samples/kubecostExtractor.yaml | 13 + go.mod | 10 +- go.sum | 12 +- .../kubecostextractor_controller.go | 411 ++++++++++++++++++ pkg/client/console.go | 1 + pkg/client/kubecost.go | 7 + pkg/test/mocks/Client_mock.go | 58 +++ 18 files changed, 1052 insertions(+), 19 deletions(-) create mode 100644 api/v1alpha1/kubecostextractor_types.go create mode 100644 charts/deployment-operator/crds/deployments.plural.sh_kubecostextractors.yaml create mode 100644 config/crd/bases/deployments.plural.sh_kubecostextractors.yaml create mode 100644 config/samples/kubecostExtractor.yaml create mode 100644 internal/controller/kubecostextractor_controller.go create mode 100644 pkg/client/kubecost.go diff --git a/Dockerfile b/Dockerfile index ef6ce1f7..98f90f18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22.6-alpine3.20 AS builder +FROM golang:1.22.7-alpine3.20 AS builder ARG TARGETARCH diff --git a/PROJECT b/PROJECT index 6ab46407..eb2e251d 100644 --- a/PROJECT +++ b/PROJECT @@ -17,4 +17,9 @@ resources: kind: CustomHealth path: github.com/pluralsh/deployment-operator/api/v1alpha1 version: v1alpha1 +- controller: true + domain: plural.sh + group: deployments + kind: KubecostExtractor + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/kubecostextractor_types.go b/api/v1alpha1/kubecostextractor_types.go new file mode 100644 index 00000000..2b268363 --- /dev/null +++ b/api/v1alpha1/kubecostextractor_types.go @@ -0,0 +1,77 @@ +package v1alpha1 + +import ( + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + defaultKubecostExtractorInterval = 2 * time.Hour + defaultKubecostExtractorPort = "9090" +) + +func init() { + SchemeBuilder.Register(&KubecostExtractor{}, &KubecostExtractorList{}) +} + +type KubecostExtractorSpec struct { + // +kubebuilder:default="1h" + // +kubebuilder:validation:Optional + Interval *string `json:"interval,omitempty"` + KubecostServiceRef corev1.ObjectReference `json:"kubecostServiceRef"` + // +kubebuilder:validation:Optional + KubecostPort *int32 `json:"kubecostPort,omitempty"` + // RecommendationThreshold float value for example: `1.2 or 0.001` + RecommendationThreshold string `json:"recommendationThreshold"` +} + +// KubecostExtractorList contains a list of [KubecostExtractor] +// +kubebuilder:object:root=true +type KubecostExtractorList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []KubecostExtractor `json:"items"` +} + +// KubecostExtractor +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:subresource:status +type KubecostExtractor struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KubecostExtractorSpec `json:"spec"` + + // Status of the MetricsAggregate + Status Status `json:"status,omitempty"` +} + +func (in *KubecostExtractorSpec) GetInterval() time.Duration { + if in.Interval == nil { + return defaultKubecostExtractorInterval + } + + interval, err := time.ParseDuration(*in.Interval) + if err != nil { + return defaultKubecostExtractorInterval + } + + return interval +} + +func (in *KubecostExtractorSpec) GetPort() string { + if in.KubecostPort == nil { + return defaultKubecostExtractorPort + } + + return fmt.Sprintf("%d", *in.KubecostPort) +} + +func (in *KubecostExtractor) SetCondition(condition metav1.Condition) { + meta.SetStatusCondition(&in.Status.Conditions, condition) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 0ab1c940..30a39090 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -32,7 +32,16 @@ import ( // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AWSProviderCredentials) DeepCopyInto(out *AWSProviderCredentials) { *out = *in - out.SecretAccessKeyRef = in.SecretAccessKeyRef + if in.AccessKeyID != nil { + in, out := &in.AccessKeyID, &out.AccessKeyID + *out = new(string) + **out = **in + } + if in.SecretAccessKeyRef != nil { + in, out := &in.SecretAccessKeyRef, &out.SecretAccessKeyRef + *out = new(corev1.SecretReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSProviderCredentials. @@ -442,6 +451,91 @@ func (in *IngressReplicaSpec) DeepCopy() *IngressReplicaSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubecostExtractor) DeepCopyInto(out *KubecostExtractor) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubecostExtractor. +func (in *KubecostExtractor) DeepCopy() *KubecostExtractor { + if in == nil { + return nil + } + out := new(KubecostExtractor) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KubecostExtractor) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubecostExtractorList) DeepCopyInto(out *KubecostExtractorList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]KubecostExtractor, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubecostExtractorList. +func (in *KubecostExtractorList) DeepCopy() *KubecostExtractorList { + if in == nil { + return nil + } + out := new(KubecostExtractorList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KubecostExtractorList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubecostExtractorSpec) DeepCopyInto(out *KubecostExtractorSpec) { + *out = *in + if in.Interval != nil { + in, out := &in.Interval, &out.Interval + *out = new(string) + **out = **in + } + out.KubecostServiceRef = in.KubecostServiceRef + if in.KubecostPort != nil { + in, out := &in.KubecostPort, &out.KubecostPort + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubecostExtractorSpec. +func (in *KubecostExtractorSpec) DeepCopy() *KubecostExtractorSpec { + if in == nil { + return nil + } + out := new(KubecostExtractorSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MetricsAggregate) DeepCopyInto(out *MetricsAggregate) { *out = *in @@ -637,7 +731,7 @@ func (in *ProviderCredentials) DeepCopyInto(out *ProviderCredentials) { if in.AWS != nil { in, out := &in.AWS, &out.AWS *out = new(AWSProviderCredentials) - **out = **in + (*in).DeepCopyInto(*out) } } diff --git a/charts/deployment-operator/crds/deployments.plural.sh_kubecostextractors.yaml b/charts/deployment-operator/crds/deployments.plural.sh_kubecostextractors.yaml new file mode 100644 index 00000000..aebb1826 --- /dev/null +++ b/charts/deployment-operator/crds/deployments.plural.sh_kubecostextractors.yaml @@ -0,0 +1,176 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: kubecostextractors.deployments.plural.sh +spec: + group: deployments.plural.sh + names: + kind: KubecostExtractor + listKind: KubecostExtractorList + plural: kubecostextractors + singular: kubecostextractor + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: KubecostExtractor + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + interval: + default: 1h + type: string + kubecostPort: + format: int32 + type: integer + kubecostServiceRef: + description: ObjectReference contains enough information to let you + inspect or modify the referred object. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + recommendationThreshold: + description: 'RecommendationThreshold float value for example: `1.2 + or 0.001`' + type: string + required: + - kubecostServiceRef + - recommendationThreshold + type: object + status: + description: Status of the MetricsAggregate + properties: + conditions: + description: Represents the observations of a PrAutomation's current + state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + id: + description: ID of the resource in the Console API. + type: string + sha: + description: SHA of last applied configuration. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/deployment-operator/crds/deployments.plural.sh_upgradeinsights.yaml b/charts/deployment-operator/crds/deployments.plural.sh_upgradeinsights.yaml index bc2bd75b..07808617 100644 --- a/charts/deployment-operator/crds/deployments.plural.sh_upgradeinsights.yaml +++ b/charts/deployment-operator/crds/deployments.plural.sh_upgradeinsights.yaml @@ -89,9 +89,7 @@ spec: type: object x-kubernetes-map-type: atomic required: - - accessKeyID - region - - secretAccessKeyRef type: object type: object distro: diff --git a/charts/deployment-operator/templates/rbac.yaml b/charts/deployment-operator/templates/rbac.yaml index 24f2dc61..73126966 100644 --- a/charts/deployment-operator/templates/rbac.yaml +++ b/charts/deployment-operator/templates/rbac.yaml @@ -27,13 +27,13 @@ rules: resources: ["pods"] verbs: ["delete"] - apiGroups: ["deployments.plural.sh"] - resources: ["customhealths"] + resources: ["customhealths", "kubecostextractors"] verbs: ["create","delete","get", "list", "patch", "update", "watch"] - apiGroups: ["deployments.plural.sh"] - resources: ["customhealths/finalizers"] + resources: ["customhealths/finalizers", "kubecostextractors/finalizers"] verbs: ["update"] - apiGroups: ["deployments.plural.sh"] - resources: ["customhealths/status"] + resources: ["customhealths/status", "kubecostextractors/status"] verbs: ["get", "patch", "update", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/cmd/agent/kubernetes.go b/cmd/agent/kubernetes.go index b6eebf02..8c8a5d3f 100644 --- a/cmd/agent/kubernetes.go +++ b/cmd/agent/kubernetes.go @@ -6,6 +6,8 @@ import ( "os" "strings" + cmap "github.com/orcaman/concurrent-map/v2" + trivy "github.com/aquasecurity/trivy-operator/pkg/apis/aquasecurity/v1alpha1" "github.com/argoproj/argo-rollouts/pkg/apis/rollouts" rolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" @@ -241,4 +243,14 @@ func registerKubeReconcilersOrDie( }).SetupWithManager(ctx, manager); err != nil { setupLog.Error(err, "unable to create controller", "controller", "MetricsAggregate") } + + if err := (&controller.KubecostExtractorReconciler{ + Client: manager.GetClient(), + Scheme: manager.GetScheme(), + KubeClient: kubeClient, + ExtConsoleClient: extConsoleClient, + Tasks: cmap.New[context.CancelFunc](), + }).SetupWithManager(manager); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "MetricsAggregate") + } } diff --git a/config/crd/bases/deployments.plural.sh_kubecostextractors.yaml b/config/crd/bases/deployments.plural.sh_kubecostextractors.yaml new file mode 100644 index 00000000..aebb1826 --- /dev/null +++ b/config/crd/bases/deployments.plural.sh_kubecostextractors.yaml @@ -0,0 +1,176 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: kubecostextractors.deployments.plural.sh +spec: + group: deployments.plural.sh + names: + kind: KubecostExtractor + listKind: KubecostExtractorList + plural: kubecostextractors + singular: kubecostextractor + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: KubecostExtractor + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + interval: + default: 1h + type: string + kubecostPort: + format: int32 + type: integer + kubecostServiceRef: + description: ObjectReference contains enough information to let you + inspect or modify the referred object. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + recommendationThreshold: + description: 'RecommendationThreshold float value for example: `1.2 + or 0.001`' + type: string + required: + - kubecostServiceRef + - recommendationThreshold + type: object + status: + description: Status of the MetricsAggregate + properties: + conditions: + description: Represents the observations of a PrAutomation's current + state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + id: + description: ID of the resource in the Console API. + type: string + sha: + description: SHA of last applied configuration. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/deployments.plural.sh_upgradeinsights.yaml b/config/crd/bases/deployments.plural.sh_upgradeinsights.yaml index bc2bd75b..07808617 100644 --- a/config/crd/bases/deployments.plural.sh_upgradeinsights.yaml +++ b/config/crd/bases/deployments.plural.sh_upgradeinsights.yaml @@ -89,9 +89,7 @@ spec: type: object x-kubernetes-map-type: atomic required: - - accessKeyID - region - - secretAccessKeyRef type: object type: object distro: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index b245da31..555b67ae 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -21,6 +21,7 @@ rules: - deployments.plural.sh resources: - customhealths + - kubecostextractors - pipelinegates verbs: - create @@ -34,6 +35,7 @@ rules: - deployments.plural.sh resources: - customhealths/finalizers + - kubecostextractors/finalizers - pipelinegates/finalizers verbs: - update @@ -41,6 +43,7 @@ rules: - deployments.plural.sh resources: - customhealths/status + - kubecostextractors/status - pipelinegates/status verbs: - get diff --git a/config/samples/kubecostExtractor.yaml b/config/samples/kubecostExtractor.yaml new file mode 100644 index 00000000..02eb23d7 --- /dev/null +++ b/config/samples/kubecostExtractor.yaml @@ -0,0 +1,13 @@ +apiVersion: deployments.plural.sh/v1alpha1 +kind: KubecostExtractor +metadata: + labels: + app.kubernetes.io/part-of: deployment-operator + app.kubernetes.io/created-by: deployment-operator + name: default +spec: + interval: "10h" + recommendationThreshold: "0.00002" + kubecostServiceRef: + name: kubecost-cost-analyzer + namespace: kubecost diff --git a/go.mod b/go.mod index d33a5b84..c064f61a 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,14 @@ module github.com/pluralsh/deployment-operator -go 1.22.6 +go 1.22.7 + +toolchain go1.23.1 require ( github.com/Masterminds/semver/v3 v3.3.0 github.com/Masterminds/sprig/v3 v3.3.0 github.com/Yamashou/gqlgenc v0.25.0 + github.com/aquasecurity/trivy-db v0.0.0-20231020043206-3770774790ce github.com/aquasecurity/trivy-operator v0.22.0 github.com/argoproj/argo-rollouts v1.7.2 github.com/aws/aws-sdk-go-v2 v1.30.5 @@ -27,9 +30,10 @@ require ( github.com/onsi/gomega v1.34.2 github.com/open-policy-agent/frameworks/constraint v0.0.0-20240802234259-aa99306df54e github.com/open-policy-agent/gatekeeper/v3 v3.17.1 + github.com/opencost/opencost/core v0.0.0-20241216191657-30e5d9a27f41 github.com/orcaman/concurrent-map/v2 v2.0.1 github.com/pkg/errors v0.9.1 - github.com/pluralsh/console/go/client v1.25.1 + github.com/pluralsh/console/go/client v1.25.2 github.com/pluralsh/controller-reconcile-helper v0.1.0 github.com/pluralsh/gophoenix v0.1.3-0.20231201014135-dff1b4309e34 github.com/pluralsh/polly v0.1.10 @@ -110,7 +114,6 @@ require ( github.com/aquasecurity/tml v0.6.1 // indirect github.com/aquasecurity/trivy v0.53.0 // indirect github.com/aquasecurity/trivy-checks v0.13.0 // indirect - github.com/aquasecurity/trivy-db v0.0.0-20231020043206-3770774790ce // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/ashanbrown/forbidigo v1.6.0 // indirect github.com/ashanbrown/makezero v1.1.1 // indirect @@ -338,6 +341,7 @@ require ( github.com/osteele/tuesday v1.0.3 // indirect github.com/owenrumney/squealer v1.2.2 // indirect github.com/package-url/packageurl-go v0.1.3 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect diff --git a/go.sum b/go.sum index 9c9ceaf8..70191a21 100644 --- a/go.sum +++ b/go.sum @@ -1154,6 +1154,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencost/opencost/core v0.0.0-20241216191657-30e5d9a27f41 h1:WQOP5mHuj+GyhBphedhm0DeTkiM+308BftppaS81S/M= +github.com/opencost/opencost/core v0.0.0-20241216191657-30e5d9a27f41/go.mod h1:ZC/gMqBdeujs9pokZNdX2bfFZmGrOkdzPuyLLgSK4Is= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= @@ -1173,6 +1175,8 @@ github.com/owenrumney/squealer v1.2.2 h1:zsnZSwkWi8Y2lgwmg77b565vlHQovlvBrSBzmAs github.com/owenrumney/squealer v1.2.2/go.mod h1:pDCW33bWJ2kDOuz7+2BSXDgY38qusVX0MtjPCSFtdSo= github.com/package-url/packageurl-go v0.1.3 h1:4juMED3hHiz0set3Vq3KeQ75KD1avthoXLtmE3I0PLs= github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= @@ -1186,12 +1190,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pluralsh/console/go/client v1.22.6 h1:ECH+bJyhfywPd8lLUJWlruBjfuKp5WGQioCXT4RD0Fk= -github.com/pluralsh/console/go/client v1.22.6/go.mod h1:lpoWASYsM9keNePS3dpFiEisUHEfObIVlSL3tzpKn8k= -github.com/pluralsh/console/go/client v1.25.0 h1:Q4Q1gmO9b7vlyUuU+TkLOCImfJeNyyUCVdi/Z5hfj7M= -github.com/pluralsh/console/go/client v1.25.0/go.mod h1:lpoWASYsM9keNePS3dpFiEisUHEfObIVlSL3tzpKn8k= -github.com/pluralsh/console/go/client v1.25.1 h1:XaCf0CH3TyPVM+Mf6xFKqz2Ysf+qs+qxL5GSTjZ7UkI= -github.com/pluralsh/console/go/client v1.25.1/go.mod h1:lpoWASYsM9keNePS3dpFiEisUHEfObIVlSL3tzpKn8k= +github.com/pluralsh/console/go/client v1.25.2 h1:Ha/ZF5t+ilJ0MVZPeDO46tK7HyKmi74c1DIHPA2sDY0= +github.com/pluralsh/console/go/client v1.25.2/go.mod h1:lpoWASYsM9keNePS3dpFiEisUHEfObIVlSL3tzpKn8k= github.com/pluralsh/controller-reconcile-helper v0.1.0 h1:BV3dYZFH5rn8ZvZjtpkACSv/GmLEtRftNQj/Y4ddHEo= github.com/pluralsh/controller-reconcile-helper v0.1.0/go.mod h1:RxAbvSB4/jkvx616krCdNQXPbpGJXW3J1L3rASxeFOA= github.com/pluralsh/gophoenix v0.1.3-0.20231201014135-dff1b4309e34 h1:ab2PN+6if/Aq3/sJM0AVdy1SYuMAnq4g20VaKhTm/Bw= diff --git a/internal/controller/kubecostextractor_controller.go b/internal/controller/kubecostextractor_controller.go new file mode 100644 index 00000000..cc4b8697 --- /dev/null +++ b/internal/controller/kubecostextractor_controller.go @@ -0,0 +1,411 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/opencost/opencost/core/pkg/opencost" + cmap "github.com/orcaman/concurrent-map/v2" + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/deployment-operator/api/v1alpha1" + "github.com/pluralsh/deployment-operator/internal/utils" + consoleclient "github.com/pluralsh/deployment-operator/pkg/client" + "github.com/samber/lo" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/cli-utils/pkg/inventory" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var kubecostResourceTypes = []string{"deployment", "statefulset", "daemonset"} + +// KubecostExtractorReconciler reconciles a KubecostExtractor object +type KubecostExtractorReconciler struct { + client.Client + Scheme *runtime.Scheme + KubeClient kubernetes.Interface + ExtConsoleClient consoleclient.Client + Tasks cmap.ConcurrentMap[string, context.CancelFunc] +} + +func (r *KubecostExtractorReconciler) RunOnInterval(ctx context.Context, key string, interval time.Duration, condition wait.ConditionWithContextFunc) { + if _, exists := r.Tasks.Get(key); exists { + return + } + ctxCancel, cancel := context.WithCancel(ctx) + r.Tasks.Set(key, cancel) + + go func() { + _ = wait.PollUntilContextCancel(ctxCancel, interval, true, condition) + }() +} + +//+kubebuilder:rbac:groups=deployments.plural.sh,resources=kubecostextractors,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=deployments.plural.sh,resources=kubecostextractors/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=deployments.plural.sh,resources=kubecostextractors/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.3/pkg/reconcile +func (r *KubecostExtractorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ reconcile.Result, reterr error) { + logger := log.FromContext(ctx) + + kubecost := &v1alpha1.KubecostExtractor{} + if err := r.Get(ctx, req.NamespacedName, kubecost); err != nil { + logger.Error(err, "Unable to fetch kubecost") + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !kubecost.DeletionTimestamp.IsZero() { + if cancel, exists := r.Tasks.Get(req.NamespacedName.String()); exists { + cancel() + r.Tasks.Remove(req.NamespacedName.String()) + } + } + + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionFalse, v1alpha1.ReadyConditionReason, "") + + scope, err := NewDefaultScope(ctx, r.Client, kubecost) + if err != nil { + logger.Error(err, "failed to create scope") + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error()) + return ctrl.Result{}, err + } + + // Always patch object when exiting this function, so we can persist any object changes. + defer func() { + if err := scope.PatchObject(); err != nil && reterr == nil { + reterr = err + } + }() + + // check service + kubecostService := &corev1.Service{} + if err := r.Get(ctx, client.ObjectKey{Name: kubecost.Spec.KubecostServiceRef.Name, Namespace: kubecost.Spec.KubecostServiceRef.Namespace}, kubecostService); err != nil { + logger.Error(err, "Unable to fetch service for kubecost") + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionFalse, v1alpha1.ErrorConditionReason, err.Error()) + return ctrl.Result{}, err + } + recommendationThreshold, err := strconv.ParseFloat(kubecost.Spec.RecommendationThreshold, 64) + if err != nil { + logger.Error(err, "Unable to parse recommendation threshold") + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionFalse, v1alpha1.ErrorConditionReason, err.Error()) + return ctrl.Result{}, err + } + + r.RunOnInterval(ctx, req.NamespacedName.String(), kubecost.Spec.GetInterval(), func(ctx context.Context) (done bool, err error) { + // Always patch object when exiting this function, so we can persist any object changes. + defer func() { + if err := scope.PatchObject(); err != nil && reterr == nil { + reterr = err + } + }() + recommendations, err := r.getRecommendationAttributes(ctx, kubecostService, kubecost.Spec.GetPort(), kubecost.Spec.GetInterval(), recommendationThreshold) + if err != nil { + logger.Error(err, "Unable to fetch recommendations") + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionFalse, v1alpha1.ErrorConditionReason, err.Error()) + return false, nil + } + clusterCostAttr, err := r.getClusterCost(ctx, kubecostService, kubecost.Spec.GetPort(), kubecost.Spec.GetInterval()) + if err != nil { + logger.Error(err, "Unable to fetch cluster cost") + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionFalse, v1alpha1.ErrorConditionReason, err.Error()) + return false, nil + } + namespacesCostAtrr, err := r.getNamespacesCost(ctx, kubecostService, kubecost.Spec.GetPort(), kubecost.Spec.GetInterval()) + if err != nil { + logger.Error(err, "Unable to fetch namespacesCostAtrr cost") + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionFalse, v1alpha1.ErrorConditionReason, err.Error()) + return false, nil + } + + // nothing for specified time window + if clusterCostAttr == nil && namespacesCostAtrr == nil && recommendations == nil { + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionTrue, v1alpha1.ReadyConditionReason, "") + return false, nil + } + + if _, err := r.ExtConsoleClient.IngestClusterCost(console.CostIngestAttributes{ + Cluster: clusterCostAttr, + Namespaces: namespacesCostAtrr, + Recommendations: recommendations, + }); err != nil { + logger.Error(err, "Unable to ingest cluster cost") + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionFalse, v1alpha1.ErrorConditionReason, err.Error()) + return false, nil + } + utils.MarkCondition(kubecost.SetCondition, v1alpha1.ReadyConditionType, v1.ConditionTrue, v1alpha1.ReadyConditionReason, "") + return false, nil + }) + + return ctrl.Result{}, nil +} + +func (r *KubecostExtractorReconciler) fetch(host, path string, params map[string]string) ([]byte, error) { + tr := &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + DisableCompression: true, + ResponseHeaderTimeout: 60 * time.Second, + } + client := &http.Client{Transport: tr} + urlParams := url.Values{} + for k, v := range params { + urlParams.Add(k, v) + } + + resp, err := client.Get(fmt.Sprintf("http://%s%s?%s", host, path, urlParams.Encode())) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var buffer bytes.Buffer + _, err = io.Copy(&buffer, resp.Body) + if err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +func (r *KubecostExtractorReconciler) getAllocation(ctx context.Context, srv *corev1.Service, servicePort, aggregate string) (*allocationResponse, error) { + now := time.Now() + oneMonthBefore := now.AddDate(0, -1, 0) // 0 years, -1 month, 0 days + window := fmt.Sprintf("%d,%d", oneMonthBefore.Unix(), now.Unix()) + + queryParams := map[string]string{ + "window": window, + "aggregate": aggregate, + "accumulate": "true", + } + + bytes, err := r.fetch(fmt.Sprintf("%s.%s:%s", srv.Name, srv.Namespace, servicePort), "/model/allocation", queryParams) + if err != nil { + return nil, err + } + ar := &allocationResponse{} + if err = json.Unmarshal(bytes, ar); err != nil { + return nil, err + } + return ar, nil +} + +func (r *KubecostExtractorReconciler) getRecommendationAttributes(ctx context.Context, srv *corev1.Service, servicePort string, interval time.Duration, recommendationThreshold float64) ([]*console.ClusterRecommendationAttributes, error) { + var result []*console.ClusterRecommendationAttributes + for _, resourceType := range kubecostResourceTypes { + ar, err := r.getAllocation(ctx, srv, servicePort, resourceType) + if err != nil { + return nil, err + } + if ar.Code != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", ar.Code) + } + for _, resourceCosts := range ar.Data { + if resourceCosts == nil { + continue + } + for name, allocation := range resourceCosts { + if name == opencost.IdleSuffix || name == opencost.UnallocatedSuffix { + continue + } + totalCost := allocation.TotalCost() + if totalCost > recommendationThreshold { + result = append(result, r.convertClusterRecommendationAttributes(ctx, allocation, name, resourceType)) + } + } + } + } + + return result, nil +} + +func (r *KubecostExtractorReconciler) getNamespacesCost(ctx context.Context, srv *corev1.Service, servicePort string, interval time.Duration) ([]*console.CostAttributes, error) { + var result []*console.CostAttributes + ar, err := r.getAllocation(ctx, srv, servicePort, "namespace") + if err != nil { + return nil, err + } + if ar.Code != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", ar.Code) + } + for _, clusterCosts := range ar.Data { + if clusterCosts == nil { + continue + } + for namespace, allocation := range clusterCosts { + if namespace == opencost.IdleSuffix { + continue + } + attr := convertCostAttributes(allocation) + attr.Namespace = lo.ToPtr(namespace) + result = append(result, attr) + } + } + + return result, nil +} + +func (r *KubecostExtractorReconciler) getClusterCost(ctx context.Context, srv *corev1.Service, servicePort string, interval time.Duration) (*console.CostAttributes, error) { + bytes, err := r.KubeClient.CoreV1().Services(srv.Namespace).ProxyGet("", srv.Name, servicePort, "/model/clusterInfo", nil).DoRaw(ctx) + if err != nil { + return nil, err + } + var resp clusterinfoResponse + err = json.Unmarshal(bytes, &resp) + if err != nil { + return nil, err + } + + ar, err := r.getAllocation(ctx, srv, servicePort, "cluster") + if err != nil { + return nil, err + } + if ar.Code != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", ar.Code) + } + for _, clusterCosts := range ar.Data { + if clusterCosts == nil { + continue + } + clusterCostAllocation, ok := clusterCosts[resp.Data.ClusterID] + if ok { + return convertCostAttributes(clusterCostAllocation), nil + } + } + + return nil, nil +} + +func (r *KubecostExtractorReconciler) getObjectInfo(ctx context.Context, resourceType console.ScalingRecommendationType, namespace, name string) (container, serviceId *string, err error) { + gvk := schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + } + switch resourceType { + case console.ScalingRecommendationTypeDeployment: + gvk.Kind = "Deployment" + case console.ScalingRecommendationTypeDaemonset: + gvk.Kind = "DaemonSet" + case console.ScalingRecommendationTypeStatefulset: + gvk.Kind = "StatefulSet" + default: + return nil, nil, nil + } + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + if err = r.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, obj); err != nil { + return + } + svcId, ok := obj.GetAnnotations()[inventory.OwningInventoryKey] + if ok { + serviceId = lo.ToPtr(svcId) + } + + return +} + +// SetupWithManager sets up the controller with the Manager. +func (r *KubecostExtractorReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.KubecostExtractor{}). + Complete(r) +} + +type allocationResponse struct { + Code int `json:"code"` + Data []map[string]opencost.Allocation `json:"data"` +} + +type clusterinfoResponse struct { + Data struct { + ClusterID string `json:"id"` + } `json:"data"` +} + +func (r *KubecostExtractorReconciler) convertClusterRecommendationAttributes(ctx context.Context, allocation opencost.Allocation, name, resourceType string) *console.ClusterRecommendationAttributes { + resourceTypeEnum := console.ScalingRecommendationType(strings.ToUpper(resourceType)) + result := &console.ClusterRecommendationAttributes{ + Type: lo.ToPtr(resourceTypeEnum), + Name: lo.ToPtr(name), + MemoryRequest: lo.ToPtr(allocation.RAMBytesRequestAverage), + CPURequest: lo.ToPtr(allocation.CPUCoreRequestAverage), + CPUCost: lo.ToPtr(allocation.CPUCost), + MemoryCost: lo.ToPtr(allocation.RAMCost), + GpuCost: lo.ToPtr(allocation.GPUCost), + } + if allocation.Properties != nil { + namespace, ok := allocation.Properties.NamespaceLabels["kubernetes_io_metadata_name"] + if ok { + result.Namespace = lo.ToPtr(namespace) + } + if allocation.Properties.Container != "" { + result.Container = lo.ToPtr(allocation.Properties.Container) + } + } + namespace := "" + if result.Namespace != nil { + namespace = *result.Namespace + } + + container, serviceID, err := r.getObjectInfo(ctx, resourceTypeEnum, namespace, name) + if err != nil { + return result + } + result.Container = container + result.ServiceID = serviceID + + return result +} + +func convertCostAttributes(allocation opencost.Allocation) *console.CostAttributes { + attr := &console.CostAttributes{ + Memory: lo.ToPtr(allocation.RAMBytes()), + CPU: lo.ToPtr(allocation.CPUCores()), + Storage: lo.ToPtr(allocation.PVBytes()), + MemoryUtil: lo.ToPtr(allocation.RAMBytesUsageAverage), + CPUUtil: lo.ToPtr(allocation.CPUCoreUsageAverage), + CPUCost: lo.ToPtr(allocation.CPUCost), + MemoryCost: lo.ToPtr(allocation.RAMCost), + GpuCost: lo.ToPtr(allocation.GPUCost), + LoadBalancerCost: lo.ToPtr(allocation.LoadBalancerCost), + } + if allocation.GPUAllocation != nil { + attr.GpuUtil = allocation.GPUAllocation.GPUUsageAverage + } + return attr +} diff --git a/pkg/client/console.go b/pkg/client/console.go index 5158c760..28995442 100644 --- a/pkg/client/console.go +++ b/pkg/client/console.go @@ -71,4 +71,5 @@ type Client interface { GetGroup(name string) (*console.GroupFragment, error) SaveUpgradeInsights(attributes []*console.UpgradeInsightAttributes) (*console.SaveUpgradeInsights, error) UpsertVulnerabilityReports(vulnerabilities []*console.VulnerabilityReportAttributes) (*console.UpsertVulnerabilities, error) + IngestClusterCost(attr console.CostIngestAttributes) (*console.IngestClusterCost, error) } diff --git a/pkg/client/kubecost.go b/pkg/client/kubecost.go new file mode 100644 index 00000000..52c1e7fb --- /dev/null +++ b/pkg/client/kubecost.go @@ -0,0 +1,7 @@ +package client + +import console "github.com/pluralsh/console/go/client" + +func (c *client) IngestClusterCost(attr console.CostIngestAttributes) (*console.IngestClusterCost, error) { + return c.consoleClient.IngestClusterCost(c.ctx, attr) +} diff --git a/pkg/test/mocks/Client_mock.go b/pkg/test/mocks/Client_mock.go index 73bfc166..3eadd72e 100644 --- a/pkg/test/mocks/Client_mock.go +++ b/pkg/test/mocks/Client_mock.go @@ -1070,6 +1070,64 @@ func (_c *ClientMock_GetUser_Call) RunAndReturn(run func(string) (*client.UserFr return _c } +// IngestClusterCost provides a mock function with given fields: attr +func (_m *ClientMock) IngestClusterCost(attr client.CostIngestAttributes) (*client.IngestClusterCost, error) { + ret := _m.Called(attr) + + if len(ret) == 0 { + panic("no return value specified for IngestClusterCost") + } + + var r0 *client.IngestClusterCost + var r1 error + if rf, ok := ret.Get(0).(func(client.CostIngestAttributes) (*client.IngestClusterCost, error)); ok { + return rf(attr) + } + if rf, ok := ret.Get(0).(func(client.CostIngestAttributes) *client.IngestClusterCost); ok { + r0 = rf(attr) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.IngestClusterCost) + } + } + + if rf, ok := ret.Get(1).(func(client.CostIngestAttributes) error); ok { + r1 = rf(attr) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ClientMock_IngestClusterCost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IngestClusterCost' +type ClientMock_IngestClusterCost_Call struct { + *mock.Call +} + +// IngestClusterCost is a helper method to define mock.On call +// - attr client.CostIngestAttributes +func (_e *ClientMock_Expecter) IngestClusterCost(attr interface{}) *ClientMock_IngestClusterCost_Call { + return &ClientMock_IngestClusterCost_Call{Call: _e.mock.On("IngestClusterCost", attr)} +} + +func (_c *ClientMock_IngestClusterCost_Call) Run(run func(attr client.CostIngestAttributes)) *ClientMock_IngestClusterCost_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(client.CostIngestAttributes)) + }) + return _c +} + +func (_c *ClientMock_IngestClusterCost_Call) Return(_a0 *client.IngestClusterCost, _a1 error) *ClientMock_IngestClusterCost_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ClientMock_IngestClusterCost_Call) RunAndReturn(run func(client.CostIngestAttributes) (*client.IngestClusterCost, error)) *ClientMock_IngestClusterCost_Call { + _c.Call.Return(run) + return _c +} + // IsClusterExists provides a mock function with given fields: id func (_m *ClientMock) IsClusterExists(id string) (bool, error) { ret := _m.Called(id)