├── .gitignore ├── version.yaml ├── .chartreleaser.yaml ├── .editorconfig ├── helm └── hcloud-pricing-exporter │ ├── templates │ ├── secret.yaml │ ├── role.yaml │ ├── serviceaccount.yaml │ ├── service.yaml │ ├── role-binding.yaml │ ├── servicemonitor.yaml │ ├── ingress.yaml │ ├── _helpers.tpl │ └── deployment.yaml │ ├── .helmignore │ ├── Chart.yaml │ ├── values.yaml │ └── README.md ├── Dockerfile ├── e2e ├── e2e_suite_test.go ├── utils_test.go ├── fetcher_volume_test.go ├── fetcher_floatingip_test.go ├── fetcher_loadbalancer_test.go ├── fetcher_primaryip_test.go └── fetcher_server_test.go ├── .golangci.yml ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── stale.yml └── workflows │ ├── release-helm.yaml │ ├── release.yaml │ ├── codeql-analysis.yml │ └── build.yaml ├── .goreleaser.yml ├── LICENSE ├── fetcher ├── snapshot.go ├── volume.go ├── floatingip.go ├── primaryip.go ├── utils.go ├── server.go ├── loadbalancer.go ├── server_traffic.go ├── loadbalancer_traffic.go ├── server_backups.go ├── fetcher.go └── prices.go ├── go.mod ├── main.go ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | dist 4 | -------------------------------------------------------------------------------- /version.yaml: -------------------------------------------------------------------------------- 1 | version: 0.11.0 2 | helmRevision: r1 3 | -------------------------------------------------------------------------------- /.chartreleaser.yaml: -------------------------------------------------------------------------------- 1 | release-name-template: "helm-v{{ .Version }}" 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = tab 8 | insert_final_newline = true 9 | max_line_length = 120 10 | tab_width = 4 11 | 12 | [{*.yaml, *.yml}] 13 | indent_size = 2 14 | indent_style = space 15 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.secret.create }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ include "hcloud-pricing-exporter.fullname" . }} 6 | type: Opaque 7 | data: 8 | token: {{ required "An token to access the HCloud API is required" .Values.secret.token | b64enc }} 9 | {{- end }} 10 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/templates/role.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.create -}} 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: {{ .Values.rbac.kind }} 4 | metadata: 5 | name: {{ include "hcloud-pricing-exporter.fullname" . }} 6 | labels: 7 | {{- include "hcloud-pricing-exporter.labels" . | nindent 4 }} 8 | {{- with .Values.rbac.rules }} 9 | rules: 10 | {{- toYaml . | nindent 2 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ARCH="" 2 | FROM golang:alpine 3 | 4 | # Create application directory 5 | RUN mkdir /app 6 | ADD . /app/ 7 | WORKDIR /app 8 | 9 | # Build the application 10 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=${ARCH} go mod download 11 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=${ARCH} go build -o run . 12 | 13 | # Add the execution user 14 | RUN adduser -S -D -H -h /app execuser 15 | USER execuser 16 | 17 | # Run the application 18 | ENTRYPOINT ["./run"] 19 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "hcloud-pricing-exporter.serviceAccountName" . }} 6 | labels: 7 | {{- include "hcloud-pricing-exporter.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "hcloud-pricing-exporter.fullname" . }} 5 | labels: 6 | {{- include "hcloud-pricing-exporter.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http-metrics 12 | protocol: TCP 13 | selector: 14 | {{- include "hcloud-pricing-exporter.selectorLabels" . | nindent 4 }} 15 | -------------------------------------------------------------------------------- /e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package e2e_test 3 | 4 | import ( 5 | "testing" 6 | 7 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var ( 13 | testClient = hcloud.NewClient(hcloud.WithToken(hcloudAPITokenFromENV())) 14 | testLabels = map[string]string{ 15 | "test": "github.com_jangraefen_hcloud-pricing-exporter", 16 | "suite": "e2e_suite_test", 17 | } 18 | ) 19 | 20 | func TestFetcher(t *testing.T) { 21 | RegisterFailHandler(Fail) 22 | RunSpecs(t, "E2E Suite") 23 | } 24 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gosimple 4 | - govet 5 | - ineffassign 6 | - staticcheck 7 | - bodyclose 8 | - dupl 9 | - exportloopref 10 | - funlen 11 | - gocognit 12 | - goconst 13 | - gocritic 14 | - gocyclo 15 | - godot 16 | - gofmt 17 | - goprintffuncname 18 | - gosec 19 | - prealloc 20 | - revive 21 | - stylecheck 22 | - unconvert 23 | - whitespace 24 | 25 | linters-settings: 26 | govet: 27 | check-shadowing: true 28 | enable-all: true 29 | disable: 30 | - fieldalignment 31 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/.github/workflows/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | github-actions: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "gomod" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | groups: 16 | gomod: 17 | patterns: 18 | - "*" 19 | - package-ecosystem: "docker" 20 | directory: "/" 21 | schedule: 22 | interval: "weekly" 23 | groups: 24 | docker: 25 | patterns: 26 | - "*" 27 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: hcloud-pricing-exporter 3 | description: A prometheus exporter for the current pricing and costs of your HCloud account 4 | home: https://github.com/jangraefen/hcloud-pricing-exporter 5 | maintainers: 6 | - name: Jan Graefen 7 | sources: 8 | - https://github.com/jangraefen/hcloud-pricing-exporter 9 | keywords: 10 | - hetzner 11 | - hcloud 12 | - prometheus 13 | - monitoring 14 | - prices 15 | - expenses 16 | 17 | type: application 18 | 19 | version: 0.11.0-r1 20 | appVersion: 0.11.0 21 | 22 | annotations: 23 | artifacthub.io/changes: | 24 | - Use new fields for server and load balancer traffic prices. 25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/templates/role-binding.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.create -}} 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: {{ printf "%sBinding" .Values.rbac.kind }} 4 | metadata: 5 | name: {{ include "hcloud-pricing-exporter.fullname" . }} 6 | labels: 7 | {{- include "hcloud-pricing-exporter.labels" . | nindent 4 }} 8 | subjects: 9 | - kind: ServiceAccount 10 | name: {{ include "hcloud-pricing-exporter.serviceAccountName" . }} 11 | {{- if eq .Values.rbac.kind "ClusterRole" }} 12 | namespace: {{ .Release.Namespace }} 13 | {{- end }} 14 | roleRef: 15 | apiGroup: rbac.authorization.k8s.io 16 | kind: {{ .Values.rbac.kind }} 17 | name: {{ include "hcloud-pricing-exporter.fullname" . }} 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/release-helm.yaml: -------------------------------------------------------------------------------- 1 | name: Release Charts 2 | 3 | on: 4 | push: 5 | paths: 6 | - version.yaml 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Configure Git 18 | run: | 19 | git config user.name "$GITHUB_ACTOR" 20 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 21 | 22 | - name: Install Helm 23 | uses: azure/setup-helm@v4 24 | with: 25 | version: v3.4.2 26 | 27 | - name: Run chart-releaser 28 | uses: helm/chart-releaser-action@v1.6.0 29 | with: 30 | charts_dir: helm 31 | config: .chartreleaser.yaml 32 | env: 33 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 34 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | 5 | builds: 6 | - id: "hcloud-pricing-exporter-cli" 7 | main: ./main.go 8 | binary: hcloud-pricing-exporter 9 | env: 10 | - CGO_ENABLED=0 11 | - GO111MODULE=on 12 | goos: 13 | - linux 14 | - windows 15 | goarch: 16 | - "386" 17 | - amd64 18 | - arm 19 | - arm64 20 | 21 | archives: 22 | - name_template: "hcloud-pricing-exporter-{{ .Version }}-{{ .Os }}-{{ .Arch }}" 23 | format_overrides: 24 | - goos: windows 25 | format: zip 26 | 27 | source: 28 | enabled: true 29 | name_template: "hcloud-pricing-exporter-{{ .Version }}.src" 30 | 31 | checksum: 32 | name_template: "hcloud-pricing-exporter-{{ .Version }}.checksums.txt" 33 | 34 | milestones: 35 | - close: true 36 | 37 | changelog: 38 | sort: asc 39 | filters: 40 | exclude: 41 | - '^docs:' 42 | - '^test:' 43 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.serviceMonitor.create }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | name: {{ include "hcloud-pricing-exporter.fullname" . }} 6 | labels: 7 | {{- include "hcloud-pricing-exporter.labels" . | nindent 4 }} 8 | {{- if .Values.serviceMonitor.labels }} 9 | {{- toYaml .Values.serviceMonitor.labels | nindent 4 }} 10 | {{- end }} 11 | spec: 12 | endpoints: 13 | - honorLabels: true 14 | targetPort: http-metrics 15 | {{- with .Values.serviceMonitor.interval }} 16 | interval: {{ . }} 17 | {{- end }} 18 | {{- with .Values.serviceMonitor.scrapeTimeout }} 19 | scrapeTimeout: {{ . }} 20 | {{- end }} 21 | namespaceSelector: 22 | matchNames: 23 | - "{{ .Release.Namespace }}" 24 | selector: 25 | matchLabels: 26 | {{- include "hcloud-pricing-exporter.selectorLabels" . | nindent 6 }} 27 | {{- end }} 28 | -------------------------------------------------------------------------------- /.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 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jan Graefen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /fetcher/snapshot.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 5 | ) 6 | 7 | var _ Fetcher = &snapshot{} 8 | 9 | // NewSnapshot creates a new fetcher that will collect pricing information on server snapshots. 10 | func NewSnapshot(pricing *PriceProvider, additionalLabels ...string) Fetcher { 11 | return &snapshot{newBase(pricing, "snapshot", nil, additionalLabels...)} 12 | } 13 | 14 | type snapshot struct { 15 | *baseFetcher 16 | } 17 | 18 | func (snapshot snapshot) Run(client *hcloud.Client) error { 19 | images, err := client.Image.All(ctx) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | for _, i := range images { 25 | if i.Type == "snapshot" { 26 | monthlyPrice := float64(i.ImageSize) * snapshot.pricing.Image() 27 | hourlyPrice := pricingPerHour(monthlyPrice) 28 | 29 | labels := append([]string{ 30 | i.Name, 31 | }, 32 | parseAdditionalLabels(snapshot.additionalLabels, i.Labels)..., 33 | ) 34 | 35 | snapshot.hourly.WithLabelValues(labels...).Set(hourlyPrice) 36 | snapshot.monthly.WithLabelValues(labels...).Set(monthlyPrice) 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /fetcher/volume.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 7 | ) 8 | 9 | var _ Fetcher = &volume{} 10 | 11 | // NewVolume creates a new fetcher that will collect pricing information on volumes. 12 | func NewVolume(pricing *PriceProvider, additionalLabels ...string) Fetcher { 13 | return &volume{newBase(pricing, "volume", []string{"location", "bytes"}, additionalLabels...)} 14 | } 15 | 16 | type volume struct { 17 | *baseFetcher 18 | } 19 | 20 | func (volume volume) Run(client *hcloud.Client) error { 21 | volumes, err := client.Volume.All(ctx) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | for _, v := range volumes { 27 | monthlyPrice := float64(v.Size) * volume.pricing.Volume() 28 | hourlyPrice := pricingPerHour(monthlyPrice) 29 | 30 | labels := append([]string{ 31 | v.Name, 32 | v.Location.Name, 33 | strconv.Itoa(v.Size), 34 | }, 35 | parseAdditionalLabels(volume.additionalLabels, v.Labels)..., 36 | ) 37 | 38 | volume.hourly.WithLabelValues(labels...).Set(hourlyPrice) 39 | volume.monthly.WithLabelValues(labels...).Set(monthlyPrice) 40 | } 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /fetcher/floatingip.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 5 | ) 6 | 7 | var _ Fetcher = &floatingIP{} 8 | 9 | // NewFloatingIP creates a new fetcher that will collect pricing information on floating IPs. 10 | func NewFloatingIP(pricing *PriceProvider, additionalLabels ...string) Fetcher { 11 | return &floatingIP{newBase(pricing, "floatingip", []string{"location", "type"}, additionalLabels...)} 12 | } 13 | 14 | type floatingIP struct { 15 | *baseFetcher 16 | } 17 | 18 | func (floatingIP floatingIP) Run(client *hcloud.Client) error { 19 | floatingIPs, err := client.FloatingIP.All(ctx) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | for _, f := range floatingIPs { 25 | location := f.HomeLocation 26 | 27 | monthlyPrice := floatingIP.pricing.FloatingIP(f.Type, location.Name) 28 | hourlyPrice := pricingPerHour(monthlyPrice) 29 | 30 | labels := append([]string{ 31 | f.Name, 32 | location.Name, 33 | string(f.Type), 34 | }, 35 | parseAdditionalLabels(floatingIP.additionalLabels, f.Labels)..., 36 | ) 37 | 38 | floatingIP.hourly.WithLabelValues(labels...).Set(hourlyPrice) 39 | floatingIP.monthly.WithLabelValues(labels...).Set(monthlyPrice) 40 | } 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /fetcher/primaryip.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 5 | ) 6 | 7 | var _ Fetcher = &floatingIP{} 8 | 9 | // NewPrimaryIP creates a new fetcher that will collect pricing information on primary IPs. 10 | func NewPrimaryIP(pricing *PriceProvider, additionalLabels ...string) Fetcher { 11 | return &primaryIP{newBase(pricing, "primaryip", []string{"datacenter", "type"}, additionalLabels...)} 12 | } 13 | 14 | type primaryIP struct { 15 | *baseFetcher 16 | } 17 | 18 | func (primaryIP primaryIP) Run(client *hcloud.Client) error { 19 | primaryIPs, err := client.PrimaryIP.All(ctx) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | for _, p := range primaryIPs { 25 | datacenter := p.Datacenter 26 | 27 | hourlyPrice, monthlyPrice, err := primaryIP.pricing.PrimaryIP(p.Type, datacenter.Location.Name) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | labels := append([]string{ 33 | p.Name, 34 | datacenter.Name, 35 | string(p.Type), 36 | }, 37 | parseAdditionalLabels(primaryIP.additionalLabels, p.Labels)..., 38 | ) 39 | 40 | primaryIP.hourly.WithLabelValues(labels...).Set(hourlyPrice) 41 | primaryIP.monthly.WithLabelValues(labels...).Set(monthlyPrice) 42 | } 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /fetcher/utils.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | ) 9 | 10 | const ( 11 | sizeTB = 1 << (10 * 4) 12 | ) 13 | 14 | func daysInMonth() int { 15 | now := time.Now() 16 | 17 | switch now.Month() { 18 | case time.April, time.June, time.September, time.November: 19 | return 30 20 | case time.February: 21 | year := now.Year() 22 | if year%4 == 0 && (year%100 != 0 || year%400 == 0) { 23 | return 29 24 | } 25 | return 28 26 | default: 27 | return 31 28 | } 29 | } 30 | 31 | func pricingPerHour(monthlyPrice float64) float64 { 32 | return monthlyPrice / float64(daysInMonth()) / 24 33 | } 34 | 35 | func parseToGauge(gauge prometheus.Gauge, value string) { 36 | parsed, err := strconv.ParseFloat(value, 32) 37 | if err != nil { 38 | panic(err) 39 | } 40 | gauge.Set(parsed) 41 | } 42 | 43 | func parseAdditionalLabels(additionalLabels []string, labels map[string]string) (result []string) { 44 | for _, al := range additionalLabels { 45 | result = append(result, findLabel(labels, al)) 46 | } 47 | return result 48 | } 49 | 50 | func findLabel(labels map[string]string, label string) string { 51 | for k, v := range labels { 52 | if k == label { 53 | return v 54 | } 55 | } 56 | return "" 57 | } 58 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "hcloud-pricing-exporter.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 5 | apiVersion: networking.k8s.io/v1beta1 6 | {{- else -}} 7 | apiVersion: extensions/v1beta1 8 | {{- end }} 9 | kind: Ingress 10 | metadata: 11 | name: {{ $fullName }} 12 | labels: 13 | {{- include "hcloud-pricing-exporter.labels" . | nindent 4 }} 14 | {{- with .Values.ingress.annotations }} 15 | annotations: 16 | {{- toYaml . | nindent 4 }} 17 | {{- end }} 18 | spec: 19 | {{- if .Values.ingress.tls }} 20 | tls: 21 | {{- range .Values.ingress.tls }} 22 | - hosts: 23 | {{- range .hosts }} 24 | - {{ . | quote }} 25 | {{- end }} 26 | secretName: {{ .secretName }} 27 | {{- end }} 28 | {{- end }} 29 | rules: 30 | {{- range .Values.ingress.hosts }} 31 | - host: {{ .host | quote }} 32 | http: 33 | paths: 34 | {{- range .paths }} 35 | - path: {{ .path }} 36 | backend: 37 | serviceName: {{ $fullName }} 38 | servicePort: {{ $svcPort }} 39 | {{- end }} 40 | {{- end }} 41 | {{- end }} 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jangraefen/hcloud-pricing-exporter 2 | 3 | go 1.22 4 | toolchain go1.22.5 5 | 6 | require ( 7 | github.com/hetznercloud/hcloud-go/v2 v2.17.1 8 | github.com/jtaczanowski/go-scheduler v0.1.0 9 | github.com/onsi/ginkgo/v2 v2.22.2 10 | github.com/onsi/gomega v1.36.2 11 | github.com/prometheus/client_golang v1.20.5 12 | golang.org/x/crypto v0.31.0 13 | ) 14 | 15 | require ( 16 | github.com/beorn7/perks v1.0.1 // indirect 17 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 18 | github.com/go-logr/logr v1.4.2 // indirect 19 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 20 | github.com/google/go-cmp v0.6.0 // indirect 21 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect 22 | github.com/klauspost/compress v1.17.9 // indirect 23 | github.com/kylelemons/godebug v1.1.0 // indirect 24 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 25 | github.com/prometheus/client_model v0.6.1 // indirect 26 | github.com/prometheus/common v0.55.0 // indirect 27 | github.com/prometheus/procfs v0.15.1 // indirect 28 | golang.org/x/net v0.33.0 // indirect 29 | golang.org/x/sys v0.28.0 // indirect 30 | golang.org/x/text v0.21.0 // indirect 31 | golang.org/x/tools v0.28.0 // indirect 32 | google.golang.org/protobuf v1.36.1 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /e2e/utils_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package e2e_test 3 | 4 | import ( 5 | "context" 6 | "crypto/ed25519" 7 | "crypto/rand" 8 | "fmt" 9 | "os" 10 | "time" 11 | 12 | . "github.com/onsi/gomega" 13 | "golang.org/x/crypto/ssh" 14 | 15 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 16 | ) 17 | 18 | func hcloudAPITokenFromENV() string { 19 | if token, ok := os.LookupEnv("HCLOUD_API_TOKEN"); ok { 20 | return token 21 | } 22 | 23 | panic(fmt.Errorf("environment variable HCLOUD_API_TOKEN not set, but required")) 24 | } 25 | 26 | func waitUntilActionSucceeds(ctx context.Context, actionToTrack *hcloud.Action) { 27 | if actionToTrack != nil { 28 | Eventually(func() (hcloud.ActionStatus, error) { 29 | action, _, err := testClient.Action.GetByID(ctx, actionToTrack.ID) 30 | if err != nil { 31 | return hcloud.ActionStatusError, err 32 | } 33 | 34 | return action.Status, nil 35 | }). 36 | WithOffset(1). 37 | Within(1 * time.Minute). 38 | ProbeEvery(5 * time.Second). 39 | Should(Equal(hcloud.ActionStatusSuccess)) 40 | } 41 | } 42 | 43 | func generatePublicKey() string { 44 | public, _, err := ed25519.GenerateKey(rand.Reader) 45 | if err != nil { 46 | panic(err) 47 | } 48 | 49 | sshKey, err := ssh.NewPublicKey(public) 50 | if err != nil { 51 | panic(err) 52 | } 53 | 54 | return string(ssh.MarshalAuthorizedKey(sshKey)) 55 | } 56 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | 3 | image: 4 | repository: jangraefen/hcloud-pricing-exporter 5 | pullPolicy: IfNotPresent 6 | tag: "" 7 | 8 | imagePullSecrets: [ ] 9 | nameOverride: "" 10 | fullnameOverride: "" 11 | 12 | podLabels: { } 13 | 14 | podAnnotations: { } 15 | 16 | service: 17 | type: ClusterIP 18 | port: 8080 19 | 20 | ingress: 21 | enabled: false 22 | annotations: { } 23 | hosts: 24 | - host: chart-example.local 25 | tls: [ ] 26 | 27 | secret: 28 | create: true 29 | token: 30 | reference: 31 | name: 32 | key: 33 | # to read HCLOUD_TOKEN from file, set file to your file path (e.g. /secrets/token) 34 | # the file must be provided manually (e.g. via secret injection) 35 | file: "" 36 | 37 | serviceMonitor: 38 | create: false 39 | interval: 40 | labels: 41 | scrapeTimeout: 42 | 43 | resources: { } 44 | 45 | nodeSelector: { } 46 | 47 | tolerations: [ ] 48 | 49 | affinity: { } 50 | 51 | serviceAccount: 52 | create: false 53 | name: "" 54 | annotations: { } 55 | 56 | rbac: 57 | create: false 58 | # can be set to ClusterRole or Role 59 | kind: ClusterRole 60 | rules: [ ] 61 | # - apiGroups: 62 | # - authorization.k8s.io 63 | # resources: 64 | # - subjectaccessreviews 65 | # verbs: 66 | # - create 67 | # - apiGroups: 68 | # - authentication.k8s.io 69 | # resources: 70 | # - tokenreviews 71 | # verbs: 72 | # - create 73 | -------------------------------------------------------------------------------- /fetcher/server.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 7 | ) 8 | 9 | var _ Fetcher = &server{} 10 | 11 | // NewServer creates a new fetcher that will collect pricing information on servers. 12 | func NewServer(pricing *PriceProvider, additionalLabels ...string) Fetcher { 13 | return &server{newBase(pricing, "server", []string{"location", "type"}, additionalLabels...)} 14 | } 15 | 16 | type server struct { 17 | *baseFetcher 18 | } 19 | 20 | func (server server) Run(client *hcloud.Client) error { 21 | servers, err := client.Server.All(ctx) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | for _, s := range servers { 27 | location := s.Datacenter.Location 28 | 29 | labels := append([]string{ 30 | s.Name, 31 | location.Name, 32 | s.ServerType.Name, 33 | }, 34 | parseAdditionalLabels(server.additionalLabels, s.Labels)..., 35 | ) 36 | 37 | pricing, err := findServerPricing(location, s.ServerType.Pricings) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | parseToGauge(server.hourly.WithLabelValues(labels...), pricing.Hourly.Gross) 43 | parseToGauge(server.monthly.WithLabelValues(labels...), pricing.Monthly.Gross) 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func findServerPricing(location *hcloud.Location, pricings []hcloud.ServerTypeLocationPricing) (*hcloud.ServerTypeLocationPricing, error) { 50 | for _, pricing := range pricings { 51 | if pricing.Location.Name == location.Name { 52 | return &pricing, nil 53 | } 54 | } 55 | 56 | return nil, fmt.Errorf("no server pricing found for location %s", location.Name) 57 | } 58 | -------------------------------------------------------------------------------- /fetcher/loadbalancer.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 7 | ) 8 | 9 | var _ Fetcher = &loadBalancer{} 10 | 11 | // NewLoadbalancer creates a new fetcher that will collect pricing information on load balancers. 12 | func NewLoadbalancer(pricing *PriceProvider, additionalLabels ...string) Fetcher { 13 | return &loadBalancer{newBase(pricing, "loadbalancer", []string{"location", "type"}, additionalLabels...)} 14 | } 15 | 16 | type loadBalancer struct { 17 | *baseFetcher 18 | } 19 | 20 | func (loadBalancer loadBalancer) Run(client *hcloud.Client) error { 21 | loadBalancers, err := client.LoadBalancer.All(ctx) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | for _, lb := range loadBalancers { 27 | location := lb.Location 28 | 29 | labels := append([]string{ 30 | lb.Name, 31 | location.Name, 32 | lb.LoadBalancerType.Name, 33 | }, 34 | parseAdditionalLabels(loadBalancer.additionalLabels, lb.Labels)..., 35 | ) 36 | 37 | pricing, err := findLBPricing(location, lb.LoadBalancerType.Pricings) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | parseToGauge(loadBalancer.hourly.WithLabelValues(labels...), pricing.Hourly.Gross) 43 | parseToGauge(loadBalancer.monthly.WithLabelValues(labels...), pricing.Monthly.Gross) 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func findLBPricing(location *hcloud.Location, pricings []hcloud.LoadBalancerTypeLocationPricing) (*hcloud.LoadBalancerTypeLocationPricing, error) { 50 | for _, pricing := range pricings { 51 | if pricing.Location.Name == location.Name { 52 | return &pricing, nil 53 | } 54 | } 55 | 56 | return nil, fmt.Errorf("no load balancer pricing found for location %s", location.Name) 57 | } 58 | -------------------------------------------------------------------------------- /fetcher/server_traffic.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 7 | ) 8 | 9 | var _ Fetcher = &serverTraffic{} 10 | 11 | // NewServerTraffic creates a new fetcher that will collect pricing information on server traffic. 12 | func NewServerTraffic(pricing *PriceProvider, additionalLabels ...string) Fetcher { 13 | return &serverTraffic{newBase(pricing, "server_traffic", []string{"location", "type"}, additionalLabels...)} 14 | } 15 | 16 | type serverTraffic struct { 17 | *baseFetcher 18 | } 19 | 20 | func (serverTraffic serverTraffic) Run(client *hcloud.Client) error { 21 | servers, err := client.Server.All(ctx) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | for _, s := range servers { 27 | location := s.Datacenter.Location 28 | 29 | labels := append([]string{ 30 | s.Name, 31 | location.Name, 32 | s.ServerType.Name, 33 | }, 34 | parseAdditionalLabels(serverTraffic.additionalLabels, s.Labels)..., 35 | ) 36 | 37 | //nolint:gosec 38 | additionalTraffic := int64(s.OutgoingTraffic) - int64(s.IncludedTraffic) 39 | if additionalTraffic < 0 { 40 | serverTraffic.hourly.WithLabelValues(labels...).Set(0) 41 | serverTraffic.monthly.WithLabelValues(labels...).Set(0) 42 | break 43 | } 44 | 45 | serverTrafficPrice, err := serverTraffic.pricing.ServerTraffic(s.ServerType, location.Name) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | monthlyPrice := math.Ceil(float64(additionalTraffic)/sizeTB) * serverTrafficPrice 51 | hourlyPrice := pricingPerHour(monthlyPrice) 52 | 53 | serverTraffic.hourly.WithLabelValues(labels...).Set(hourlyPrice) 54 | serverTraffic.monthly.WithLabelValues(labels...).Set(monthlyPrice) 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /fetcher/loadbalancer_traffic.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 7 | ) 8 | 9 | var _ Fetcher = &loadbalancerTraffic{} 10 | 11 | // NewLoadbalancerTraffic creates a new fetcher that will collect pricing information on load balancer traffic. 12 | func NewLoadbalancerTraffic(pricing *PriceProvider, additionalLabels ...string) Fetcher { 13 | return &loadbalancerTraffic{newBase(pricing, "loadbalancer_traffic", []string{"location", "type"}, additionalLabels...)} 14 | } 15 | 16 | type loadbalancerTraffic struct { 17 | *baseFetcher 18 | } 19 | 20 | func (loadbalancerTraffic loadbalancerTraffic) Run(client *hcloud.Client) error { 21 | loadBalancers, err := client.LoadBalancer.All(ctx) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | for _, lb := range loadBalancers { 27 | location := lb.Location 28 | 29 | labels := append([]string{ 30 | lb.Name, 31 | location.Name, 32 | lb.LoadBalancerType.Name, 33 | }, 34 | parseAdditionalLabels(loadbalancerTraffic.additionalLabels, lb.Labels)..., 35 | ) 36 | 37 | //nolint:gosec 38 | additionalTraffic := int64(lb.OutgoingTraffic) - int64(lb.IncludedTraffic) 39 | if additionalTraffic < 0 { 40 | loadbalancerTraffic.hourly.WithLabelValues(labels...).Set(0) 41 | loadbalancerTraffic.monthly.WithLabelValues(labels...).Set(0) 42 | break 43 | } 44 | 45 | lbTrafficPrice, err := loadbalancerTraffic.pricing.LoadBalancerTraffic(lb.LoadBalancerType, location.Name) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | monthlyPrice := math.Ceil(float64(additionalTraffic)/sizeTB) * lbTrafficPrice 51 | hourlyPrice := pricingPerHour(monthlyPrice) 52 | 53 | loadbalancerTraffic.hourly.WithLabelValues(labels...).Set(hourlyPrice) 54 | loadbalancerTraffic.monthly.WithLabelValues(labels...).Set(monthlyPrice) 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /fetcher/server_backups.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 7 | ) 8 | 9 | var _ Fetcher = &serverBackup{} 10 | 11 | // NewServerBackup creates a new fetcher that will collect pricing information on server backups. 12 | func NewServerBackup(pricing *PriceProvider, additionalLabels ...string) Fetcher { 13 | return &serverBackup{newBase(pricing, "server_backup", []string{"location", "type"}, additionalLabels...)} 14 | } 15 | 16 | type serverBackup struct { 17 | *baseFetcher 18 | } 19 | 20 | func (serverBackup serverBackup) Run(client *hcloud.Client) error { 21 | servers, err := client.Server.All(ctx) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | for _, s := range servers { 27 | location := s.Datacenter.Location 28 | 29 | labels := append([]string{ 30 | s.Name, 31 | location.Name, 32 | s.ServerType.Name, 33 | }, 34 | parseAdditionalLabels(serverBackup.additionalLabels, s.Labels)..., 35 | ) 36 | 37 | if s.BackupWindow != "" { 38 | serverPrice, err := findServerPricing(location, s.ServerType.Pricings) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | hourlyPrice := serverBackup.toBackupPrice(serverPrice.Hourly.Gross) 44 | monthlyPrice := serverBackup.toBackupPrice(serverPrice.Monthly.Gross) 45 | 46 | serverBackup.hourly.WithLabelValues(labels...).Set(hourlyPrice) 47 | serverBackup.monthly.WithLabelValues(labels...).Set(monthlyPrice) 48 | } else { 49 | serverBackup.hourly.WithLabelValues(labels...).Set(0) 50 | serverBackup.monthly.WithLabelValues(labels...).Set(0) 51 | } 52 | } 53 | 54 | return nil 55 | } 56 | 57 | func (serverBackup serverBackup) toBackupPrice(rawServerPrice string) float64 { 58 | serverPrice, err := strconv.ParseFloat(rawServerPrice, 32) 59 | if err != nil { 60 | return 0 61 | } 62 | 63 | return serverPrice * (serverBackup.pricing.ServerBackup() / 100) 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | binaryrelease: 10 | name: Binary Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: 1.22 21 | - name: Run GoReleaser 22 | uses: goreleaser/goreleaser-action@v6 23 | with: 24 | version: latest 25 | args: release --clean 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | dockerrelease: 30 | name: Docker Release 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Check out code into the Go module directory 34 | uses: actions/checkout@v4 35 | 36 | - name: Docker meta 37 | id: docker_meta 38 | uses: docker/metadata-action@v5 39 | with: 40 | images: jangraefen/hcloud-pricing-exporter 41 | tag-sha: true 42 | tag-semver: | 43 | type=semver,pattern={{version}} 44 | type=semver,pattern={{major}}.{{minor}} 45 | - name: Set up Docker Buildx 46 | id: buildx 47 | uses: docker/setup-buildx-action@v3 48 | 49 | - name: Login to GitHub docker registry 50 | uses: docker/login-action@v3 51 | with: 52 | username: ${{ secrets.DOCKERHUB_USERNAME }} 53 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 54 | 55 | - name: Build docker image 56 | uses: docker/build-push-action@v6 57 | with: 58 | context: . 59 | platforms: | 60 | linux/amd64 61 | linux/arm64/v8 62 | linux/arm/v7 63 | tags: ${{ steps.docker_meta.outputs.tags }} 64 | labels: ${{ steps.docker_meta.outputs.labels }} 65 | push: true 66 | -------------------------------------------------------------------------------- /e2e/fetcher_volume_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package e2e_test 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 8 | "github.com/jangraefen/hcloud-pricing-exporter/fetcher" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | "github.com/prometheus/client_golang/prometheus/testutil" 12 | ) 13 | 14 | var _ = Describe("For volumes", Ordered, Label("volumes"), func() { 15 | sut := fetcher.NewVolume(&fetcher.PriceProvider{Client: testClient}, "suite") 16 | 17 | BeforeAll(func(ctx context.Context) { 18 | location, _, err := testClient.Location.GetByName(ctx, "fsn1") 19 | Expect(err).NotTo(HaveOccurred()) 20 | 21 | res, _, err := testClient.Volume.Create(ctx, hcloud.VolumeCreateOpts{ 22 | Name: ("test-volume"), 23 | Labels: testLabels, 24 | Location: location, 25 | Size: 10, 26 | }) 27 | Expect(err).ShouldNot(HaveOccurred()) 28 | DeferCleanup(testClient.Volume.Delete, res.Volume) 29 | 30 | waitUntilActionSucceeds(ctx, res.Action) 31 | }) 32 | 33 | //nolint:dupl 34 | When("getting prices", func() { 35 | It("should fetch them", func() { 36 | Expect(sut.Run(testClient)).To(Succeed()) 37 | }) 38 | 39 | It("should get prices for correct values", func() { 40 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-volume", "fsn1", "10", "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 41 | Expect(testutil.ToFloat64(sut.GetMonthly().WithLabelValues("test-volume", "fsn1", "10", "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 42 | }) 43 | 44 | It("should get zero for incorrect values", func() { 45 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("invalid-name", "fsn1", "10", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 46 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-volume", "nbg1", "10", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 47 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-volume", "fsn1", "99", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 48 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-volume", "fsn1", "10", "e3e_suite_test"))).Should(BeNumerically("==", 0)) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "hcloud-pricing-exporter.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "hcloud-pricing-exporter.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "hcloud-pricing-exporter.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "hcloud-pricing-exporter.labels" -}} 37 | helm.sh/chart: {{ include "hcloud-pricing-exporter.chart" . }} 38 | {{ include "hcloud-pricing-exporter.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "hcloud-pricing-exporter.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "hcloud-pricing-exporter.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "hcloud-pricing-exporter.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "hcloud-pricing-exporter.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | 64 | -------------------------------------------------------------------------------- /e2e/fetcher_floatingip_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package e2e_test 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 8 | "github.com/jangraefen/hcloud-pricing-exporter/fetcher" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | "github.com/prometheus/client_golang/prometheus/testutil" 12 | ) 13 | 14 | var _ = Describe("For floating IPs", Ordered, Label("floatingips"), func() { 15 | sut := fetcher.NewFloatingIP(&fetcher.PriceProvider{Client: testClient}, "suite") 16 | 17 | BeforeAll(func(ctx context.Context) { 18 | location, _, err := testClient.Location.GetByName(ctx, "fsn1") 19 | Expect(err).NotTo(HaveOccurred()) 20 | 21 | res, _, err := testClient.FloatingIP.Create(ctx, hcloud.FloatingIPCreateOpts{ 22 | Name: hcloud.Ptr("test-floatingip"), 23 | Labels: testLabels, 24 | HomeLocation: location, 25 | Type: hcloud.FloatingIPTypeIPv6, 26 | }) 27 | Expect(err).ShouldNot(HaveOccurred()) 28 | DeferCleanup(testClient.FloatingIP.Delete, res.FloatingIP) 29 | 30 | waitUntilActionSucceeds(ctx, res.Action) 31 | }) 32 | 33 | //nolint:dupl 34 | When("getting prices", func() { 35 | It("should fetch them", func() { 36 | Expect(sut.Run(testClient)).To(Succeed()) 37 | }) 38 | 39 | It("should get prices for correct values", func() { 40 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-floatingip", "fsn1", "ipv6", "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 41 | Expect(testutil.ToFloat64(sut.GetMonthly().WithLabelValues("test-floatingip", "fsn1", "ipv6", "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 42 | }) 43 | 44 | It("should get zero for incorrect values", func() { 45 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("invalid-name", "fsn1", "ipv6", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 46 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-floatingip", "nbg1", "ipv6", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 47 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-floatingip", "fsn1", "ipv4", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 48 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-floatingip", "fsn1", "ipv6", "e3e_suite_test"))).Should(BeNumerically("==", 0)) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /e2e/fetcher_loadbalancer_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package e2e_test 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 8 | "github.com/jangraefen/hcloud-pricing-exporter/fetcher" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | "github.com/prometheus/client_golang/prometheus/testutil" 12 | ) 13 | 14 | var _ = Describe("For loadbalancers", Ordered, Label("loadbalancers"), func() { 15 | sut := fetcher.NewLoadbalancer(&fetcher.PriceProvider{Client: testClient}, "suite") 16 | 17 | BeforeAll(func(ctx context.Context) { 18 | location, _, err := testClient.Location.GetByName(ctx, "fsn1") 19 | Expect(err).NotTo(HaveOccurred()) 20 | 21 | lbType, _, err := testClient.LoadBalancerType.GetByName(ctx, "lb11") 22 | Expect(err).NotTo(HaveOccurred()) 23 | 24 | res, _, err := testClient.LoadBalancer.Create(ctx, hcloud.LoadBalancerCreateOpts{ 25 | Name: "test-loadbalancer", 26 | Labels: testLabels, 27 | Location: location, 28 | LoadBalancerType: lbType, 29 | }) 30 | Expect(err).ShouldNot(HaveOccurred()) 31 | DeferCleanup(testClient.LoadBalancer.Delete, res.LoadBalancer) 32 | 33 | waitUntilActionSucceeds(ctx, res.Action) 34 | }) 35 | 36 | //nolint:dupl 37 | When("getting prices", func() { 38 | It("should fetch them", func() { 39 | Expect(sut.Run(testClient)).To(Succeed()) 40 | }) 41 | 42 | It("should get prices for correct values", func() { 43 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-loadbalancer", "fsn1", "lb11", "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 44 | Expect(testutil.ToFloat64(sut.GetMonthly().WithLabelValues("test-loadbalancer", "fsn1", "lb11", "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 45 | }) 46 | 47 | It("should get zero for incorrect values", func() { 48 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("invalid-name", "fsn1", "lb11", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 49 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-loadbalancer", "nbg1", "lb11", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 50 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-loadbalancer", "fsn1", "lb21", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 51 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-loadbalancer", "fsn1", "lb11", "e3e_suite_test"))).Should(BeNumerically("==", 0)) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '17 13 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'go' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v3 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v3 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v3 68 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "hcloud-pricing-exporter.fullname" . }} 5 | labels: 6 | {{- include "hcloud-pricing-exporter.labels" . | nindent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | {{- include "hcloud-pricing-exporter.selectorLabels" . | nindent 6 }} 12 | template: 13 | metadata: 14 | {{- with .Values.podAnnotations }} 15 | annotations: 16 | {{- toYaml . | nindent 8 }} 17 | {{- end }} 18 | labels: 19 | {{- include "hcloud-pricing-exporter.selectorLabels" . | nindent 8 }} 20 | {{- with .Values.podLabels }} 21 | {{- toYaml . | nindent 8 }} 22 | {{- end }} 23 | spec: 24 | {{- if .Values.serviceAccount.create }} 25 | serviceAccountName: {{ include "hcloud-pricing-exporter.serviceAccountName" . }} 26 | {{- end }} 27 | {{- with .Values.imagePullSecrets }} 28 | imagePullSecrets: 29 | {{- toYaml . | nindent 8 }} 30 | {{- end }} 31 | containers: 32 | - name: {{ .Chart.Name }} 33 | image: "{{ .Values.image.repository }}:v{{ .Values.image.tag | default .Chart.AppVersion }}" 34 | imagePullPolicy: {{ .Values.image.pullPolicy }} 35 | env: 36 | - name: HCLOUD_TOKEN 37 | {{- if .Values.secret.create }} 38 | valueFrom: 39 | secretKeyRef: 40 | name: {{ include "hcloud-pricing-exporter.fullname" . }} 41 | key: token 42 | {{- else if .Values.secret.file }} 43 | value: {{ printf "file:%s" .Values.secret.file }} 44 | {{- else }} 45 | valueFrom: 46 | secretKeyRef: 47 | name: {{ .Values.secret.reference.name }} 48 | key: {{ .Values.secret.reference.key }} 49 | {{- end }} 50 | ports: 51 | - name: http-metrics 52 | containerPort: 8080 53 | protocol: TCP 54 | livenessProbe: 55 | httpGet: 56 | path: /metrics 57 | port: http-metrics 58 | readinessProbe: 59 | httpGet: 60 | path: /metrics 61 | port: http-metrics 62 | resources: 63 | {{- toYaml .Values.resources | nindent 12 }} 64 | {{- with .Values.nodeSelector }} 65 | nodeSelector: 66 | {{- toYaml . | nindent 8 }} 67 | {{- end }} 68 | {{- with .Values.affinity }} 69 | affinity: 70 | {{- toYaml . | nindent 8 }} 71 | {{- end }} 72 | {{- with .Values.tolerations }} 73 | tolerations: 74 | {{- toYaml . | nindent 8 }} 75 | {{- end }} 76 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths-ignore: 7 | - README.md 8 | - LICENSE 9 | - .gitignore 10 | - .editorconfig 11 | - .chartreleaser.yaml 12 | - .github/workflows/*-helm.yaml 13 | - helm/** 14 | - version.yaml 15 | pull_request: 16 | branches: [main] 17 | paths-ignore: 18 | - README.md 19 | - LICENSE 20 | - .gitignore 21 | - .editorconfig 22 | - .chartreleaser.yaml 23 | - .github/workflows/*-helm.yaml 24 | - helm/** 25 | - version.yaml 26 | 27 | jobs: 28 | lint: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Install Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version: ^1.22 35 | - name: Checkout code 36 | uses: actions/checkout@v4 37 | - name: Get dependencies 38 | run: | 39 | export GO111MODULE=on 40 | go get -v -t -d ./... 41 | - name: golangci-lint 42 | uses: golangci/golangci-lint-action@v6 43 | with: 44 | install-mode: binary 45 | version: latest 46 | 47 | build: 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Install Go 51 | uses: actions/setup-go@v5 52 | with: 53 | go-version: ^1.22 54 | - name: Checkout code 55 | uses: actions/checkout@v4 56 | - name: Get dependencies 57 | run: | 58 | export GO111MODULE=on 59 | go get -v -t -d ./... 60 | - name: Build 61 | run: | 62 | export GO111MODULE=on 63 | go mod download 64 | GOOS=linux GOARCH=amd64 go build -o bin/hcloud-pricing-exporter-linux-amd64 main.go 65 | GOOS=linux GOARCH=arm64 go build -o bin/hcloud-pricing-exporter-linux-arm64 main.go 66 | GOOS=windows GOARCH=amd64 go build -o bin/hcloud-pricing-exporter-windows-amd64.exe main.go 67 | - name: Upload Artifacts 68 | uses: actions/upload-artifact@master 69 | with: 70 | name: binaries 71 | path: bin/ 72 | 73 | test: 74 | runs-on: ubuntu-latest 75 | if: github.ref == 'refs/heads/main' 76 | steps: 77 | - name: Install Go 78 | uses: actions/setup-go@v5 79 | with: 80 | go-version: ^1.22 81 | - name: Checkout code 82 | uses: actions/checkout@v4 83 | - name: Get dependencies 84 | run: | 85 | export GO111MODULE=on 86 | go get -v -t -d ./... 87 | - name: Run tests 88 | env: 89 | HCLOUD_API_TOKEN: ${{ secrets.HCLOUD_API_TOKEN }} 90 | run: go test -v -race -covermode=atomic "-coverprofile=coverprofile.out" ./... 91 | - name: Report coverage 92 | uses: codecov/codecov-action@v5 93 | env: 94 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 95 | with: 96 | files: coverprofile.out 97 | fail_ci_if_error: true 98 | -------------------------------------------------------------------------------- /fetcher/fetcher.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 8 | "github.com/prometheus/client_golang/prometheus" 9 | ) 10 | 11 | var ( 12 | ctx = context.Background() 13 | ) 14 | 15 | // Fetcher defines a common interface for types that fetch pricing data from the HCloud API. 16 | type Fetcher interface { 17 | // GetHourly returns the prometheus collector that collects pricing data for hourly expenses. 18 | GetHourly() *prometheus.GaugeVec 19 | // GetMonthly returns the prometheus collector that collects pricing data for monthly expenses. 20 | GetMonthly() *prometheus.GaugeVec 21 | // Run executes a new data fetching cycle and updates the prometheus exposed collectors. 22 | Run(*hcloud.Client) error 23 | } 24 | 25 | type baseFetcher struct { 26 | pricing *PriceProvider 27 | hourly *prometheus.GaugeVec 28 | monthly *prometheus.GaugeVec 29 | additionalLabels []string 30 | } 31 | 32 | func (fetcher baseFetcher) GetHourly() *prometheus.GaugeVec { 33 | return fetcher.hourly 34 | } 35 | 36 | func (fetcher baseFetcher) GetMonthly() *prometheus.GaugeVec { 37 | return fetcher.monthly 38 | } 39 | 40 | func newBase(pricing *PriceProvider, resource string, baselabels []string, additionalLabels ...string) *baseFetcher { 41 | labels := append([]string{"name"}, baselabels...) 42 | labels = append(labels, additionalLabels...) 43 | 44 | hourlyGaugeOpts := prometheus.GaugeOpts{ 45 | Namespace: "hcloud", 46 | Subsystem: "pricing", 47 | Name: fmt.Sprintf("%s_hourly", resource), 48 | Help: fmt.Sprintf("The cost of the resource %s per hour", resource), 49 | } 50 | monthlyGaugeOpts := prometheus.GaugeOpts{ 51 | Namespace: "hcloud", 52 | Subsystem: "pricing", 53 | Name: fmt.Sprintf("%s_monthly", resource), 54 | Help: fmt.Sprintf("The cost of the resource %s per month", resource), 55 | } 56 | 57 | return &baseFetcher{ 58 | pricing: pricing, 59 | hourly: prometheus.NewGaugeVec(hourlyGaugeOpts, labels), 60 | monthly: prometheus.NewGaugeVec(monthlyGaugeOpts, labels), 61 | additionalLabels: additionalLabels, 62 | } 63 | } 64 | 65 | // Fetchers defines a type for a slice of fetchers that should be handled together. 66 | type Fetchers []Fetcher 67 | 68 | // RegisterCollectors registers all collectors of the contained fetchers into the passed registry. 69 | func (fetchers Fetchers) RegisterCollectors(registry *prometheus.Registry) { 70 | for _, fetcher := range fetchers { 71 | registry.MustRegister( 72 | fetcher.GetHourly(), 73 | fetcher.GetMonthly(), 74 | ) 75 | } 76 | } 77 | 78 | // Run executes all contained fetchers and returns a single error, even when multiple failures occurred. 79 | func (fetchers Fetchers) Run(client *hcloud.Client) error { 80 | errors := prometheus.MultiError{} 81 | for _, fetcher := range fetchers { 82 | fetcher.GetHourly().Reset() 83 | fetcher.GetMonthly().Reset() 84 | 85 | if err := fetcher.Run(client); err != nil { 86 | errors.Append(err) 87 | } 88 | } 89 | 90 | if len(errors) > 0 { 91 | return errors 92 | } 93 | return nil 94 | } 95 | 96 | // MustRun executes all contained fetchers and panics if any of them threw an error. 97 | func (fetchers Fetchers) MustRun(client *hcloud.Client) { 98 | if err := fetchers.Run(client); err != nil { 99 | panic(err) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 13 | "github.com/jangraefen/hcloud-pricing-exporter/fetcher" 14 | "github.com/jtaczanowski/go-scheduler" 15 | "github.com/prometheus/client_golang/prometheus" 16 | "github.com/prometheus/client_golang/prometheus/promhttp" 17 | ) 18 | 19 | const ( 20 | defaultPort = 8080 21 | defaultFetchInterval = 1 * time.Minute 22 | ) 23 | 24 | var ( 25 | hcloudAPIToken string 26 | port uint 27 | fetchInterval time.Duration 28 | additionalLabelsFlag string 29 | additionalLabels []string 30 | ) 31 | 32 | func handleFlags() { 33 | flag.StringVar(&hcloudAPIToken, "hcloud-token", "", "the token to authenticate against the HCloud API") 34 | flag.UintVar(&port, "port", defaultPort, "the port that the exporter exposes its data on") 35 | flag.DurationVar(&fetchInterval, "fetch-interval", defaultFetchInterval, "the interval between data fetching cycles") 36 | flag.StringVar(&additionalLabelsFlag, "additional-labels", "", "comma separated additional labels to parse for all metrics, e.g: 'service,environment,owner'") 37 | flag.Parse() 38 | 39 | if hcloudAPIToken == "" { 40 | if envHCloudAPIToken, present := os.LookupEnv("HCLOUD_TOKEN"); present { 41 | hcloudAPIToken = envHCloudAPIToken 42 | } 43 | } 44 | if hcloudAPIToken == "" { 45 | panic(fmt.Errorf("no API token for HCloud specified, but required")) 46 | } 47 | if strings.HasPrefix(hcloudAPIToken, "file:") { 48 | hcloudAPITokenBytes, err := os.ReadFile(strings.TrimPrefix(hcloudAPIToken, "file:")) 49 | if err != nil { 50 | panic(fmt.Errorf("failed to read HCLOUD_TOKEN from file: %s", err.Error())) 51 | } 52 | hcloudAPIToken = strings.TrimSpace(string(hcloudAPITokenBytes)) 53 | } 54 | if len(hcloudAPIToken) != 64 { 55 | panic(fmt.Errorf("invalid API token for HCloud specified, must be 64 characters long")) 56 | } 57 | 58 | additionalLabelsFlag = strings.TrimSpace(strings.ReplaceAll(additionalLabelsFlag, " ", "")) 59 | additionalLabelsSlice := strings.Split(additionalLabelsFlag, ",") 60 | if len(additionalLabelsSlice) > 0 && additionalLabelsSlice[0] != "" { 61 | additionalLabels = additionalLabelsSlice 62 | } 63 | } 64 | 65 | func main() { 66 | handleFlags() 67 | 68 | client := hcloud.NewClient(hcloud.WithToken(hcloudAPIToken)) 69 | priceRepository := &fetcher.PriceProvider{Client: client} 70 | 71 | fetchers := fetcher.Fetchers{ 72 | fetcher.NewFloatingIP(priceRepository, additionalLabels...), 73 | fetcher.NewPrimaryIP(priceRepository, additionalLabels...), 74 | fetcher.NewLoadbalancer(priceRepository, additionalLabels...), 75 | fetcher.NewLoadbalancerTraffic(priceRepository, additionalLabels...), 76 | fetcher.NewServer(priceRepository, additionalLabels...), 77 | fetcher.NewServerBackup(priceRepository, additionalLabels...), 78 | fetcher.NewServerTraffic(priceRepository, additionalLabels...), 79 | fetcher.NewSnapshot(priceRepository, additionalLabels...), 80 | fetcher.NewVolume(priceRepository, additionalLabels...), 81 | } 82 | 83 | fetchers.MustRun(client) 84 | scheduler.RunTaskAtInterval(func() { fetchers.MustRun(client) }, fetchInterval, 0) 85 | scheduler.RunTaskAtInterval(priceRepository.Sync, 10*fetchInterval, 10*fetchInterval) 86 | 87 | registry := prometheus.NewRegistry() 88 | fetchers.RegisterCollectors(registry) 89 | 90 | http.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) 91 | log.Printf("Listening on: http://0.0.0.0:%d\n", port) 92 | 93 | server := &http.Server{ 94 | Addr: fmt.Sprintf(":%d", port), 95 | ReadHeaderTimeout: 10 * time.Second, 96 | } 97 | 98 | if err := server.ListenAndServe(); err != nil { 99 | panic(err) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hcloud-pricing-exporter 2 | 3 | [![Build Status](https://img.shields.io/github/actions/workflow/status/jangraefen/hcloud-pricing-exporter/build.yaml?branch=main&logo=GitHub)](https://github.com/jangraefen/hcloud-pricing-exporter/actions?query=workflow:Build) 4 | [![PkgGoDev](https://pkg.go.dev/badge/mod/github.com/jangraefen/hcloud-pricing-exporter)](https://pkg.go.dev/mod/github.com/jangraefen/hcloud-pricing-exporter) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/jangraefen/hcloud-pricing-exporter)](https://goreportcard.com/report/github.com/jangraefen/hcloud-pricing-exporter) 6 | [![Docker Pulls](https://img.shields.io/docker/pulls/jangraefen/hcloud-pricing-exporter)](https://hub.docker.com/r/jangraefen/hcloud-pricing-exporter) 7 | [![Artifact HUB](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/hcloud-pricing-exporter)](https://artifacthub.io/packages/search?repo=hcloud-pricing-exporter) 8 | 9 | A Prometheus exporter that connects to your HCloud account and collects data on your current expenses. The aim is to 10 | make cost of cloud infrastructure more transparent and manageable, especially for private projects. 11 | 12 | Please note that no guarantees on correctness are made and any financial decisions should be always be based on the 13 | billing and cost functions provided by HCloud itself. Some hourly costs are estimations based on monthly costs, if the 14 | HCloud API does not provide an hourly expense. 15 | 16 | ## Deployment 17 | 18 | To run the exporter from the CLI you need to run the following commands: 19 | 20 | ```shell 21 | # Just run it with the default settings 22 | ./hcloud-pricing-exporter -hcloud-token 23 | 24 | # Get the token from an ENV variable 25 | export HCLOUD_TOKEN= 26 | ./hcloud-pricing-exporter 27 | 28 | # Run the exporter on a different port with another fetch interval 29 | ./hcloud-pricing-exporter -port 1234 -fetch-interval 45m 30 | ``` 31 | 32 | Alternatively, the exporter can be run by using the provided docker image: 33 | 34 | ```shell 35 | docker run jangraefen/hcloud-pricing-exporter:latest -e HCLOUD_TOKEN= -p 8080:8080 36 | ``` 37 | 38 | If you want to deploy the exporter to a Kubernetes environment, you can use the provided helm chart. Just perform the 39 | following commands: 40 | 41 | ```shell 42 | helm repo add hcloud-pricing-exporter https://jangraefen.github.io/hcloud-pricing-exporter 43 | helm repo update 44 | helm upgrade --install hcloud-pricing-exporter hcloud-pricing-exporter/hcloud-pricing-exporter --version {VERSION} 45 | ``` 46 | 47 | ## Exported metrics 48 | 49 | - `hcloud_pricing_floatingip_hourly{name, location, type}` _(Estimated based on the monthly price)_ 50 | - `hcloud_pricing_floatingip_monthly{name, location, type}` 51 | - `hcloud_pricing_loadbalancer_hourly{name, location, type}` 52 | - `hcloud_pricing_loadbalancer_monthly{name, location, type}` 53 | - `hcloud_pricing_primaryip_hourly{name, datacenter, type}` 54 | - `hcloud_pricing_primaryip_monthly{name, datacenter, type}` 55 | - `hcloud_pricing_server_hourly{name, location, type}` 56 | - `hcloud_pricing_server_monthly{name, location, type}` 57 | - `hcloud_pricing_server_backups_hourly{name, location, type}` 58 | - `hcloud_pricing_server_backups_monthly{name, location, type}` 59 | - `hcloud_pricing_server_traffic_hourly{name, location, type}` _(Estimated based on the monthly price)_ 60 | - `hcloud_pricing_server_traffic_monthly{name, location, type}` 61 | - `hcloud_pricing_snapshot_hourly{name}` _(Estimated based on the monthly price)_ 62 | - `hcloud_pricing_snapshot_monthly{name}` 63 | - `hcloud_pricing_volume_hourly{name, location, bytes}` _(Estimated based on the monthly price)_ 64 | - `hcloud_pricing_volume_monthly{name, location, bytes}` 65 | 66 | Each exported metric can also be enriched with additional labels, coming from the actual labels on the Hetzner resource. 67 | To expose additional labels, use the `-additional-labels label1,label2,...` command line parameter. 68 | -------------------------------------------------------------------------------- /e2e/fetcher_primaryip_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package e2e_test 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 8 | "github.com/jangraefen/hcloud-pricing-exporter/fetcher" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | "github.com/prometheus/client_golang/prometheus/testutil" 12 | ) 13 | 14 | var _ = Describe("For primary IPs", Ordered, Label("primaryips"), func() { 15 | sut := fetcher.NewPrimaryIP(&fetcher.PriceProvider{Client: testClient}, "suite") 16 | 17 | BeforeAll(func(ctx context.Context) { 18 | By("Creating a IPv4 address") 19 | resv4, _, err := testClient.PrimaryIP.Create(ctx, hcloud.PrimaryIPCreateOpts{ 20 | Name: "test-primaryipv4", 21 | Labels: testLabels, 22 | Datacenter: "fsn1-dc14", 23 | Type: hcloud.PrimaryIPTypeIPv4, 24 | AssigneeType: "server", 25 | }) 26 | Expect(err).ShouldNot(HaveOccurred()) 27 | DeferCleanup(testClient.PrimaryIP.Delete, resv4.PrimaryIP) 28 | 29 | waitUntilActionSucceeds(ctx, resv4.Action) 30 | 31 | By("Creating a IPv6 address") 32 | resv6, _, err := testClient.PrimaryIP.Create(ctx, hcloud.PrimaryIPCreateOpts{ 33 | Name: "test-primaryipv6", 34 | Labels: testLabels, 35 | Datacenter: "fsn1-dc14", 36 | Type: hcloud.PrimaryIPTypeIPv6, 37 | AssigneeType: "server", 38 | }) 39 | Expect(err).ShouldNot(HaveOccurred()) 40 | DeferCleanup(testClient.PrimaryIP.Delete, resv6.PrimaryIP) 41 | 42 | waitUntilActionSucceeds(ctx, resv6.Action) 43 | }) 44 | 45 | //nolint:dupl 46 | When("getting prices", func() { 47 | It("should fetch them", func() { 48 | By("Running the price collection") 49 | Expect(sut.Run(testClient)).To(Succeed()) 50 | }) 51 | 52 | It("should get prices for correct values for v4", func() { 53 | By("Checking IPv4 prices") 54 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-primaryipv4", "fsn1-dc14", "ipv4", "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 55 | Expect(testutil.ToFloat64(sut.GetMonthly().WithLabelValues("test-primaryipv4", "fsn1-dc14", "ipv4", "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 56 | }) 57 | 58 | It("should get prices for correct values for v6", func() { 59 | By("Checking IPv6 prices") 60 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("test-primaryipv6", "fsn1-dc14", "ipv6", "e2e_suite_test"))).Should(BeNumerically("==", 0.0)) 61 | Expect(testutil.ToFloat64(sut.GetMonthly().WithLabelValues("test-primaryipv6", "fsn1-dc14", "ipv6", "e2e_suite_test"))).Should(BeNumerically("==", 0.0)) 62 | }) 63 | 64 | It("should get zero for incorrect values", func() { 65 | By("Checking IPv4 prices") 66 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("invalid-name", "fsn1-dc14", "ipv4", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 67 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("est-primaryipv4", "nbg1-dc14", "ipv4", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 68 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("est-primaryipv4", "fsn1-dc14", "ipv6", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 69 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("est-primaryipv4", "fsn1-dc14", "ipv4", "e3e_suite_test"))).Should(BeNumerically("==", 0)) 70 | 71 | By("Checking IPv6 prices") 72 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("invalid-name", "fsn1-dc14", "ipv6", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 73 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("est-primaryipv6", "nbg1-dc14", "ipv6", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 74 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("est-primaryipv6", "fsn1-dc14", "ipv4", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 75 | Expect(testutil.ToFloat64(sut.GetHourly().WithLabelValues("est-primaryipv6", "fsn1-dc14", "ipv6", "e3e_suite_test"))).Should(BeNumerically("==", 0)) 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /helm/hcloud-pricing-exporter/README.md: -------------------------------------------------------------------------------- 1 | # hcloud-pricing-exporter Helm Chart 2 | 3 | [![Build Status](https://img.shields.io/github/workflow/status/jangraefen/hcloud-pricing-exporter/Build?logo=GitHub)](https://github.com/jangraefen/hcloud-pricing-exporter/actions?query=workflow:Build) 4 | [![Docker Pulls](https://img.shields.io/docker/pulls/jangraefen/hcloud-pricing-exporter)](https://hub.docker.com/r/jangraefen/hcloud-pricing-exporter) 5 | [![Artifact HUB](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/hcloud-pricing-exporter)](https://artifacthub.io/packages/search?repo=hcloud-pricing-exporter) 6 | 7 | A Prometheus exporter that connects to your HCloud account and collects data on your current expenses. The aim is to 8 | make cost of cloud infrastructure more transparent and manageable, especially for private projects. 9 | 10 | Please note that no guarantees on correctness are made and any financial decisions should be always be based on the 11 | billing and cost functions provided by HCloud itself. Some hourly costs are estimations based on monthly costs, if the 12 | HCloud API does not provide an hourly expense. 13 | 14 | ## Deployment 15 | 16 | To run the exporter from the CLI you need to run the following commands: 17 | 18 | ```shell 19 | helm repo add hcloud-pricing-exporter https://jangraefen.github.io/hcloud-pricing-exporter 20 | helm repo update 21 | helm upgrade --install hcloud-pricing-exporter hcloud-pricing-exporter/hcloud-pricing-exporter --version {VERSION} 22 | ``` 23 | 24 | ## Configuration 25 | 26 | Parameter | Default | Description 27 | ------------------------------ | -------------------------------------- | ----------- 28 | `replicaCount` | `1` | 29 | `image.repository` | `"jangraefen/hcloud-pricing-exporter"` | 30 | `image.pullPolicy` | `"IfNotPresent"` | 31 | `image.tag` | `""` | 32 | `imagePullSecrets` | `[]` | 33 | `nameOverride` | `""` | 34 | `fullnameOverride` | `""` | 35 | `podAnnotations` | `{}` | 36 | `service.type` | `"ClusterIP"` | 37 | `service.port` | `8080` | 38 | `ingress.enabled` | `false` | 39 | `ingress.annotations` | `{}` | 40 | `ingress.hosts` | `[{"host": "chart-example.local"}]` | 41 | `ingress.tls` | `[]` | 42 | `secret.token` | `null` | The API token to access your HCloud data. 43 | `secret.create` | `true` | If you want to provision the secret for the API token yourself, set this to `false`. 44 | `secret.reference.name` | `null` | The name of the secret that contains the API token to access your HCloud data. 45 | `secret.reference.key` | `null` | The key of the secret that contains the API token to access your HCloud data. 46 | `serviceMonitor.create` | `false` | Enable this if you want to monitor the exporter with the Prometheus Operator. 47 | `serviceMonitor.interval` | `null` | 48 | `serviceMonitor.labels` | `null` | 49 | `serviceMonitor.scrapeTimeout` | `null` | 50 | `resources` | `{}` | 51 | `nodeSelector` | `{}` | 52 | `tolerations` | `[]` | 53 | `affinity` | `{}` | 54 | -------------------------------------------------------------------------------- /fetcher/prices.go: -------------------------------------------------------------------------------- 1 | package fetcher 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "sync" 8 | 9 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 10 | ) 11 | 12 | // PriceProvider provides easy access to current HCloud prices. 13 | type PriceProvider struct { 14 | Client *hcloud.Client 15 | pricing *hcloud.Pricing 16 | pricingLock sync.RWMutex 17 | } 18 | 19 | // FloatingIP returns the current price for a floating IP per month. 20 | func (provider *PriceProvider) FloatingIP(ipType hcloud.FloatingIPType, location string) float64 { 21 | provider.pricingLock.RLock() 22 | defer provider.pricingLock.RUnlock() 23 | 24 | for _, byType := range provider.getPricing().FloatingIPs { 25 | if byType.Type == ipType { 26 | for _, pricing := range byType.Pricings { 27 | if pricing.Location.Name == location { 28 | return parsePrice(pricing.Monthly.Gross) 29 | } 30 | } 31 | } 32 | } 33 | 34 | // If the pricing can not be determined by the type and location, we just return 0.00 35 | return 0.0 36 | } 37 | 38 | // PrimaryIP returns the current price for a primary IP per hour and month. 39 | func (provider *PriceProvider) PrimaryIP(ipType hcloud.PrimaryIPType, location string) (hourly, monthly float64, err error) { 40 | provider.pricingLock.RLock() 41 | defer provider.pricingLock.RUnlock() 42 | 43 | // v6 pricing is not defined by the API 44 | if string(ipType) == "ipv6" { 45 | return 0, 0, nil 46 | } 47 | 48 | for _, byType := range provider.getPricing().PrimaryIPs { 49 | if byType.Type == string(ipType) { 50 | for _, pricing := range byType.Pricings { 51 | if pricing.Location == location { 52 | return parsePrice(pricing.Hourly.Gross), parsePrice(pricing.Monthly.Gross), nil 53 | } 54 | } 55 | } 56 | } 57 | 58 | return 0, 0, fmt.Errorf("no primary IP pricing found for location %s", location) 59 | } 60 | 61 | // Image returns the current price for an image per GB per month. 62 | func (provider *PriceProvider) Image() float64 { 63 | provider.pricingLock.RLock() 64 | defer provider.pricingLock.RUnlock() 65 | 66 | return parsePrice(provider.getPricing().Image.PerGBMonth.Gross) 67 | } 68 | 69 | // ServerTraffic returns the current price for a TB of extra traffic per month. 70 | func (provider *PriceProvider) ServerTraffic(serverType *hcloud.ServerType, location string) (gross float64, err error) { 71 | provider.pricingLock.RLock() 72 | defer provider.pricingLock.RUnlock() 73 | 74 | for _, byType := range provider.getPricing().ServerTypes { 75 | if byType.ServerType.ID == serverType.ID { 76 | for _, price := range byType.Pricings { 77 | if price.Location.Name == location { 78 | return parsePrice(price.PerTBTraffic.Gross), nil 79 | } 80 | } 81 | } 82 | } 83 | 84 | return 0, fmt.Errorf("no traffic pricing found for server type %s and location %s", serverType.Name, location) 85 | } 86 | 87 | // LoadBalancerTraffic returns the current price for a TB of extra traffic per month. 88 | func (provider *PriceProvider) LoadBalancerTraffic(loadBalancerType *hcloud.LoadBalancerType, location string) (gross float64, err error) { 89 | provider.pricingLock.RLock() 90 | defer provider.pricingLock.RUnlock() 91 | 92 | for _, byType := range provider.getPricing().LoadBalancerTypes { 93 | if byType.LoadBalancerType.ID == loadBalancerType.ID { 94 | for _, price := range byType.Pricings { 95 | if price.Location.Name == location { 96 | return parsePrice(price.PerTBTraffic.Gross), nil 97 | } 98 | } 99 | } 100 | } 101 | 102 | return 0, fmt.Errorf("no traffic pricing found for load balancer type %s and location %s", loadBalancerType.Name, location) 103 | } 104 | 105 | // ServerBackup returns the percentage of base price increase for server backups per month. 106 | func (provider *PriceProvider) ServerBackup() float64 { 107 | provider.pricingLock.RLock() 108 | defer provider.pricingLock.RUnlock() 109 | 110 | return parsePrice(provider.getPricing().ServerBackup.Percentage) 111 | } 112 | 113 | // Volume returns the current price for a volume per GB per month. 114 | func (provider *PriceProvider) Volume() float64 { 115 | provider.pricingLock.RLock() 116 | defer provider.pricingLock.RUnlock() 117 | 118 | return parsePrice(provider.getPricing().Volume.PerGBMonthly.Gross) 119 | } 120 | 121 | // Sync forces the provider to re-fetch prices from the HCloud API. 122 | func (provider *PriceProvider) Sync() { 123 | provider.pricingLock.Lock() 124 | defer provider.pricingLock.Unlock() 125 | 126 | provider.pricing = nil 127 | } 128 | 129 | func (provider *PriceProvider) getPricing() *hcloud.Pricing { 130 | if provider.pricing == nil { 131 | pricing, _, err := provider.Client.Pricing.Get(context.Background()) 132 | if err != nil { 133 | panic(err) 134 | } 135 | 136 | provider.pricing = &pricing 137 | } 138 | 139 | return provider.pricing 140 | } 141 | 142 | func parsePrice(rawPrice string) float64 { 143 | if price, err := strconv.ParseFloat(rawPrice, 32); err == nil { 144 | return price 145 | } 146 | 147 | return 0 148 | } 149 | -------------------------------------------------------------------------------- /e2e/fetcher_server_test.go: -------------------------------------------------------------------------------- 1 | //nolint:revive 2 | package e2e_test 3 | 4 | import ( 5 | "context" 6 | "time" 7 | 8 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 9 | "github.com/jangraefen/hcloud-pricing-exporter/fetcher" 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | "github.com/prometheus/client_golang/prometheus/testutil" 13 | ) 14 | 15 | const ( 16 | serverTypeName = "cx22" 17 | ) 18 | 19 | var _ = Describe("For servers", Ordered, Label("servers"), func() { 20 | sutServer := fetcher.NewServer(&fetcher.PriceProvider{Client: testClient}, "suite") 21 | sutBackup := fetcher.NewServerBackup(&fetcher.PriceProvider{Client: testClient}, "suite") 22 | 23 | BeforeAll(func(ctx context.Context) { 24 | location, _, err := testClient.Location.GetByName(ctx, "fsn1") 25 | Expect(err).NotTo(HaveOccurred()) 26 | 27 | serverType, _, err := testClient.ServerType.GetByName(ctx, serverTypeName) 28 | Expect(err).NotTo(HaveOccurred()) 29 | 30 | image, _, err := testClient.Image.GetByNameAndArchitecture(ctx, "ubuntu-24.04", hcloud.ArchitectureX86) 31 | Expect(err).NotTo(HaveOccurred()) 32 | 33 | sshKey, _, err := testClient.SSHKey.Create(ctx, hcloud.SSHKeyCreateOpts{ 34 | Name: "test-key", 35 | Labels: testLabels, 36 | PublicKey: generatePublicKey(), 37 | }) 38 | Expect(err).ShouldNot(HaveOccurred()) 39 | DeferCleanup(testClient.SSHKey.Delete, sshKey) 40 | 41 | By("Setting up a server") 42 | res, _, err := testClient.Server.Create(ctx, hcloud.ServerCreateOpts{ 43 | Name: "test-server", 44 | Labels: testLabels, 45 | Location: location, 46 | ServerType: serverType, 47 | Image: image, 48 | SSHKeys: []*hcloud.SSHKey{sshKey}, 49 | PublicNet: &hcloud.ServerCreatePublicNet{EnableIPv4: false, EnableIPv6: true}, 50 | }) 51 | Expect(err).ShouldNot(HaveOccurred()) 52 | DeferCleanup(testClient.Server.DeleteWithResult, context.Background(), res.Server) 53 | 54 | waitUntilActionSucceeds(ctx, res.Action) 55 | 56 | By("Enabling backups for the server") 57 | Eventually(func() (hcloud.ServerStatus, error) { 58 | server, _, serverErr := testClient.Server.GetByID(ctx, res.Server.ID) 59 | if serverErr != nil { 60 | return hcloud.ServerStatusOff, err 61 | } 62 | 63 | return server.Status, nil 64 | }). 65 | Within(1 * time.Minute). 66 | ProbeEvery(5 * time.Second). 67 | Should(Equal(hcloud.ServerStatusRunning)) 68 | 69 | action, _, err := testClient.Server.EnableBackup(ctx, res.Server, "") 70 | Expect(err).ShouldNot(HaveOccurred()) 71 | 72 | waitUntilActionSucceeds(ctx, action) 73 | }) 74 | 75 | //nolint:dupl 76 | When("getting prices", func() { 77 | It("should fetch them", func() { 78 | By("Running the price collection") 79 | Expect(sutServer.Run(testClient)).To(Succeed()) 80 | Expect(sutBackup.Run(testClient)).To(Succeed()) 81 | }) 82 | 83 | It("should get prices for correct values", func() { 84 | By("Checking server prices") 85 | Expect(testutil.ToFloat64(sutServer.GetHourly().WithLabelValues("test-server", "fsn1", serverTypeName, "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 86 | Expect(testutil.ToFloat64(sutServer.GetMonthly().WithLabelValues("test-server", "fsn1", serverTypeName, "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 87 | 88 | By("Checking server backup prices") 89 | Expect(testutil.ToFloat64(sutBackup.GetHourly().WithLabelValues("test-server", "fsn1", serverTypeName, "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 90 | Expect(testutil.ToFloat64(sutBackup.GetMonthly().WithLabelValues("test-server", "fsn1", serverTypeName, "e2e_suite_test"))).Should(BeNumerically(">", 0.0)) 91 | }) 92 | 93 | It("should get zero for incorrect values", func() { 94 | By("Checking server prices") 95 | Expect(testutil.ToFloat64(sutServer.GetHourly().WithLabelValues("invalid-name", "fsn1", serverTypeName, "e2e_suite_test"))).Should(BeNumerically("==", 0)) 96 | Expect(testutil.ToFloat64(sutServer.GetHourly().WithLabelValues("test-server", "nbg1", serverTypeName, "e2e_suite_test"))).Should(BeNumerically("==", 0)) 97 | Expect(testutil.ToFloat64(sutServer.GetHourly().WithLabelValues("test-server", "fsn1", "cx21", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 98 | Expect(testutil.ToFloat64(sutServer.GetHourly().WithLabelValues("test-server", "fsn1", serverTypeName, "e3e_suite_test"))).Should(BeNumerically("==", 0)) 99 | 100 | By("Checking server backup prices") 101 | Expect(testutil.ToFloat64(sutBackup.GetHourly().WithLabelValues("invalid-name", "fsn1", serverTypeName, "e2e_suite_test"))).Should(BeNumerically("==", 0)) 102 | Expect(testutil.ToFloat64(sutBackup.GetHourly().WithLabelValues("test-server", "nbg1", serverTypeName, "e2e_suite_test"))).Should(BeNumerically("==", 0)) 103 | Expect(testutil.ToFloat64(sutBackup.GetHourly().WithLabelValues("test-server", "fsn1", "cx21", "e2e_suite_test"))).Should(BeNumerically("==", 0)) 104 | Expect(testutil.ToFloat64(sutBackup.GetHourly().WithLabelValues("test-server", "fsn1", serverTypeName, "e3e_suite_test"))).Should(BeNumerically("==", 0)) 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 8 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 9 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 10 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= 14 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 15 | github.com/hetznercloud/hcloud-go/v2 v2.17.1 h1:DPi019dv0WCiECEmtcuTgc//hBvnxESb6QlJnAb4a04= 16 | github.com/hetznercloud/hcloud-go/v2 v2.17.1/go.mod h1:6ygmBba+FdawR2lLp/d9uJljY2k0dTYthprrI8usdLw= 17 | github.com/jtaczanowski/go-scheduler v0.1.0 h1:aDcrHrhvM9i0AWxp3wrMwKKmZGjgykt8Afttcd7yC2w= 18 | github.com/jtaczanowski/go-scheduler v0.1.0/go.mod h1:yqdW4TW2f0pD2g5I/ngq4WHbdeKko7htm6i2DyXvEJs= 19 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 20 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 21 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 22 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 23 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 24 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 25 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 26 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 27 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 28 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 29 | github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= 30 | github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= 31 | github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= 32 | github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= 33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 34 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 35 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= 36 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 37 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 38 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 39 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 40 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 41 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 42 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 43 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 44 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 45 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 46 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 47 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 48 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 49 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 50 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 51 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 52 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 53 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 54 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 55 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 56 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 57 | golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= 58 | golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= 59 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 60 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 63 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 64 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 65 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 66 | --------------------------------------------------------------------------------