├── .gitignore ├── .dockerignore ├── k8s ├── service-account.yaml ├── clusterrole.yaml ├── clusterrolebinding.yaml ├── service.yaml ├── secret.yaml ├── README.md └── deployment.yaml ├── .golangci.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── golangci-lint.yml │ └── ci.yml ├── Dockerfile-builder_distroless ├── Dockerfile-builder ├── go.mod ├── cardinality ├── metrics.go ├── mock_cardinality │ └── mock_cardinality.go ├── cardinality.go └── cardinality_test.go ├── CODE_OF_CONDUCT.md ├── README.md ├── LICENSE ├── main.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | # Please output directory and local configuration 2 | plz-out 3 | .plzconfig.local 4 | prometheus-cardinality-exporter 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore files not built in docker image 2 | plz-out 3 | 4 | # Ignore git directories 5 | .git 6 | .gitignore 7 | 8 | # Ignore circleci 9 | .circleci 10 | -------------------------------------------------------------------------------- /k8s/service-account.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | labels: 6 | app: prometheus-cardinality-exporter 7 | project: monitoring 8 | name: prometheus-cardinality-exporter 9 | -------------------------------------------------------------------------------- /k8s/clusterrole.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: prometheus-cardinality-exporter 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - namespaces 11 | - endpoints 12 | verbs: 13 | - list 14 | -------------------------------------------------------------------------------- /k8s/clusterrolebinding.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRoleBinding 4 | metadata: 5 | name: prometheus-cardinality-exporter 6 | roleRef: 7 | apiGroup: rbac.authorization.k8s.io 8 | kind: ClusterRole 9 | name: prometheus-cardinality-exporter 10 | subjects: 11 | - kind: ServiceAccount 12 | name: prometheus-cardinality-exporter 13 | namespace: monitoring 14 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | 4 | linters: 5 | disable: 6 | # Should fix the issues and re-enable this linter 7 | - errcheck 8 | 9 | linters-settings: 10 | gci: 11 | sections: 12 | - standard # Captures all standard packages if they do not match another section. 13 | - default # Contains all imports that could not be matched to another section type. 14 | - prefix(github.com/thought-machine/please) 15 | -------------------------------------------------------------------------------- /k8s/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: prometheus-cardinality-exporter-service 6 | annotations: 7 | prometheus.io/scrape: "true" 8 | prometheus.io/port: "9090" 9 | labels: 10 | app: prometheus-cardinality-exporter 11 | project: cardinality-exporter 12 | team: cloud 13 | spec: 14 | type: ClusterIP 15 | ports: 16 | - name: http 17 | port: 9090 18 | targetPort: http 19 | selector: 20 | app: prometheus-cardinality-exporter 21 | project: cardinality-exporter 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "docker" # See documentation for possible values 13 | directory: "/" # Location of Dockerfiles 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /k8s/secret.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: prometheus-cardinality-exporter-secret 6 | namespace: example-namespace 7 | stringData: 8 | prometheus-api-auth-values.yaml: | 9 | example-namespace_example-prometheus-instance_example-sharded-instance: "Basic YWRtaW46cGFzc3dvcmQ=" # set Authorization header for specific sharded instance 10 | example-namespace_example-prometheus-instance: "Bearer 123456789" # set Authorization header for all sharded instances that have the Prometheus instance name "example-prometheus-instance" in namespace "example-namespace" 11 | example-namespace: "Basic 123456789" # set Authorization header for all instances in namespace "example-namespace" 12 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | pull-requests: read 13 | 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.25.5' 23 | cache: false 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@v7 26 | with: 27 | # Require: The version of golangci-lint to use. 28 | version: v2.7.0 29 | 30 | # Optional: working directory, useful for monorepos 31 | working-directory: cardinality 32 | -------------------------------------------------------------------------------- /Dockerfile-builder_distroless: -------------------------------------------------------------------------------- 1 | # 1.25.5-alpine3.23 (linux/amd64) 2 | FROM golang:1.25.5-alpine3.23@sha256:26111811bc967321e7b6f852e914d14bede324cd1accb7f81811929a6a57fea9 AS builder 3 | 4 | RUN ln -s /usr/local/go/bin/go /usr/local/bin/go 5 | 6 | RUN apk add --no-cache curl wget gcc make bash git musl-dev libc6-compat gettext 7 | 8 | WORKDIR /go/github.com/thought-machine/prometheus-cardinality-exporter 9 | 10 | COPY . . 11 | 12 | RUN go build ./... 13 | 14 | RUN go test ./... -race 15 | 16 | # So the binary is where we expect it 17 | # env var and flags ensure a static binary 18 | RUN CGO_ENABLED=0 go build -ldflags="-extldflags=-static" . 19 | 20 | FROM scratch 21 | 22 | EXPOSE 9090 23 | 24 | COPY --from=builder /go/github.com/thought-machine/prometheus-cardinality-exporter/prometheus-cardinality-exporter /home/app/prometheus-cardinality-exporter 25 | 26 | USER 255999 27 | 28 | ENTRYPOINT ["/home/app/prometheus-cardinality-exporter"] 29 | -------------------------------------------------------------------------------- /k8s/README.md: -------------------------------------------------------------------------------- 1 | # K8s Files 2 | These yamls are provided to give an idea of how this exporter can work within kubernetes clusters 3 | ## ```deployment.yaml``` 4 | Defines the deployment of the exporter. It specifies the replicas and resources required to run it as well as the http port to expose and the arguments to use. In order to use this deployment file it is likely that you will have to change the image reference accordingly. 5 | ## ```service.yaml``` 6 | Defines the kubernetes service, which is an abstraction away from the actual pods running the code. The service allows the pod(s) to be accessed without a fixed IP address. 7 | ## ```service-account.yaml``` 8 | Defines the service account for the service. 9 | ## ```clusterrole.yaml``` 10 | States the resources that service discovery is allowed to query (namespaces, endpoints) and with what method (list). 11 | ## ```clusterrolesbinding.yaml``` 12 | Binds the cluster role to the service account. 13 | ## ```secret.yaml``` 14 | Stores the configuration files necessary to run the exporter. Currently only one secret file is (possibly) required; this is the YAML file specified by the ```--auth``` flag. 15 | -------------------------------------------------------------------------------- /Dockerfile-builder: -------------------------------------------------------------------------------- 1 | # 1.25.5-alpine3.23 (linux/amd64) 2 | FROM golang:1.25.5-alpine3.23@sha256:26111811bc967321e7b6f852e914d14bede324cd1accb7f81811929a6a57fea9 AS builder 3 | 4 | RUN ln -s /usr/local/go/bin/go /usr/local/bin/go 5 | 6 | RUN apk add --no-cache curl wget gcc make bash git musl-dev libc6-compat gettext 7 | 8 | WORKDIR /go/github.com/thought-machine/prometheus-cardinality-exporter 9 | 10 | COPY . . 11 | 12 | RUN go build ./... 13 | 14 | RUN go test ./... -race 15 | 16 | # So the binary is where we expect it 17 | # env var and flags ensure a static binary 18 | RUN CGO_ENABLED=0 go build -ldflags="-extldflags=-static" . 19 | 20 | # alpine:3.23 (linux/amd64) 21 | FROM alpine@sha256:51183f2cfa6320055da30872f211093f9ff1d3cf06f39a0bdb212314c5dc7375 22 | 23 | EXPOSE 9090 24 | 25 | COPY --from=builder /go/github.com/thought-machine/prometheus-cardinality-exporter/prometheus-cardinality-exporter /home/app/prometheus-cardinality-exporter 26 | 27 | # Max user 28 | RUN addgroup -g 255999 -S app && \ 29 | adduser -u 255999 -S app -G app 30 | 31 | RUN chmod +x /home/app/prometheus-cardinality-exporter 32 | 33 | USER app 34 | 35 | ENTRYPOINT ["/home/app/prometheus-cardinality-exporter"] 36 | -------------------------------------------------------------------------------- /k8s/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: prometheus-cardinality-exporter 6 | labels: 7 | app: prometheus-cardinality-exporter 8 | project: cardinality-exporter 9 | team: cloud 10 | spec: 11 | replicas: 1 12 | revisionHistoryLimit: 5 13 | selector: 14 | matchLabels: 15 | app: prometheus-cardinality-exporter 16 | project: cardinality-exporter 17 | team: cloud 18 | template: 19 | metadata: 20 | labels: 21 | app: prometheus-cardinality-exporter 22 | project: cardinality-exporter 23 | team: cloud 24 | spec: 25 | serviceAccountName: prometheus-cardinality-exporter 26 | containers: 27 | - name: prometheus-cardinality-exporter 28 | image: //:prometheus-cardinality-exporter_distroless 29 | command: ["/home/app/prometheus-cardinality-exporter"] 30 | args: ["--auth=/etc/prometheus-cardinality-exporter/auth/prometheus-api-auth-values.yaml", "--service_discovery", "--freq=2", "--selector=app=prometheus", "--regex=prometheus[a-zA-Z0-9_-]*"] 31 | volumeMounts: 32 | - mountPath: /etc/prometheus-cardinality-exporter/auth 33 | name: auth 34 | ports: 35 | - containerPort: 9090 36 | name: http 37 | livenessProbe: 38 | httpGet: 39 | path: /health 40 | port: http 41 | initialDelaySeconds: 5 42 | periodSeconds: 3 43 | readinessProbe: 44 | httpGet: 45 | path: /health 46 | port: http 47 | initialDelaySeconds: 5 48 | periodSeconds: 3 49 | resources: 50 | requests: 51 | memory: 100Mi 52 | cpu: 100m 53 | limits: 54 | memory: 700Mi 55 | volumes: 56 | - name: auth 57 | secret: 58 | defaultMode: 420 59 | secretName: prometheus-cardinality-exporter-secret 60 | items: 61 | - key: prometheus-api-auth-values.yaml 62 | path: prometheus-api-auth-values.yaml 63 | securityContext: 64 | runAsUser: 1000 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github/thought-machine/prometheus-cardinality-exporter 2 | 3 | go 1.25.5 4 | 5 | require ( 6 | github.com/cenkalti/backoff v2.2.1+incompatible 7 | github.com/golang/mock v1.6.0 8 | github.com/jessevdk/go-flags v1.6.1 9 | github.com/prometheus/client_golang v1.23.2 10 | github.com/sirupsen/logrus v1.9.3 11 | github.com/stretchr/testify v1.11.1 12 | gopkg.in/yaml.v3 v3.0.1 13 | k8s.io/apimachinery v0.34.3 14 | k8s.io/client-go v0.34.3 15 | ) 16 | 17 | require ( 18 | github.com/beorn7/perks v1.0.1 // indirect 19 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 20 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 21 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 22 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 23 | github.com/go-logr/logr v1.4.2 // indirect 24 | github.com/go-openapi/jsonpointer v0.21.1 // indirect 25 | github.com/go-openapi/jsonreference v0.21.0 // indirect 26 | github.com/go-openapi/swag v0.23.1 // indirect 27 | github.com/gogo/protobuf v1.3.2 // indirect 28 | github.com/google/gnostic-models v0.7.0 // indirect 29 | github.com/google/uuid v1.6.0 // indirect 30 | github.com/josharian/intern v1.0.0 // indirect 31 | github.com/json-iterator/go v1.1.12 // indirect 32 | github.com/mailru/easyjson v0.9.0 // indirect 33 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 34 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 35 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 36 | github.com/pkg/errors v0.9.1 // indirect 37 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 38 | github.com/prometheus/client_model v0.6.2 // indirect 39 | github.com/prometheus/common v0.66.1 // indirect 40 | github.com/prometheus/procfs v0.16.1 // indirect 41 | github.com/x448/float16 v0.8.4 // indirect 42 | go.yaml.in/yaml/v2 v2.4.2 // indirect 43 | go.yaml.in/yaml/v3 v3.0.4 // indirect 44 | golang.org/x/net v0.43.0 // indirect 45 | golang.org/x/oauth2 v0.30.0 // indirect 46 | golang.org/x/sys v0.35.0 // indirect 47 | golang.org/x/term v0.34.0 // indirect 48 | golang.org/x/text v0.28.0 // indirect 49 | golang.org/x/time v0.11.0 // indirect 50 | google.golang.org/protobuf v1.36.8 // indirect 51 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 52 | gopkg.in/inf.v0 v0.9.1 // indirect 53 | k8s.io/api v0.34.3 // indirect 54 | k8s.io/klog/v2 v2.130.1 // indirect 55 | k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect 56 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect 57 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 58 | sigs.k8s.io/randfill v1.0.0 // indirect 59 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 60 | sigs.k8s.io/yaml v1.6.0 // indirect 61 | ) 62 | -------------------------------------------------------------------------------- /cardinality/metrics.go: -------------------------------------------------------------------------------- 1 | package cardinality 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | ) 6 | 7 | var ( 8 | // SeriesCountByMetricNameGauge provides a list of metrics names and their series count 9 | SeriesCountByMetricNameGauge = PrometheusCardinalityMetric{ 10 | GaugeVec: prometheus.NewGaugeVec( 11 | prometheus.GaugeOpts{ 12 | Subsystem: "cardinality_exporter", 13 | Name: "series_count_by_metric_name_total", 14 | Help: "A list of metrics names and their series count.", 15 | }, 16 | []string{ 17 | "metric", 18 | "scraped_instance", 19 | "sharded_instance", 20 | "instance_namespace", 21 | }, 22 | ), 23 | } 24 | 25 | // LabelValueCountByLabelNameGauge provides a list of the label names and their value count 26 | LabelValueCountByLabelNameGauge = PrometheusCardinalityMetric{ 27 | GaugeVec: prometheus.NewGaugeVec( 28 | prometheus.GaugeOpts{ 29 | Subsystem: "cardinality_exporter", 30 | Name: "label_value_count_by_label_name_total", 31 | Help: "A list of the label names and their value count.", 32 | }, 33 | []string{ 34 | "label", 35 | "scraped_instance", 36 | "sharded_instance", 37 | "instance_namespace", 38 | }, 39 | ), 40 | } 41 | 42 | // MemoryInBytesByLabelNameGauge provides a list of the label names and memory used in bytes 43 | // Memory usage is calculated by adding the length of all values for a given label name 44 | MemoryInBytesByLabelNameGauge = PrometheusCardinalityMetric{ 45 | GaugeVec: prometheus.NewGaugeVec( 46 | prometheus.GaugeOpts{ 47 | Subsystem: "cardinality_exporter", 48 | Name: "memory_by_label_name_bytes", 49 | Help: "A list of the label names and memory used in bytes. Memory usage is calculated by adding the length of all values for a given label name.", 50 | }, 51 | []string{ 52 | "label", 53 | "scraped_instance", 54 | "sharded_instance", 55 | "instance_namespace", 56 | }, 57 | ), 58 | } 59 | 60 | // SeriesCountByLabelValuePairGauge provides a list of label value pairs and their series count 61 | SeriesCountByLabelValuePairGauge = PrometheusCardinalityMetric{ 62 | GaugeVec: prometheus.NewGaugeVec( 63 | prometheus.GaugeOpts{ 64 | Subsystem: "cardinality_exporter", 65 | Name: "series_count_by_label_value_pair_total", 66 | Help: "A list of label/value pairs and their series count.", 67 | }, 68 | []string{ 69 | "label_pair", 70 | "scraped_instance", 71 | "sharded_instance", 72 | "instance_namespace", 73 | }, 74 | ), 75 | } 76 | ) 77 | 78 | func init() { 79 | prometheus.MustRegister(SeriesCountByMetricNameGauge.GaugeVec) 80 | prometheus.MustRegister(LabelValueCountByLabelNameGauge.GaugeVec) 81 | prometheus.MustRegister(MemoryInBytesByLabelNameGauge.GaugeVec) 82 | prometheus.MustRegister(SeriesCountByLabelValuePairGauge.GaugeVec) 83 | } 84 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' # Trigger on all branches 7 | tags: 8 | - '*' # Trigger on all tags 9 | 10 | jobs: 11 | # JOB 1: Test 12 | build-and-test: 13 | runs-on: ubuntu-latest 14 | if: github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Image Name 20 | run: | 21 | # Dynamically set image name and export to ENV 22 | echo "IMAGE_NAME=ghcr.io/$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 23 | 24 | - name: Build and Test Docker Image 25 | run: | 26 | # Use shell variable $IMAGE_NAME to ensure prefix is present 27 | docker build -f Dockerfile-builder . -t $IMAGE_NAME:latest 28 | docker run --rm $IMAGE_NAME:latest --help 29 | 30 | # JOB 2: Push 31 | build-and-push: 32 | runs-on: ubuntu-latest 33 | if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') 34 | permissions: 35 | contents: read 36 | packages: write 37 | steps: 38 | - name: Checkout code 39 | uses: actions/checkout@v4 40 | 41 | - name: Set up Docker Buildx 42 | uses: docker/setup-buildx-action@v3 43 | 44 | - name: Log in to GitHub Container Registry 45 | uses: docker/login-action@v3 46 | with: 47 | registry: ghcr.io 48 | username: ${{ github.actor }} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: Set up Image Name 52 | run: | 53 | # Dynamically set image name and export to ENV 54 | echo "IMAGE_NAME=ghcr.io/$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 55 | 56 | - name: Determine Docker Tags 57 | id: meta 58 | env: 59 | REF_TYPE: ${{ github.ref_type }} 60 | REF_NAME: ${{ github.ref_name }} 61 | REF: ${{ github.ref }} 62 | SHA: ${{ github.sha }} 63 | run: | 64 | # Debugging: Print the image name to verify prefix 65 | echo "Using Image Name: $IMAGE_NAME" 66 | 67 | # 1. Calculate the primary tag 68 | if [[ "$REF_TYPE" == "tag" ]]; then 69 | DOCKER_TAG="$REF_NAME" 70 | else 71 | SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9]/-/g') 72 | SHORT_SHA=$(echo "$SHA" | cut -c1-7) 73 | DOCKER_TAG="${SANITIZED_BRANCH}-${SHORT_SHA}" 74 | fi 75 | 76 | # 2. Construct tags using shell variable $IMAGE_NAME 77 | TAG_LIST="$IMAGE_NAME:$DOCKER_TAG" 78 | 79 | # If on main branch, add the 'latest' tag 80 | if [[ "$REF" == "refs/heads/main" ]]; then 81 | TAG_LIST="${TAG_LIST},$IMAGE_NAME:latest" 82 | fi 83 | 84 | echo "Final Tags: $TAG_LIST" 85 | echo "tags=${TAG_LIST}" >> $GITHUB_OUTPUT 86 | 87 | - name: Build and Push 88 | uses: docker/build-push-action@v5 89 | with: 90 | context: . 91 | file: Dockerfile-builder_distroless 92 | platforms: linux/amd64 93 | push: true 94 | tags: ${{ steps.meta.outputs.tags }} 95 | -------------------------------------------------------------------------------- /cardinality/mock_cardinality/mock_cardinality.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: prometheus-cardinality-exporter/cardinality (interfaces: PrometheusGaugeVec,PrometheusClient) 3 | 4 | package mock_cardinality 5 | 6 | import ( 7 | gomock "github.com/golang/mock/gomock" 8 | prometheus "github.com/prometheus/client_golang/prometheus" 9 | http "net/http" 10 | ) 11 | 12 | // MockPrometheusGaugeVec is a mock of PrometheusGaugeVec interface 13 | type MockPrometheusGaugeVec struct { 14 | ctrl *gomock.Controller 15 | recorder *MockPrometheusGaugeVecMockRecorder 16 | } 17 | 18 | // MockPrometheusGaugeVecMockRecorder is the mock recorder for MockPrometheusGaugeVec 19 | type MockPrometheusGaugeVecMockRecorder struct { 20 | mock *MockPrometheusGaugeVec 21 | } 22 | 23 | // NewMockPrometheusGaugeVec creates a new mock instance 24 | func NewMockPrometheusGaugeVec(ctrl *gomock.Controller) *MockPrometheusGaugeVec { 25 | mock := &MockPrometheusGaugeVec{ctrl: ctrl} 26 | mock.recorder = &MockPrometheusGaugeVecMockRecorder{mock} 27 | return mock 28 | } 29 | 30 | // EXPECT returns an object that allows the caller to indicate expected use 31 | func (_m *MockPrometheusGaugeVec) EXPECT() *MockPrometheusGaugeVecMockRecorder { 32 | return _m.recorder 33 | } 34 | 35 | // Collect mocks base method 36 | func (_m *MockPrometheusGaugeVec) Collect(_param0 chan<- prometheus.Metric) { 37 | _m.ctrl.Call(_m, "Collect", _param0) 38 | } 39 | 40 | // Collect indicates an expected call of Collect 41 | func (_mr *MockPrometheusGaugeVecMockRecorder) Collect(arg0 interface{}) *gomock.Call { 42 | return _mr.mock.ctrl.RecordCall(_mr.mock, "Collect", arg0) 43 | } 44 | 45 | // Delete mocks base method 46 | func (_m *MockPrometheusGaugeVec) Delete(_param0 prometheus.Labels) bool { 47 | ret := _m.ctrl.Call(_m, "Delete", _param0) 48 | ret0, _ := ret[0].(bool) 49 | return ret0 50 | } 51 | 52 | // Delete indicates an expected call of Delete 53 | func (_mr *MockPrometheusGaugeVecMockRecorder) Delete(arg0 interface{}) *gomock.Call { 54 | return _mr.mock.ctrl.RecordCall(_mr.mock, "Delete", arg0) 55 | } 56 | 57 | // Describe mocks base method 58 | func (_m *MockPrometheusGaugeVec) Describe(_param0 chan<- *prometheus.Desc) { 59 | _m.ctrl.Call(_m, "Describe", _param0) 60 | } 61 | 62 | // Describe indicates an expected call of Describe 63 | func (_mr *MockPrometheusGaugeVecMockRecorder) Describe(arg0 interface{}) *gomock.Call { 64 | return _mr.mock.ctrl.RecordCall(_mr.mock, "Describe", arg0) 65 | } 66 | 67 | // GetMetricWith mocks base method 68 | func (_m *MockPrometheusGaugeVec) GetMetricWith(_param0 prometheus.Labels) (prometheus.Gauge, error) { 69 | ret := _m.ctrl.Call(_m, "GetMetricWith", _param0) 70 | ret0, _ := ret[0].(prometheus.Gauge) 71 | ret1, _ := ret[1].(error) 72 | return ret0, ret1 73 | } 74 | 75 | // GetMetricWith indicates an expected call of GetMetricWith 76 | func (_mr *MockPrometheusGaugeVecMockRecorder) GetMetricWith(arg0 interface{}) *gomock.Call { 77 | return _mr.mock.ctrl.RecordCall(_mr.mock, "GetMetricWith", arg0) 78 | } 79 | 80 | // MockPrometheusClient is a mock of PrometheusClient interface 81 | type MockPrometheusClient struct { 82 | ctrl *gomock.Controller 83 | recorder *MockPrometheusClientMockRecorder 84 | } 85 | 86 | // MockPrometheusClientMockRecorder is the mock recorder for MockPrometheusClient 87 | type MockPrometheusClientMockRecorder struct { 88 | mock *MockPrometheusClient 89 | } 90 | 91 | // NewMockPrometheusClient creates a new mock instance 92 | func NewMockPrometheusClient(ctrl *gomock.Controller) *MockPrometheusClient { 93 | mock := &MockPrometheusClient{ctrl: ctrl} 94 | mock.recorder = &MockPrometheusClientMockRecorder{mock} 95 | return mock 96 | } 97 | 98 | // EXPECT returns an object that allows the caller to indicate expected use 99 | func (_m *MockPrometheusClient) EXPECT() *MockPrometheusClientMockRecorder { 100 | return _m.recorder 101 | } 102 | 103 | // Do mocks base method 104 | func (_m *MockPrometheusClient) Do(_param0 *http.Request) (*http.Response, error) { 105 | ret := _m.ctrl.Call(_m, "Do", _param0) 106 | ret0, _ := ret[0].(*http.Response) 107 | ret1, _ := ret[1].(error) 108 | return ret0, ret1 109 | } 110 | 111 | // Do indicates an expected call of Do 112 | func (_mr *MockPrometheusClientMockRecorder) Do(arg0 interface{}) *gomock.Call { 113 | return _mr.mock.ctrl.RecordCall(_mr.mock, "Do", arg0) 114 | } 115 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /cardinality/cardinality.go: -------------------------------------------------------------------------------- 1 | package cardinality 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/prometheus/client_golang/prometheus" 12 | 13 | logging "github.com/sirupsen/logrus" 14 | ) 15 | 16 | var log = logging.WithFields(logging.Fields{}) 17 | 18 | // PrometheusClient interface for mock 19 | type PrometheusClient interface { 20 | Do(req *http.Request) (*http.Response, error) 21 | } 22 | 23 | // PrometheusGaugeVec interface for mock 24 | type PrometheusGaugeVec interface { 25 | GetMetricWith(labels prometheus.Labels) (prometheus.Gauge, error) 26 | Delete(labels prometheus.Labels) bool 27 | Collect(ch chan<- prometheus.Metric) 28 | Describe(ch chan<- *prometheus.Desc) 29 | } 30 | 31 | // PrometheusCardinalityMetric used to apply methods to a PrometheusGaugeVec (updateMetric) 32 | type PrometheusCardinalityMetric struct { 33 | GaugeVec PrometheusGaugeVec 34 | } 35 | 36 | // Struct for retaining a single label value pair 37 | type labelValuePair struct { 38 | Label string `json:"name"` 39 | Value uint64 `json:"value"` 40 | } 41 | 42 | // TSDBData contains the metric updates 43 | type TSDBData struct { 44 | SeriesCountByMetricName []labelValuePair `json:"seriesCountByMetricName"` 45 | LabelValueCountByLabelName []labelValuePair `json:"labelValueCountByLabelName"` 46 | MemoryInBytesByLabelName []labelValuePair `json:"memoryInBytesByLabelName"` 47 | SeriesCountByLabelValuePair []labelValuePair `json:"seriesCountByLabelValuePair"` 48 | } 49 | 50 | // TSDBStatus : a struct to hold data returned by the Prometheus API call 51 | type TSDBStatus struct { 52 | Status string `json:"status"` 53 | Data TSDBData `json:"data"` 54 | } 55 | 56 | // TrackedLabelNames : a struct to keep track of which metrics we are currently tracking 57 | type TrackedLabelNames struct { 58 | SeriesCountByMetricNameLabels []string 59 | LabelValueCountByLabelNameLabels []string 60 | MemoryInBytesByLabelNameLabels []string 61 | SeriesCountByLabelValuePairLabels []string 62 | } 63 | 64 | // PrometheusCardinalityInstance stores all that is required to know about prometheus instance 65 | // inc. it's name, it's address, the latest api call results, and the labels currently being tracked 66 | type PrometheusCardinalityInstance struct { 67 | Namespace string 68 | InstanceName string 69 | InstanceAddress string 70 | ShardedInstanceName string 71 | AuthValue string 72 | LatestTSDBStatus TSDBStatus 73 | TrackedLabels TrackedLabelNames 74 | } 75 | 76 | // FetchTSDBStatus saves tracked TSDB status metrics in the struct pointed to by the "data" parameter 77 | func (promInstance *PrometheusCardinalityInstance) FetchTSDBStatus(prometheusClient PrometheusClient, statsLimit int) error { 78 | 79 | // Create a GET request to the Prometheus API 80 | values := url.Values{} 81 | values.Add("limit", fmt.Sprintf("%d", statsLimit)) 82 | queryParams := values.Encode() 83 | 84 | apiURL := promInstance.InstanceAddress + "/api/v1/status/tsdb?" + queryParams 85 | 86 | request, err := http.NewRequest("GET", apiURL, nil) 87 | 88 | if promInstance.AuthValue != "" { 89 | request.Header.Add("Authorization", promInstance.AuthValue) 90 | } 91 | 92 | if err != nil { 93 | return fmt.Errorf("Cannot create GET request to %v: %v", apiURL, err) 94 | } 95 | 96 | // Perform GET request 97 | res, err := prometheusClient.Do(request) 98 | if err != nil { 99 | return fmt.Errorf("Can't connect to %v: %v ", apiURL, err) 100 | } 101 | defer res.Body.Close() 102 | 103 | // Check the response and either log it, if 2xx, or return an error 104 | responseStatusLog := fmt.Sprintf("Request to %s returned status %s.", apiURL, res.Status) 105 | statusOK := res.StatusCode >= 200 && res.StatusCode < 300 106 | if !statusOK { 107 | return errors.New(responseStatusLog) 108 | } 109 | log.Debug(responseStatusLog) 110 | 111 | // Read the body of the response 112 | body, err := io.ReadAll(res.Body) 113 | if err != nil { 114 | return fmt.Errorf("Can't read from socket: %v", err) 115 | } 116 | 117 | // Parse the JSON response body into a struct 118 | err = json.Unmarshal(body, &promInstance.LatestTSDBStatus) 119 | if err != nil { 120 | return fmt.Errorf("Can't parse json: %v", err) 121 | } 122 | return nil 123 | } 124 | 125 | // ExposeTSDBStatus expose TSDB status to /metrics 126 | func (promInstance *PrometheusCardinalityInstance) ExposeTSDBStatus(seriesCountByMetricNameGauge, labelValueCountByLabelNameGauge, memoryInBytesByLabelNameGauge, seriesCountByLabelValuePairGauge *PrometheusCardinalityMetric) (err error) { 127 | 128 | promInstance.TrackedLabels.SeriesCountByMetricNameLabels, err = seriesCountByMetricNameGauge.updateMetric(promInstance.LatestTSDBStatus.Data.SeriesCountByMetricName, promInstance.TrackedLabels.SeriesCountByMetricNameLabels, promInstance.InstanceName, promInstance.ShardedInstanceName, promInstance.Namespace, "metric") 129 | if err != nil { 130 | return err 131 | } 132 | promInstance.TrackedLabels.LabelValueCountByLabelNameLabels, err = labelValueCountByLabelNameGauge.updateMetric(promInstance.LatestTSDBStatus.Data.LabelValueCountByLabelName, promInstance.TrackedLabels.LabelValueCountByLabelNameLabels, promInstance.InstanceName, promInstance.ShardedInstanceName, promInstance.Namespace, "label") 133 | if err != nil { 134 | return err 135 | } 136 | promInstance.TrackedLabels.MemoryInBytesByLabelNameLabels, err = memoryInBytesByLabelNameGauge.updateMetric(promInstance.LatestTSDBStatus.Data.MemoryInBytesByLabelName, promInstance.TrackedLabels.MemoryInBytesByLabelNameLabels, promInstance.InstanceName, promInstance.ShardedInstanceName, promInstance.Namespace, "label") 137 | if err != nil { 138 | return err 139 | } 140 | promInstance.TrackedLabels.SeriesCountByLabelValuePairLabels, err = seriesCountByLabelValuePairGauge.updateMetric(promInstance.LatestTSDBStatus.Data.SeriesCountByLabelValuePair, promInstance.TrackedLabels.SeriesCountByLabelValuePairLabels, promInstance.InstanceName, promInstance.ShardedInstanceName, promInstance.Namespace, "label_pair") 141 | if err != nil { 142 | return err 143 | } 144 | 145 | return nil 146 | 147 | } 148 | 149 | // Updates the given metric with new values and deletes ones which are no longer being reported 150 | func (Metric *PrometheusCardinalityMetric) updateMetric(newLabelsValues []labelValuePair, trackedLabels []string, prometheusInstance string, shardedInstance string, namespace string, nameOfLabel string) (newTrackedLabels []string, err error) { 151 | newTrackedLabels = make([]string, len(newLabelsValues)) 152 | 153 | for idx, labelValuePair := range newLabelsValues { 154 | if labelValuePair.Label == "" { 155 | break 156 | } 157 | metricGauge, err := Metric.GaugeVec.GetMetricWith(prometheus.Labels{nameOfLabel: labelValuePair.Label, "scraped_instance": prometheusInstance, "sharded_instance": shardedInstance, "instance_namespace": namespace}) 158 | if err != nil { 159 | return trackedLabels, fmt.Errorf("Error updating metric with label name %v: %v", labelValuePair.Label, err) 160 | } 161 | metricGauge.Set(float64(labelValuePair.Value)) 162 | newTrackedLabels[idx] = labelValuePair.Label 163 | } 164 | 165 | for _, oldLabel := range trackedLabels { 166 | found := false 167 | for _, newLabelVP := range newLabelsValues { 168 | if oldLabel == newLabelVP.Label { 169 | found = true 170 | break 171 | } 172 | } 173 | if !found && oldLabel != "" { 174 | Metric.GaugeVec.Delete(prometheus.Labels{nameOfLabel: oldLabel, "scraped_instance": prometheusInstance, "sharded_instance": shardedInstance, "instance_namespace": namespace}) 175 | } 176 | } 177 | 178 | return newTrackedLabels, nil 179 | 180 | } 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prometheus Cardinality Exporter 2 | 3 | ![Go Version](https://img.shields.io/github/go-mod/go-version/thought-machine/prometheus-cardinality-exporter) 4 | ![License](https://img.shields.io/github/license/thought-machine/prometheus-cardinality-exporter) 5 | ![Docker Pulls](https://img.shields.io/docker/pulls/thoughtmachine/prometheus-cardinality-exporter) 6 | 7 | A simple Prometheus exporter for exposing the cardinality of metrics Prometheus has scraped. It queries the target Prometheus API at `/api/v1/status/tsdb` to provide granular insights into label usage, series counts, and memory consumption. 8 | 9 | This tool is critical for identifying **high-cardinality metrics** that may be causing performance degradation or OOM (Out of Memory) kills in your monitoring infrastructure. 10 | 11 | This was originally started as an intern project by [Harry Fallows](https://github.com/harryfallows) during his internship at [Thought Machine](https://www.thoughtmachine.net/). 12 | 13 | ## Features 14 | 15 | * **Granular Cardinality Metrics**: Export label value counts, memory usage by label, and series counts by metric name. 16 | * **Kubernetes Service Discovery**: Automatically discover and scrape Prometheus pods in your cluster. 17 | * **Multi-Instance Support**: Monitor multiple Prometheus instances from a single exporter. 18 | * **Auth Compatible**: Specific support for Basic Auth and Bearer Token setups for secured Prometheus instances. 19 | 20 | --- 21 | 22 | ## 🚀 Quick Start 23 | 24 | ### Docker 25 | 26 | **Docker images** 27 | 28 | Distroless docker images are available at: 29 | `ghcr.io/thought-machine/prometheus-cardinality-exporter:$COMMIT` 30 | 31 | > **Note:** Images are **no longer** uploaded to Docker Hub. See [Docker Hub](https://hub.docker.com/r/thoughtmachine/prometheus-cardinality-exporter). 32 | 33 | 34 | Run the exporter locally, pointing it to a Prometheus instance running on `localhost:9090`. 35 | 36 | ```bash 37 | docker run -p 9090:9090 thoughtmachine/prometheus-cardinality-exporter \ 38 | --proms=[http://host.docker.internal:9090](http://host.docker.internal:9090) \ 39 | --port=9090 \ 40 | --freq=1 41 | ``` 42 | 43 | Access metrics at: `http://localhost:9090/metrics` 44 | 45 | 46 | **Binary** 47 | ```bash 48 | # Clone and run 49 | git clone [https://github.com/thought-machine/prometheus-cardinality-exporter.git](https://github.com/thought-machine/prometheus-cardinality-exporter.git) 50 | cd prometheus-cardinality-exporter 51 | go run . --proms=http://localhost:9090 52 | ``` 53 | 54 | ## 📊 Exposed Metrics 55 | 56 | The exporter exposes the following metrics: 57 | 58 | | Metric Name | Description | 59 | | :--- | :--- | 60 | | `cardinality_exporter_label_value_count_by_label_name` | Count of unique values for a specific label name. Useful for finding labels with too many values (e.g., `user_id` or `pod_name`). | 61 | | `cardinality_exporter_memory_in_bytes_by_label_name` | Memory used by a specific label name (sum of the length of all values). | 62 | | `cardinality_exporter_series_count_by_label_pair` | Number of series associated with a specific label key-value pair. | 63 | | `cardinality_exporter_series_count_by_metric_name` | Number of series per metric name. Useful for identifying the "heaviest" metrics in your TSDB. | 64 | 65 | 66 | ## ⚙️ Configuration 67 | 68 | The exporter is configured via command-line flags. 69 | `(go run . [OPTIONS])` 70 | 71 | | Short | Flag | Description | Default | 72 | | :--- | :--- | :--- | :--- | 73 | | `-s` | `--selector` | Label selector for K8s service discovery (e.g., `app=prometheus`). | | 74 | | `-n` | `--namespaces` | Comma-separated K8s namespaces to discover services in. | | 75 | | `-i` | `--proms` | Manual list of Prometheus URLs to scrape. | | 76 | | `-d` | `--service_discovery` | Enable Kubernetes service discovery (replaces `--proms`). | `false` | 77 | | `-p` | `--port` | Port to expose the exporter metrics on. | `9090` | 78 | | `-f` | `--freq` | Frequency (in hours) to query the target Prometheus TSDB API. | | 79 | | `-r` | `--regex` | Regex to filter discovered service names. | | 80 | | `-a` | `--auth` | Path to YAML file containing auth credentials. | | 81 | | `-L` | `--stats-limit` | Limit the number of items fetched from TSDB stats. | `10` | 82 | | `-l` | `--log.level` | Log level (`debug`, `info`, `warn`, `error`, `fatal`). | `info` | 83 | 84 | 85 | ## 🔐 Authentication Guide 86 | 87 | If your target Prometheus instances require authentication (e.g., Basic Auth or Bearer Token) to access the `/api/v1/status/tsdb` endpoint, you must provide a credential configuration file using the `--auth` flag. 88 | 89 | The structure of this file depends on how you are discovering your Prometheus targets. 90 | 91 | #### 1. Using Manual List (`--proms`) 92 | When manually specifying Prometheus URLs, map the full URL to the Authorization header value. 93 | ```yaml 94 | "": "" 95 | ``` 96 | 97 | #### 2. Using Service Discovery (`--service_discovery`) 98 | 99 | When using Kubernetes service discovery, you map credentials using specific **identifiers**. The exporter checks for credentials in the following order of precedence: 100 | 101 | 1. **Sharded Instance Level** (Most specific) 102 | 2. **Prometheus Instance Level** 103 | 3. **Namespace Level** (Least specific) 104 | 4. **No Auth** (If no match found) 105 | 106 | Naming Convention: `[_[_]]` 107 | 108 | Example Configuration: 109 | 110 | ```yaml 111 | # 1. Namespace Level 112 | # Apply to ALL Prometheus instances found in "monitoring-ns" 113 | "monitoring-ns": "Bearer eyJhbGciOiJ..." 114 | 115 | # 2. Instance Level 116 | # Apply specifically to the "main-prom" instance in "default" namespace 117 | "default_main-prom": "Basic YWRtaW46cGFzc3dvcmQ=" 118 | 119 | # 3. Sharded Instance Level 120 | # Apply to a specific shard of a Prometheus instance 121 | "default_main-prom_shard-0": "Basic 987654321" 122 | ``` 123 | 124 | > ⚠️ **Note:** You must provide the full value for the Authorization header (e.g., including `Basic` or `Bearer` prefix). 125 | > * Correct: "Basic YWRtaW46..." or "Bearer eyJ..." 126 | > * Incorrect: "YWRtaW46..." 127 | 128 | ## ☸️ Kubernetes Deployment 129 | To deploy in Kubernetes with **Service Discovery ** enabled, the exporter needs RBAC permissions to list Services and Pods. 130 | 131 | **1. RBAC Permissions** 132 | 133 | Create a `ServiceAccount`, `ClusterRole`, and `ClusterRoleBinding`. 134 | 135 | ```yaml 136 | apiVersion: v1 137 | kind: ServiceAccount 138 | metadata: 139 | name: cardinality-exporter 140 | namespace: monitoring 141 | --- 142 | apiVersion: rbac.authorization.k8s.io/v1 143 | kind: ClusterRole 144 | metadata: 145 | name: cardinality-exporter-role 146 | rules: 147 | - apiGroups: [""] 148 | resources: ["services", "pods", "endpoints"] 149 | verbs: ["get", "list", "watch"] 150 | --- 151 | apiVersion: rbac.authorization.k8s.io/v1 152 | kind: ClusterRoleBinding 153 | metadata: 154 | name: cardinality-exporter-binding 155 | subjects: 156 | - kind: ServiceAccount 157 | name: cardinality-exporter 158 | namespace: monitoring 159 | roleRef: 160 | kind: ClusterRole 161 | name: cardinality-exporter-role 162 | apiGroup: rbac.authorization.k8s.io 163 | ``` 164 | 165 | **2. Deployment Manifest** 166 | 167 | Deploy the exporter using the service account created above. Ensure you set the `--namespaces` and `--selector flags` to match your Prometheus installation. 168 | 169 | ```yaml 170 | apiVersion: apps/v1 171 | kind: Deployment 172 | metadata: 173 | name: prometheus-cardinality-exporter 174 | namespace: monitoring 175 | spec: 176 | replicas: 1 177 | selector: 178 | matchLabels: 179 | app: cardinality-exporter 180 | template: 181 | metadata: 182 | labels: 183 | app: cardinality-exporter 184 | spec: 185 | serviceAccountName: cardinality-exporter 186 | containers: 187 | - name: exporter 188 | image: thoughtmachine/prometheus-cardinality-exporter:latest 189 | args: 190 | - "--service_discovery" 191 | - "--namespaces=monitoring" 192 | - "--selector=app.kubernetes.io/name=prometheus" 193 | - "--freq=1" 194 | ports: 195 | - containerPort: 9090 196 | ``` 197 | 198 | ## 🔨 Building 199 | 200 | Build Binary 201 | ```bash 202 | go build ./... 203 | ``` 204 | 205 | Build Docker Image 206 | ```bash 207 | docker build -f Dockerfile-builder . -t prometheus-cardinality-exporter 208 | ``` 209 | 210 | ## 🧪 Testing 211 | ```bash 212 | go test ./... 213 | ``` 214 | 215 | ## 🚨 Linting 216 | ```bash 217 | go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.0 run 218 | ``` 219 | 220 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github/thought-machine/prometheus-cardinality-exporter/cardinality" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | "time" 13 | 14 | "github.com/prometheus/client_golang/prometheus/promhttp" 15 | "gopkg.in/yaml.v3" 16 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/client-go/kubernetes" 18 | "k8s.io/client-go/rest" 19 | 20 | "github.com/cenkalti/backoff" 21 | "github.com/jessevdk/go-flags" 22 | logging "github.com/sirupsen/logrus" 23 | ) 24 | 25 | var log = logging.WithFields(logging.Fields{}) 26 | 27 | var opts struct { 28 | Selector string `long:"selector" short:"s" default:"app=prometheus" help:"Selector for Service Discovery."` 29 | Namespaces []string `long:"namespaces" short:"n" help:"Namespaces for Service Discovery."` 30 | PrometheusInstances []string `long:"proms" short:"i" help:"Prometheus instance links. Mutually exclusive to the service discover flag."` 31 | PromAPIAuthValuesFile string `long:"auth" short:"a" help:"Location of YAML file where Prometheus instance authorisation credentials can be found. For instances that don't appear in the file, it is assumed that no authorisation is required to access them."` 32 | ServiceDiscovery bool `long:"service_discovery" short:"d" help:"Service discovery flag, use service discovery to find new instances of Prometheus within a cluster. Mutually exclusive to the prometheus instance link flag."` 33 | Port int `long:"port" short:"p" default:"9090" help:"Port on which to serve."` 34 | Frequency float32 `long:"freq" short:"f" default:"6" help:"Frequency in hours with which to query the Prometheus API."` 35 | ServiceRegex string `long:"regex" short:"r" default:"prometheus-[a-zA-Z0-9_-]+" help:"If any found services don't match the regex, they are ignored."` 36 | LogLevel string `long:"log.level" short:"l" default:"info" help:"Level for logging. Options (in order of verbosity): [debug, info, warn, error, fatal]."` 37 | StatsLimit int `long:"stats-limit" short:"L" default:"10" help:"Limit the number of items fetched from the TSDB statistics."` 38 | } 39 | 40 | func collectMetrics() { 41 | 42 | // Number of times to retry before fetching the data before giving up. 43 | // If the number of retries is exhausted, it will wait until the next time it has to query the Prometheus API. 44 | var numRetries uint64 = 3 45 | sleepTime, err := time.ParseDuration(fmt.Sprintf("%0.4fh", opts.Frequency)) 46 | if err != nil { 47 | log.Errorf("Cannot parse frequency variable %v: %v", opts.Frequency, err) 48 | } 49 | 50 | // Map of prometheus instance identifiers to their authorisation credentials, used for accessing the TSDB API 51 | var promAPIAuthValues map[string]string 52 | 53 | // This is a data structure that allows for the storage of the names prometheus instances and their sharded instances 54 | // Sharded instances are specified because a service may have several endpoints 55 | // Ignoring this would result in kubernetes selecting only one endpoint per API call, which could lead to inconsistent metric reporting 56 | // Each sharded instance also stores it's address (which can change), the latest cardinality info, and the current tracked labels 57 | cardinalityInfoByInstance := make(map[string]*cardinality.PrometheusCardinalityInstance) 58 | 59 | if opts.PromAPIAuthValuesFile != "" { 60 | filename, err := filepath.Abs(opts.PromAPIAuthValuesFile) 61 | if err != nil { 62 | log.Errorf("Failed to obtain the filepath of the Prometheus API authorisation values file provided: %v.", err.Error()) 63 | } else { 64 | fileContents, err := os.ReadFile(filename) 65 | if err != nil { 66 | log.Errorf("Failed to read Prometheus API authorisation values file provided: %v.", err.Error()) 67 | } else { 68 | err = yaml.Unmarshal(fileContents, &promAPIAuthValues) 69 | if err != nil { 70 | log.Errorf("Failed to read Prometheus API authorisation values file into the appropriate data structure: %v. Check the format of your file!", err.Error()) 71 | } 72 | } 73 | } 74 | if len(promAPIAuthValues) == 0 { 75 | log.Errorf("Skipping the authorisation component to continue collecting metrics from Prometheus instances that don't require authorisation. This will result in no metrics from secured Prometheus instances.") 76 | } 77 | } 78 | 79 | if !opts.ServiceDiscovery { // Prometheus instances defined by arguments 80 | 81 | // precompile required regex 82 | regexC, err := regexp.Compile(`https?:\/\/[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+[a-zA-Z0-9_.-]*\/?`) 83 | if err != nil { 84 | log.Fatalf("invalid regex: %+v", err) 85 | } 86 | 87 | // In this case the name of the sharded instance is the same as the name of the prometheus instance 88 | // This is because is not possible to distinguish between them based on addresses given as arguments 89 | for _, prometheusInstanceAddress := range opts.PrometheusInstances { 90 | 91 | // Check the address matches a familiar pattern: http(s)://.(/) 92 | matched := regexC.MatchString(prometheusInstanceAddress) 93 | if !matched { 94 | log.Fatalf("%v is not a valid prometheus instance address.", prometheusInstanceAddress) 95 | } 96 | 97 | // Get the name of the prometheus instance from the link 98 | splitByDots := strings.Split(prometheusInstanceAddress, ".") 99 | splitInstanceName := strings.Split(splitByDots[0], "/") 100 | instanceName := splitInstanceName[len(splitInstanceName)-1] 101 | namespace := splitByDots[1] 102 | 103 | instanceID := namespace + "_" + instanceName 104 | 105 | // Add the prometheus instance to the data structure 106 | cardinalityInfoByInstance[instanceID] = &cardinality.PrometheusCardinalityInstance{ 107 | Namespace: namespace, 108 | InstanceName: instanceName, 109 | ShardedInstanceName: instanceName, 110 | InstanceAddress: prometheusInstanceAddress, 111 | AuthValue: promAPIAuthValues[prometheusInstanceAddress], 112 | TrackedLabels: cardinality.TrackedLabelNames{ 113 | SeriesCountByMetricNameLabels: make([]string, 0, opts.StatsLimit), 114 | LabelValueCountByLabelNameLabels: make([]string, 0, opts.StatsLimit), 115 | MemoryInBytesByLabelNameLabels: make([]string, 0, opts.StatsLimit), 116 | SeriesCountByLabelValuePairLabels: make([]string, 0, opts.StatsLimit), 117 | }, 118 | } 119 | } 120 | } 121 | 122 | for { 123 | if opts.ServiceDiscovery { 124 | 125 | // Obtains the cluster config of the cluster we are currently in 126 | config, err := rest.InClusterConfig() 127 | if err != nil { 128 | log.Fatalf("Error obtaining the current cluster config: %v", err.Error()) 129 | } 130 | 131 | // Creates the clientset 132 | clientset, err := kubernetes.NewForConfig(config) 133 | if err != nil { 134 | log.Fatalf("Error creating the clientset from the cluster config: %v", err.Error()) 135 | } 136 | 137 | // If namespaces are specified as arguments use them, if not use service discovery 138 | var namespaceList []string 139 | if len(opts.Namespaces) == 0 { 140 | // Accesses the API to list all namespaces in the cluster 141 | namespaces, _ := clientset.CoreV1().Namespaces().List(context.TODO(), v1.ListOptions{}) 142 | for _, namespaceObj := range namespaces.Items { 143 | namespaceList = append(namespaceList, namespaceObj.ObjectMeta.GetName()) 144 | } 145 | } else { 146 | namespaceList = opts.Namespaces 147 | } 148 | 149 | for _, namespace := range namespaceList { 150 | 151 | // Accesses the API to list all endpoints and services which match the label selector in the given namespace 152 | endpointsList, _ := clientset.CoreV1().Endpoints(namespace).List(context.TODO(), v1.ListOptions{LabelSelector: opts.Selector}) 153 | 154 | if err != nil { 155 | log.Fatalf("Error obtaining endpoints matching selector (%v) in namespace (%v): %v", namespace, opts.Selector, err.Error()) 156 | } 157 | 158 | // Iterate over all of the endpoints and add them to the data structure 159 | for _, endpoints := range endpointsList.Items { // This loop represents a service 160 | 161 | prometheusInstanceName := endpoints.ObjectMeta.GetName() 162 | //If the instance name doesn't start with the chosen prefix, it is ignored 163 | if matched, _ := regexp.MatchString(opts.ServiceRegex, prometheusInstanceName); !matched { 164 | continue 165 | } 166 | 167 | for _, endpointSubset := range endpoints.Subsets { // This loop represents groups of endpoints within a service 168 | 169 | for _, address := range endpointSubset.Addresses { // This loop represents each individual endpoint 170 | shardedInstanceName := address.TargetRef.Name // Name of sharded instance e.g. prometheus-kubernetes-0 171 | instanceID := namespace + "_" + prometheusInstanceName + "_" + shardedInstanceName 172 | 173 | if _, ok := cardinalityInfoByInstance[instanceID]; !ok { 174 | // Add a newly found endpoint to the data structure 175 | cardinalityInfoByInstance[instanceID] = &cardinality.PrometheusCardinalityInstance{ 176 | Namespace: namespace, 177 | InstanceName: prometheusInstanceName, 178 | ShardedInstanceName: shardedInstanceName, 179 | InstanceAddress: "http://" + address.IP + ":9090", 180 | TrackedLabels: cardinality.TrackedLabelNames{ 181 | SeriesCountByMetricNameLabels: make([]string, 0, opts.StatsLimit), 182 | LabelValueCountByLabelNameLabels: make([]string, 0, opts.StatsLimit), 183 | MemoryInBytesByLabelNameLabels: make([]string, 0, opts.StatsLimit), 184 | SeriesCountByLabelValuePairLabels: make([]string, 0, opts.StatsLimit), 185 | }, 186 | } 187 | } else { 188 | // If the endpoint is already known, update it's address 189 | cardinalityInfoByInstance[instanceID].InstanceAddress = "http://" + address.IP + ":9090" 190 | } 191 | 192 | if authValue, ok := promAPIAuthValues[instanceID]; ok { // Check for Prometheus API credentials for sharded instance 193 | cardinalityInfoByInstance[instanceID].AuthValue = authValue 194 | } else if authValue, ok := promAPIAuthValues[namespace+"_"+prometheusInstanceName]; ok { // Check for Prometheus API credentials for instance 195 | cardinalityInfoByInstance[instanceID].AuthValue = authValue 196 | } else if authValue, ok := promAPIAuthValues[namespace]; ok { // Check for Prometheus API credentials for namespace 197 | cardinalityInfoByInstance[instanceID].AuthValue = authValue 198 | } 199 | } 200 | } 201 | } 202 | } 203 | } 204 | 205 | // Iterates over all prometheus instances and runs cardinality exporter logic 206 | for instanceID, instance := range cardinalityInfoByInstance { 207 | 208 | prometheusClient := &http.Client{} 209 | 210 | log.Infof("Fetching current Prometheus status, from Prometheus instance: %v. Sharded instance: %v. Namespace: %v.", instance.InstanceName, instance.ShardedInstanceName, instance.Namespace) 211 | 212 | if instance.AuthValue != "" { 213 | log.Info("Including Authorization header.") 214 | } 215 | 216 | // Fetch the data from Prometheus 217 | err := backoff.Retry(func() error { 218 | return cardinalityInfoByInstance[instanceID].FetchTSDBStatus(prometheusClient, opts.StatsLimit) 219 | }, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), numRetries)) 220 | if err != nil { 221 | log.WithError(err).Warningf("Error fetching Prometheus status: %v", err) 222 | delete(cardinalityInfoByInstance, instanceID) 223 | continue 224 | } 225 | 226 | // Expose data on /metrics 227 | err = backoff.Retry(func() error { 228 | return cardinalityInfoByInstance[instanceID].ExposeTSDBStatus(&cardinality.SeriesCountByMetricNameGauge, &cardinality.LabelValueCountByLabelNameGauge, &cardinality.MemoryInBytesByLabelNameGauge, &cardinality.SeriesCountByLabelValuePairGauge) 229 | }, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), numRetries)) 230 | if err != nil { 231 | log.WithError(err).Warningf("Error exposing Prometheus metrics: %v", err) 232 | } 233 | } 234 | 235 | // Sleep until next metric update 236 | log.Debugf("Sleeping for %0.4f hours.", opts.Frequency) 237 | time.Sleep(sleepTime) 238 | } 239 | } 240 | 241 | func main() { 242 | _, err := flags.Parse(&opts) 243 | 244 | // Exit gracefully if help flag used 245 | if err != nil && flags.WroteHelp(err) { 246 | os.Exit(0) 247 | } else if err != nil { 248 | log.Fatalf("%+v", err) 249 | } 250 | 251 | if len(opts.PrometheusInstances) > 0 && opts.ServiceDiscovery { 252 | log.Fatal("Cannot parse Prometheus Instances (--proms) AND use Service Discovery (--service_discovery), these options are mutually exclusive.") 253 | } else if len(opts.PrometheusInstances) > 0 { 254 | log.Info("Obtaining metics from prometheus instances specified as arguments.") 255 | } else if opts.ServiceDiscovery { 256 | log.Info("Obtaining metrics from services found with service discovery.") 257 | } else { 258 | log.Fatal("Service Discovery has not been selected (--service_discovery) and no Prometheus Instances (--proms) have been passed, therefore there are no Prometheus Instances to connect to.") 259 | } 260 | 261 | logLevel, err := logging.ParseLevel(opts.LogLevel) 262 | if err != nil { 263 | log.Warnf("Invalid log level \"%s\", setting log level to \"info\".", opts.LogLevel) 264 | logLevel = logging.InfoLevel 265 | } 266 | logging.SetLevel(logLevel) 267 | 268 | log.Infof("Serving on port: %d", opts.Port) 269 | log.Infof("Serving Prometheus metrics on /metrics") 270 | http.Handle("/metrics", promhttp.Handler()) 271 | http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 272 | fmt.Fprintf(w, "OK") 273 | }) 274 | 275 | log.Infof("Starting Prometheus cardinality metric collection.") 276 | go collectMetrics() 277 | 278 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", opts.Port), nil)) 279 | } 280 | -------------------------------------------------------------------------------- /cardinality/cardinality_test.go: -------------------------------------------------------------------------------- 1 | package cardinality 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "github/thought-machine/prometheus-cardinality-exporter/cardinality/mock_cardinality" 10 | "io" 11 | "net" 12 | "net/http" 13 | "strings" 14 | "testing" 15 | "time" 16 | 17 | "github.com/golang/mock/gomock" 18 | "github.com/prometheus/client_golang/prometheus" 19 | "github.com/prometheus/client_golang/prometheus/promhttp" 20 | "github.com/stretchr/testify/assert" 21 | "github.com/stretchr/testify/suite" 22 | ) 23 | 24 | // CardinalitySuite used to mock objects 25 | type CardinalitySuite struct { 26 | suite.Suite 27 | MockController *gomock.Controller 28 | MockPrometheusClient *mock_cardinality.MockPrometheusClient 29 | MockSeriesCountByMetricNameGauge *mock_cardinality.MockPrometheusGaugeVec 30 | MockLabelValueCountByLabelNameGauge *mock_cardinality.MockPrometheusGaugeVec 31 | MockMemoryInBytesByLabelNameGauge *mock_cardinality.MockPrometheusGaugeVec 32 | MockSeriesCountByLabelValuePairGauge *mock_cardinality.MockPrometheusGaugeVec 33 | } 34 | 35 | func TestCardinalitySuite(t *testing.T) { 36 | suite.Run(t, new(CardinalitySuite)) 37 | } 38 | 39 | // Set up each of the required mocks 40 | func (ts *CardinalitySuite) SetupTest() { 41 | ts.MockController = gomock.NewController(ts.T()) 42 | ts.MockPrometheusClient = mock_cardinality.NewMockPrometheusClient(ts.MockController) 43 | ts.MockSeriesCountByMetricNameGauge = mock_cardinality.NewMockPrometheusGaugeVec(ts.MockController) 44 | ts.MockLabelValueCountByLabelNameGauge = mock_cardinality.NewMockPrometheusGaugeVec(ts.MockController) 45 | ts.MockMemoryInBytesByLabelNameGauge = mock_cardinality.NewMockPrometheusGaugeVec(ts.MockController) 46 | ts.MockSeriesCountByLabelValuePairGauge = mock_cardinality.NewMockPrometheusGaugeVec(ts.MockController) 47 | } 48 | 49 | func (ts *CardinalitySuite) TearDownTest() { 50 | defer ts.MockController.Finish() 51 | } 52 | 53 | type authHeaderMatcher struct { 54 | expectedAuthHeaderValue string 55 | } 56 | 57 | func (m authHeaderMatcher) Matches(y interface{}) bool { 58 | 59 | authHeaders := y.(*http.Request).Header["Authorization"] 60 | if m.expectedAuthHeaderValue != "" { 61 | if len(authHeaders) > 1 || authHeaders[0] != m.expectedAuthHeaderValue { 62 | return false 63 | } 64 | } 65 | return true 66 | } 67 | 68 | func (m authHeaderMatcher) String() string { 69 | if m.expectedAuthHeaderValue == "" { 70 | return "contains no authorization header value" 71 | } 72 | return fmt.Sprintf("contains authorization header value %s", m.expectedAuthHeaderValue) 73 | } 74 | 75 | func AuthHeaderCorrect(expectedAuthHeaderValue string) gomock.Matcher { 76 | return authHeaderMatcher{expectedAuthHeaderValue} 77 | } 78 | 79 | // This tests the FetchTSDBStatus function on all of the test cases 80 | func (ts *CardinalitySuite) TestFetchTSDBStatus() { 81 | 82 | for _, tt := range cardinalityTests { 83 | 84 | // Mock json response 85 | response := &http.Response{ 86 | Status: tt.responseStatus, 87 | StatusCode: tt.responseStatusCode, 88 | Body: io.NopCloser(bytes.NewBufferString(tt.json)), 89 | } 90 | ts.MockPrometheusClient.EXPECT().Do(AuthHeaderCorrect(tt.expectedAuthHeaderValue)).Return(response, nil) 91 | err := tt.prometheusInstance.FetchTSDBStatus(ts.MockPrometheusClient, 20) 92 | 93 | assert.Equal(ts.T(), tt.incomingTSDBStatus, tt.prometheusInstance.LatestTSDBStatus) 94 | assert.Equal(ts.T(), err, nil) 95 | 96 | // reset the LatestTSDBStatus, so that it doesn't affect later tests 97 | tt.prometheusInstance.LatestTSDBStatus = *new(TSDBStatus) 98 | 99 | } 100 | } 101 | 102 | // This function tests the ExposeTSDBStatus function on all test cases 103 | func (ts *CardinalitySuite) TestExposeTSDBStatus() { 104 | 105 | for _, tt := range cardinalityTests { 106 | 107 | // Iterate over each of the mock input metrics and check the prometheus.GetMetricWith() function is called with each of them 108 | // Also check that old metrics are deleted 109 | 110 | tt.prometheusInstance.LatestTSDBStatus = tt.incomingTSDBStatus 111 | 112 | SeriesCountByMetricNameGauge := &PrometheusCardinalityMetric{GaugeVec: ts.MockSeriesCountByMetricNameGauge} 113 | SeriesCountByMetricNameGauge.expectMetricUpdates(tt.prometheusInstance.TrackedLabels.SeriesCountByMetricNameLabels, tt.incomingTSDBStatus.Data.SeriesCountByMetricName, tt.prometheusInstance.InstanceName, tt.prometheusInstance.ShardedInstanceName, tt.prometheusInstance.Namespace, "metric") 114 | 115 | LabelValueCountByLabelNameGauge := &PrometheusCardinalityMetric{GaugeVec: ts.MockLabelValueCountByLabelNameGauge} 116 | LabelValueCountByLabelNameGauge.expectMetricUpdates(tt.prometheusInstance.TrackedLabels.LabelValueCountByLabelNameLabels, tt.incomingTSDBStatus.Data.LabelValueCountByLabelName, tt.prometheusInstance.InstanceName, tt.prometheusInstance.ShardedInstanceName, tt.prometheusInstance.Namespace, "label") 117 | 118 | MemoryInBytesByLabelNameGauge := &PrometheusCardinalityMetric{GaugeVec: ts.MockMemoryInBytesByLabelNameGauge} 119 | MemoryInBytesByLabelNameGauge.expectMetricUpdates(tt.prometheusInstance.TrackedLabels.MemoryInBytesByLabelNameLabels, tt.incomingTSDBStatus.Data.MemoryInBytesByLabelName, tt.prometheusInstance.InstanceName, tt.prometheusInstance.ShardedInstanceName, tt.prometheusInstance.Namespace, "label") 120 | 121 | SeriesCountByLabelValuePairGauge := &PrometheusCardinalityMetric{GaugeVec: ts.MockSeriesCountByLabelValuePairGauge} 122 | SeriesCountByLabelValuePairGauge.expectMetricUpdates(tt.prometheusInstance.TrackedLabels.SeriesCountByLabelValuePairLabels, tt.incomingTSDBStatus.Data.SeriesCountByLabelValuePair, tt.prometheusInstance.InstanceName, tt.prometheusInstance.ShardedInstanceName, tt.prometheusInstance.Namespace, "label_pair") 123 | 124 | //Call the ExposeTSDBStatus function to check that the correct functions are called 125 | err := tt.prometheusInstance.ExposeTSDBStatus(SeriesCountByMetricNameGauge, LabelValueCountByLabelNameGauge, MemoryInBytesByLabelNameGauge, SeriesCountByLabelValuePairGauge) 126 | assert.Equal(ts.T(), err, nil) 127 | 128 | // reset the LatestTSDBStatus, so that it doesn't affect later tests 129 | tt.prometheusInstance.LatestTSDBStatus = *new(TSDBStatus) 130 | } 131 | } 132 | 133 | // This function was introduced to reduce clutter, it is used to set the EXPECTed calls to each GaugeVec 134 | func (mockMetric *PrometheusCardinalityMetric) expectMetricUpdates(trackedLabels []string, incomingMetrics []labelValuePair, prometheusInstance string, shardedInstance string, namespace string, nameOfLabel string) { 135 | 136 | // Iterate over each metric and apply checks to see whether GetMetricWith is called 137 | for _, metric := range incomingMetrics { 138 | gauge := prometheus.NewGauge(prometheus.GaugeOpts{}) 139 | if metric.Label != "" { 140 | (mockMetric.GaugeVec).(*mock_cardinality.MockPrometheusGaugeVec).EXPECT().GetMetricWith(prometheus.Labels{nameOfLabel: metric.Label, "scraped_instance": prometheusInstance, "sharded_instance": shardedInstance, "instance_namespace": namespace}).Return(gauge, nil) 141 | } 142 | } 143 | 144 | // Iterate over each of the trackedLabels to check if they are no longer tracked, if so expect a call to Delete 145 | for _, oldMetric := range trackedLabels { 146 | found := false 147 | for _, newMetric := range incomingMetrics { 148 | if oldMetric == newMetric.Label { 149 | found = true 150 | break 151 | } 152 | } 153 | if !found && oldMetric != "" { 154 | (mockMetric.GaugeVec).(*mock_cardinality.MockPrometheusGaugeVec).EXPECT().Delete(prometheus.Labels{nameOfLabel: oldMetric, "scraped_instance": prometheusInstance, "sharded_instance": shardedInstance, "instance_namespace": namespace}).Return(true) 155 | } 156 | } 157 | } 158 | 159 | // E2E test 160 | // 1. Creates a /metrics endpoint 161 | // 2. Creates another endpoints to act as the Prometheus API 162 | // 3. Calls the FetchTSDBStatus function to call the mock API 163 | // 4. Calls the ExposeTSDBStatus function to expose the fetched metrics on the /metrics endpoint 164 | // 5. Scrapes the /metrics endpoint and checks that the result is as expected 165 | func (ts *CardinalitySuite) TestE2E() { 166 | 167 | for _, tt := range cardinalityTests { 168 | 169 | // Create /metrics endpoint on next available port 170 | mux := http.NewServeMux() 171 | mux.Handle("/metrics", promhttp.Handler()) 172 | metricsServer := &http.Server{Handler: mux} 173 | listener, err := net.Listen("tcp", ":0") 174 | if err != nil { 175 | panic(err) 176 | } 177 | metricsServerPort := listener.Addr().(*net.TCPAddr).Port 178 | log.Infof("Serving /metrics endpoint for E2E test on port: %d.", metricsServerPort) 179 | go metricsServer.Serve(listener) 180 | 181 | // Set up test API on next available port 182 | JSONResponse := json.RawMessage(tt.json) 183 | mockAPI := func(w http.ResponseWriter, r *http.Request) { 184 | if tt.expectedAuthHeaderValue != "" { 185 | if len(r.Header["Authorization"]) > 1 || r.Header["Authorization"][0] != tt.expectedAuthHeaderValue { 186 | w.WriteHeader(http.StatusUnauthorized) 187 | w.Write([]byte("401 Unauthorized")) 188 | return 189 | } 190 | } 191 | w.Header().Set("Content-Type", "application/json") 192 | w.Write(JSONResponse) 193 | } 194 | mux.HandleFunc("/api/v1/status/tsdb", mockAPI) 195 | mockAPIServer := &http.Server{Handler: mux} 196 | listener, err = net.Listen("tcp", ":0") 197 | if err != nil { 198 | log.Error(err) 199 | } 200 | mockAPIPort := listener.Addr().(*net.TCPAddr).Port 201 | log.Infof("Serving test API for E2E test on port: %d.", mockAPIPort) 202 | go mockAPIServer.Serve(listener) 203 | time.Sleep(1 * time.Millisecond) // This is here to give the Serve time to set up 204 | 205 | // Fetch metrics from test API 206 | tt.prometheusInstance.LatestTSDBStatus = *new(TSDBStatus) 207 | tt.prometheusInstance.InstanceAddress = fmt.Sprintf("http://localhost:%v", mockAPIPort) 208 | err = tt.prometheusInstance.FetchTSDBStatus(&http.Client{}, 20) 209 | if err != nil { 210 | log.WithError(err).Warningf("Error fetching Prometheus status: %v", err) 211 | } 212 | 213 | // Expose test metrics on /metrics 214 | tt.prometheusInstance.ExposeTSDBStatus(&SeriesCountByMetricNameGauge, &LabelValueCountByLabelNameGauge, &MemoryInBytesByLabelNameGauge, &SeriesCountByLabelValuePairGauge) 215 | 216 | // Perform GET request on /metrics 217 | apiURL := fmt.Sprintf("http://localhost:%v/metrics", metricsServerPort) 218 | request, err := http.Get(apiURL) 219 | if err != nil { 220 | panic(fmt.Sprintf("Can't connect to %v: %v ", apiURL, err)) 221 | } 222 | defer request.Body.Close() 223 | 224 | // Read the body of the response from /metrics GET request 225 | body, err := io.ReadAll(request.Body) 226 | if err != nil { 227 | panic(fmt.Sprintf("Can't read from socket: %v", err)) 228 | } 229 | 230 | // Check that all expected metrics are found 231 | bodyString := string(body) 232 | scanner := bufio.NewScanner(strings.NewReader(bodyString)) 233 | for scanner.Scan() { 234 | if tt.expectedMetrics[scanner.Text()] { 235 | delete(tt.expectedMetrics, scanner.Text()) 236 | } 237 | } 238 | 239 | // Assert that there are no expected metrics unaccounted for 240 | assert.Equal(ts.T(), 0, len(tt.expectedMetrics)) 241 | metricsServer.Shutdown(context.Background()) // Shutdown the server at the end of the function 242 | mockAPIServer.Shutdown(context.Background()) // Shutdown the server at the end of the function 243 | } 244 | } 245 | 246 | // Test cases 247 | var cardinalityTests = []struct { 248 | json string 249 | responseStatus string 250 | responseStatusCode int 251 | prometheusInstance PrometheusCardinalityInstance 252 | incomingTSDBStatus TSDBStatus 253 | expectedMetrics map[string]bool 254 | expectedAuthHeaderValue string 255 | }{ 256 | { 257 | `{"status":"success", "data":{"seriesCountByMetricName":[],"labelValueCountByLabelName":[],"memoryInBytesByLabelName":[],"seriesCountByLabelValuePair":[]}}`, 258 | "200 OK", 259 | 200, 260 | PrometheusCardinalityInstance{ 261 | Namespace: "namespace", 262 | InstanceName: "prometheus-test", 263 | ShardedInstanceName: "prometheus-shard", 264 | }, 265 | TSDBStatus{ 266 | Status: "success", 267 | Data: TSDBData{ 268 | []labelValuePair{}, 269 | []labelValuePair{}, 270 | []labelValuePair{}, 271 | []labelValuePair{}, 272 | }, 273 | }, 274 | make(map[string]bool), 275 | "", 276 | }, 277 | { 278 | `{"status":"success", "data":{"seriesCountByMetricName":[{"name":"label0","value":0}],"labelValueCountByLabelName":[{"name":"label1","value":1}],"memoryInBytesByLabelName":[{"name":"label2","value":2}],"seriesCountByLabelValuePair":[{"name":"label3=label3value","value":3}]}}`, 279 | "200 OK", 280 | 200, 281 | PrometheusCardinalityInstance{ 282 | Namespace: "namespace-1", 283 | InstanceName: "prometheus-test-1", 284 | ShardedInstanceName: "prometheus-shard-1", 285 | TrackedLabels: TrackedLabelNames{ 286 | SeriesCountByMetricNameLabels: []string{"YeOldeMetric", "MetricMcOutOfDate"}, 287 | LabelValueCountByLabelNameLabels: []string{}, 288 | MemoryInBytesByLabelNameLabels: []string{}, 289 | SeriesCountByLabelValuePairLabels: []string{"StraightOuttaDateMetric"}, 290 | }, 291 | }, 292 | TSDBStatus{ 293 | Status: "success", 294 | Data: TSDBData{ 295 | []labelValuePair{ 296 | {Label: "label0", Value: 0}, 297 | }, 298 | []labelValuePair{ 299 | {Label: "label1", Value: 1}, 300 | }, 301 | []labelValuePair{ 302 | {Label: "label2", Value: 2}, 303 | }, 304 | []labelValuePair{ 305 | {Label: "label3=label3value", Value: 3}, 306 | }, 307 | }, 308 | }, 309 | map[string]bool{ 310 | `cardinality_exporter_series_count_by_metric_name_total{instance_namespace="namespace-1",metric="label0",scraped_instance="prometheus-test-1",sharded_instance="prometheus-shard-1"} 0`: true, 311 | `cardinality_exporter_label_value_count_by_label_name_total{instance_namespace="namespace-1",label="label1",scraped_instance="prometheus-test-1",sharded_instance="prometheus-shard-1"} 1`: true, 312 | `cardinality_exporter_memory_by_label_name_bytes{instance_namespace="namespace-1",label="label2",scraped_instance="prometheus-test-1",sharded_instance="prometheus-shard-1"} 2`: true, 313 | `cardinality_exporter_series_count_by_label_value_pair_total{instance_namespace="namespace-1",label_pair="label3=label3value",scraped_instance="prometheus-test-1",sharded_instance="prometheus-shard-1"} 3`: true, 314 | }, 315 | "", 316 | }, 317 | { 318 | `{"status":"success", "data":{"seriesCountByMetricName":[{"name":"label4","value":4},{"name":"label5","value":5}],"labelValueCountByLabelName":[{"name":"label6","value":6}],"memoryInBytesByLabelName":[{"name":"label7","value":7}],"seriesCountByLabelValuePair":[]}}`, 319 | "200 OK", 320 | 200, 321 | PrometheusCardinalityInstance{ 322 | Namespace: "namespace-2", 323 | InstanceName: "prometheus-test-2", 324 | ShardedInstanceName: "prometheus-shard-2", 325 | AuthValue: "Basic YWRtaW46cGFzc3dvcmQ=", 326 | TrackedLabels: TrackedLabelNames{ 327 | SeriesCountByMetricNameLabels: []string{}, 328 | LabelValueCountByLabelNameLabels: []string{"OAM", "GreatGrandmetric"}, 329 | MemoryInBytesByLabelNameLabels: []string{"DeadMetric"}, 330 | SeriesCountByLabelValuePairLabels: []string{}, 331 | }, 332 | }, 333 | TSDBStatus{ 334 | Status: "success", 335 | Data: TSDBData{ 336 | []labelValuePair{ 337 | {Label: "label4", Value: 4}, 338 | {Label: "label5", Value: 5}, 339 | }, 340 | []labelValuePair{ 341 | {Label: "label6", Value: 6}, 342 | }, 343 | []labelValuePair{ 344 | {Label: "label7", Value: 7}, 345 | }, 346 | []labelValuePair{}, 347 | }, 348 | }, 349 | map[string]bool{ 350 | `cardinality_exporter_series_count_by_metric_name_total{instance_namespace="namespace-2",metric="label4",scraped_instance="prometheus-test-2",sharded_instance="prometheus-shard-2"} 4`: true, 351 | `cardinality_exporter_series_count_by_metric_name_total{instance_namespace="namespace-2",metric="label5",scraped_instance="prometheus-test-2",sharded_instance="prometheus-shard-2"} 5`: true, 352 | `cardinality_exporter_label_value_count_by_label_name_total{instance_namespace="namespace-2",label="label6",scraped_instance="prometheus-test-2",sharded_instance="prometheus-shard-2"} 6`: true, 353 | `cardinality_exporter_memory_by_label_name_bytes{instance_namespace="namespace-2",label="label7",scraped_instance="prometheus-test-2",sharded_instance="prometheus-shard-2"} 7`: true, 354 | }, 355 | "Basic YWRtaW46cGFzc3dvcmQ=", 356 | }, 357 | } 358 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= 4 | github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 10 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= 12 | github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 13 | github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 14 | github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 15 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 16 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 17 | github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= 18 | github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= 19 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 20 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 21 | github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= 22 | github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= 23 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 24 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 25 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 26 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 27 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 28 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 29 | github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= 30 | github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= 31 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 32 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 33 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 34 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= 35 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 36 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 37 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 38 | github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= 39 | github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= 40 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 41 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 42 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 43 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 44 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 45 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 46 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 47 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 48 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 49 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 50 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 51 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 52 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 53 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 54 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 55 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 56 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 57 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 58 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 59 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 60 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= 61 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 62 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 63 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 64 | github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= 65 | github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 66 | github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= 67 | github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 68 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 69 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 70 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 71 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 72 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 73 | github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 74 | github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 75 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 76 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 77 | github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= 78 | github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 79 | github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 80 | github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 81 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 82 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 83 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 84 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 85 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 86 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 87 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 88 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 89 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 90 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 91 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 92 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 93 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 94 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 95 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 96 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 97 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 98 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 99 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 100 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 101 | go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 102 | go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 103 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 104 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 105 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 106 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 107 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 108 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 109 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 110 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 111 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 112 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 113 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 114 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 115 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 116 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 117 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 118 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 119 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 120 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 121 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 122 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 123 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 124 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 125 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 126 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 127 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 128 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 129 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 130 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 131 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 132 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 133 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 134 | golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= 135 | golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 136 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 137 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 138 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 139 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 140 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 141 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 142 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 143 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 144 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 145 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 146 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 147 | golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= 148 | golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 149 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 150 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 151 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 152 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 153 | google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= 154 | google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 155 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 156 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 157 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 158 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 159 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 160 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 161 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 162 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 163 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 164 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 165 | k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= 166 | k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= 167 | k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= 168 | k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= 169 | k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= 170 | k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= 171 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 172 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 173 | k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= 174 | k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= 175 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= 176 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 177 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= 178 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 179 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 180 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 181 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= 182 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= 183 | sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= 184 | sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= 185 | --------------------------------------------------------------------------------