commit a0e328f6d3824cf8611c3378198011d113c2a9b3
parent 47394384e683940267f278afdfaa44fb09f531ef
Author: MTRNord <MTRNord@users.noreply.github.com>
Date: Fri, 27 Feb 2026 10:42:06 +0100
proxmox-ccm
Signed-off-by: MTRNord <MTRNord@users.noreply.github.com>
Diffstat:
7 files changed, 201 insertions(+), 67 deletions(-)
diff --git a/apps/talos_cluster/kustomization.yaml b/apps/talos_cluster/kustomization.yaml
@@ -36,3 +36,4 @@ resources:
- ./persephone
- ./codeberg
- ./opencost
+ - ./proxmox-ccm
diff --git a/apps/talos_cluster/proxmox-ccm/kustomization.yaml b/apps/talos_cluster/proxmox-ccm/kustomization.yaml
@@ -0,0 +1,7 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: kube-system
+resources:
+ - repository.yaml
+ - secret.yaml
+ - release.yaml
diff --git a/apps/talos_cluster/proxmox-ccm/release.yaml b/apps/talos_cluster/proxmox-ccm/release.yaml
@@ -0,0 +1,55 @@
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: proxmox-ccm
+ namespace: kube-system
+spec:
+ releaseName: proxmox-ccm
+ targetNamespace: kube-system
+ interval: 30m
+ chart:
+ spec:
+ chart: proxmox-cloud-controller-manager
+ sourceRef:
+ kind: HelmRepository
+ name: sergelogvinov
+ namespace: kube-system
+ interval: 1h
+ install:
+ remediation:
+ retries: 3
+ upgrade:
+ remediation:
+ retries: 3
+ # Sensitive values (Proxmox API credentials) are loaded from the SOPS-encrypted secret.
+ # Non-sensitive values are set inline below.
+ valuesFrom:
+ - kind: Secret
+ name: proxmox-ccm-values
+ valuesKey: values.yaml
+ values:
+ cluster:
+ name: cluster-2025
+
+ # Only initialize nodes — node lifecycle (deletion on VM removal) is also safe
+ # to enable since each CCM only manages its own providerID-prefixed nodes.
+ enabledControllers:
+ - cloud-node
+ - cloud-node-lifecycle
+
+ # Run on existing cloud control-plane nodes so the CCM is available before
+ # dedicated workers join (and thus can remove the uninitialized taint promptly).
+ nodeSelector:
+ node-role.kubernetes.io/control-plane: ""
+
+ tolerations:
+ - key: node-role.kubernetes.io/control-plane
+ operator: Exists
+ effect: NoSchedule
+
+ resources:
+ requests:
+ cpu: 50m
+ memory: 32Mi
+ limits:
+ memory: 128Mi
diff --git a/apps/talos_cluster/proxmox-ccm/repository.yaml b/apps/talos_cluster/proxmox-ccm/repository.yaml
@@ -0,0 +1,8 @@
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: HelmRepository
+metadata:
+ name: sergelogvinov
+ namespace: kube-system
+spec:
+ interval: 1h
+ url: https://sergelogvinov.github.io/charts
diff --git a/apps/talos_cluster/proxmox-ccm/secret.yaml b/apps/talos_cluster/proxmox-ccm/secret.yaml
@@ -0,0 +1,39 @@
+# IMPORTANT: This file must be SOPS-encrypted before committing.
+# After filling in real values, run from the gitops repo root:
+# sops --encrypt --in-place apps/talos_cluster/proxmox-ccm/secret.yaml
+#
+# Proxmox API token for CCM (read-only is sufficient):
+# pveum user add ccm@pve
+# pveum role add CCM -privs "VM.Audit Sys.Audit"
+# pveum aclmod / -user ccm@pve -role CCM
+# pveum user token add ccm@pve ccm --privsep=0
+#
+# The 'config' key in valuesFrom must match the Helm chart's values structure.
+# config.clusters entries correspond to Proxmox nodes; one per physical server.
+apiVersion: v1
+kind: Secret
+metadata:
+ name: proxmox-ccm-values
+ namespace: kube-system
+stringData:
+ values.yaml: ENC[AES256_GCM,data:p3jRZP1Iyi6pswnoXJXcKv6VQE09FG/VLi9yYZCBSb5TRLXAPjFX0xqVE3Q4PyzXnO2ir+AnRf+vVYk8LOqpqEE4frxSTHwTfXpX1TYL/4uU4AOOQmOVmMfrvmr6EM6urwIJrU5HL3c+3mlxQQ44f2JxTYsQv+W3gR2+IA9Uo1h54LMo1Yyi5RBNOvTtWOLWmTEhxs5I/jS6qkALF73dDD7QrIuojYf+2ur1q0VFkBdWxbeC9FpF08u7pqEpwGZNQIhy0AoVyRAX03SkmJXTXNboAAaiyebu/MdsSh9A1BexecOY8HU6mim5GC/pJaIHV7R+ultb8WF9fmL8ZQ6xzI2Fy42/EcbYWMobI+iMjfWsriroOZIOGC0xMlg5n/QW8gPIh3tHtWThPaO81ShdGNiqeSb1T+F/XXljNHLovOZnls3Rx/juzODpHW7anXrdDQahs9RP/xtt7i2TMlrGxD4GPBSZ35j3OIlMQIAM4rNUG/ofrclwnTdpjNBtPfsQlCP6T3dz2s+snE8tELv91dqYzlZZg7MlH9w/lYZNzJYXC63DZVJ1KOJcC2FL/fvsw9b8LBfwz2NAFQ==,iv:RHd6VizrmaVMnPIg3hhR55LDcSm3Yf9A9ZRh6uj9zXk=,tag:6PKgfxRnRPI8fH+9l2QbqA==,type:str]
+sops:
+ kms: []
+ gcp_kms: []
+ azure_kv: []
+ hc_vault: []
+ age:
+ - recipient: age1esjyg2qfy49awv0ptkzvpk425adczjr38m37w2mmcahzc4p8n54sll2nzh
+ enc: |
+ -----BEGIN AGE ENCRYPTED FILE-----
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBseFRvRFl0eWcyZ2ZuTkZ3
+ MzhUcW84UW5WSk0vRVMycUVwb2gvOEk3SFdFClNHZjViZW5iVzZhVjBRMjdKMUxL
+ dU9jazhnU01BTy9tY0diYkZtcm03bmMKLS0tIHRMRmNBQ0gvM1BYSjNZNkxKdWJZ
+ KzA2cjMwYzY5KzBBMFFnblBEZGZXeU0KuMLAKs1gU/sNtglluNJCiUwKNvaQ7Av2
+ bI3eZ/wBMZhhLUxDpO56s6CQ0Hk2dUz1P/CYaUFrxdL5CGVYnIbXRw==
+ -----END AGE ENCRYPTED FILE-----
+ lastmodified: "2026-02-27T09:41:36Z"
+ mac: ENC[AES256_GCM,data:YvZOg43pPSQy5tejTByXARbYF23/06m5gPEYc2m369JfOxHfCzHWxYO2bt537imisfOZjL9raeLRUGfDhRPRO9gQZmTJiG/+Hsv+G0/EEL/GN6ISSf+pcFKGCD+7FqwUiMsGVxxzZlBHWSQZYLvpREHSedCN5pSio+upswluFNY=,iv:KdOrveWoYtkSyyNeCBcymeS3rjX8lDHQxz1+QDqI8j8=,tag:ILxPFtd91Lc46XVz415YHw==,type:str]
+ pgp: []
+ encrypted_regex: ^(apiKey|appUserPassword|otelUserPassword|harborAdminPassword|totpVaultKey|kimaiAppSecret|kimaiAdminPassword|GITHUB_CLIENT_ID|GITHUB_CLIENT_SECRET|GITHUB_PRIVATE_KEY|woosh|root_password|rspamd_password|pgdb_password|matrix_access_token|pgdb_remote_url|hmac_secret_key|adminPassword|adminEmail|jenkinsAdminEmail|securityRealm|gerrit.config|routing_key|DATABASE_URL|SMTP_PASSWORD|SECRET_KEY_BASE|admin_password|extraCommands|key|clickhouseDatabaseURL|databaseURL|client_id|client_secret|secret_key_base|otp_secret|private_key|public_key|primaryKey|deterministicKey|keyDerivationSalt|token|clientId|secretKey|installationId|installationKey|uriOverride|adminToken.value|password.value|sql_password|erlangCookie|AUTHENTICATION_PASSWORD|ROOM_API_SECRET_KEY|adminPassword|configPassword|adminUser|configUser|MAIL_PASSWORD|APP_KEY|api_key|api_secret|keys|livekit_key|livekit_secret|secret_key|admin_pass|admin_email|mariadbPassword|mariadbRootPassword|privateKey|data|stringData|PASSWD|password|pass|postgresPassword|postgresqlPassword|redminePassword|smtpPassword|registration_shared_secret|shared_secret|secret|admin_token|integrationKey|rootPassword|adminPassword|adminUser|adminEmail|emailPassword|secretKey|appId|clientSecret|webhookSecret)$
+ version: 3.9.1
diff --git a/scripts/hetzner_pricing.csv b/scripts/hetzner_pricing.csv
@@ -1,5 +1,5 @@
# Hetzner Cloud Pricing for OpenCost CSV Provider
-# Generated: 2026-02-26T11:39:02.783509
+# Generated: 2026-02-26T20:48:41.236842
# Source: Hetzner Cloud API (https://api.hetzner.cloud/v1/pricing)
# Prices in EUR per hour (net, excluding VAT)
#
@@ -133,6 +133,6 @@ EndTimestamp,InstanceID,Region,AssetClass,InstanceIDField,InstanceType,MarketPri
,lb31,ash,node,metadata.labels.load-balancer.hetzner.cloud/type,lb31,0.049500,2026.02
,lb31,hil,node,metadata.labels.load-balancer.hetzner.cloud/type,lb31,0.049500,2026.02
,lb31,sin,node,metadata.labels.load-balancer.hetzner.cloud/type,lb31,0.049500,2026.02
-,ssd,fsn1,pv,spec.storageClassName,hcloud-volumes,0.00006027,2026.02
-,ssd,nbg1,pv,spec.storageClassName,hcloud-volumes,0.00006027,2026.02
-,ssd,hel1,pv,spec.storageClassName,hcloud-volumes,0.00006027,2026.02
+,hcloud-volumes,fsn1,pv,spec.storageClassName,hcloud-volumes,0.00006027,2026.02
+,hcloud-volumes,nbg1,pv,spec.storageClassName,hcloud-volumes,0.00006027,2026.02
+,hcloud-volumes,hel1,pv,spec.storageClassName,hcloud-volumes,0.00006027,2026.02
diff --git a/scripts/hetzner_pricing_generator.py b/scripts/hetzner_pricing_generator.py
@@ -11,10 +11,10 @@ Requirements:
Usage:
# Set your Hetzner Cloud API token
export HCLOUD_TOKEN="your-api-token"
-
+
# Run the script
python3 hetzner_pricing_generator.py
-
+
# Output will be written to hetzner_pricing.csv
API Documentation:
@@ -48,7 +48,9 @@ def get_api_token() -> str:
token = os.environ.get("HCLOUD_TOKEN")
if not token:
print("Error: HCLOUD_TOKEN environment variable not set")
- print("Get your token from: https://console.hetzner.cloud/projects/*/security/tokens")
+ print(
+ "Get your token from: https://console.hetzner.cloud/projects/*/security/tokens"
+ )
sys.exit(1)
return token
@@ -57,12 +59,12 @@ def fetch_pricing(token: str) -> dict[str, Any]:
"""Fetch pricing data from Hetzner Cloud API."""
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(f"{HETZNER_API_URL}/pricing", headers=headers)
-
+
if response.status_code != 200:
print(f"Error fetching pricing: {response.status_code}")
print(response.text)
sys.exit(1)
-
+
return response.json()
@@ -70,12 +72,12 @@ def fetch_server_types(token: str) -> dict[str, Any]:
"""Fetch server types from Hetzner Cloud API."""
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(f"{HETZNER_API_URL}/server_types", headers=headers)
-
+
if response.status_code != 200:
print(f"Error fetching server types: {response.status_code}")
print(response.text)
sys.exit(1)
-
+
return response.json()
@@ -83,12 +85,12 @@ def fetch_load_balancer_types(token: str) -> dict[str, Any]:
"""Fetch load balancer types from Hetzner Cloud API."""
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(f"{HETZNER_API_URL}/load_balancer_types", headers=headers)
-
+
if response.status_code != 200:
print(f"Error fetching load balancer types: {response.status_code}")
print(response.text)
sys.exit(1)
-
+
return response.json()
@@ -103,91 +105,112 @@ def parse_hourly_price(price_str: str) -> float:
def generate_csv(output_file: str = "hetzner_pricing.csv"):
"""Generate OpenCost-compatible CSV from Hetzner API data."""
token = get_api_token()
-
+
print("Fetching pricing data from Hetzner Cloud API...")
pricing_data = fetch_pricing(token)
server_types_data = fetch_server_types(token)
lb_types_data = fetch_load_balancer_types(token)
-
+
version = datetime.now().strftime("%Y.%m")
rows = []
-
+
# Header
header = [
- "EndTimestamp", "InstanceID", "Region", "AssetClass",
- "InstanceIDField", "InstanceType", "MarketPriceHourly", "Version"
+ "EndTimestamp",
+ "InstanceID",
+ "Region",
+ "AssetClass",
+ "InstanceIDField",
+ "InstanceType",
+ "MarketPriceHourly",
+ "Version",
]
-
+
# Process server types
print("Processing server types...")
for server_type in server_types_data.get("server_types", []):
name = server_type["name"]
-
+
# Get pricing for each location
for price_info in server_type.get("prices", []):
location = price_info.get("location")
hourly_price = parse_hourly_price(
price_info.get("price_hourly", {}).get("net", "0")
)
-
+
if hourly_price > 0:
- rows.append([
- "", # EndTimestamp
- name, # InstanceID
- location, # Region
- "node", # AssetClass
- "metadata.labels.node.kubernetes.io/instance-type", # InstanceIDField
- name, # InstanceType
- f"{hourly_price:.6f}", # MarketPriceHourly
- version # Version
- ])
-
+ rows.append(
+ [
+ "", # EndTimestamp
+ name, # InstanceID
+ location, # Region
+ "node", # AssetClass
+ "metadata.labels.node.kubernetes.io/instance-type", # InstanceIDField
+ name, # InstanceType
+ f"{hourly_price:.6f}", # MarketPriceHourly
+ version, # Version
+ ]
+ )
+
# Process load balancer types
print("Processing load balancer types...")
for lb_type in lb_types_data.get("load_balancer_types", []):
name = lb_type["name"]
-
+
for price_info in lb_type.get("prices", []):
location = price_info.get("location")
hourly_price = parse_hourly_price(
price_info.get("price_hourly", {}).get("net", "0")
)
-
+
if hourly_price > 0:
- rows.append([
- "", # EndTimestamp
- name, # InstanceID
- location, # Region
- "node", # AssetClass (using node for LBs to track costs)
- "metadata.labels.load-balancer.hetzner.cloud/type", # InstanceIDField
- name, # InstanceType
- f"{hourly_price:.6f}", # MarketPriceHourly
- version # Version
- ])
-
+ rows.append(
+ [
+ "", # EndTimestamp
+ name, # InstanceID
+ location, # Region
+ "node", # AssetClass (using node for LBs to track costs)
+ "metadata.labels.load-balancer.hetzner.cloud/type", # InstanceIDField
+ name, # InstanceType
+ f"{hourly_price:.6f}", # MarketPriceHourly
+ version, # Version
+ ]
+ )
+
# Process volume pricing
print("Processing volume pricing...")
pricing = pricing_data.get("pricing", {})
volume_pricing = pricing.get("volume", {})
-
+
# Volume pricing is per GB per month, convert to hourly
- volume_monthly = parse_hourly_price(
- volume_pricing.get("price_per_gb_month", {}).get("net", "0.052")
- )
+ price_per_gb_month_net = volume_pricing.get("price_per_gb_month", {}).get("net")
+ if price_per_gb_month_net is None:
+ # Fall back to a hardcoded default but warn the user that API data was unavailable
+ fallback_price_per_gb_month_net = "0.052"
+ print(
+ "Warning: Volume pricing (pricing.volume.price_per_gb_month.net) "
+ "not returned by Hetzner API; using fallback net price_per_gb_month="
+ f"{fallback_price_per_gb_month_net} EUR/GB/month",
+ file=sys.stderr,
+ )
+ price_per_gb_month_net = fallback_price_per_gb_month_net
+ volume_monthly = parse_hourly_price(price_per_gb_month_net)
volume_hourly = volume_monthly / 730 # Average hours per month
-
+
for region in EU_REGIONS:
- rows.append([
- "", # EndTimestamp
- "ssd", # InstanceID
- region, # Region
- "pv", # AssetClass
- "spec.storageClassName", # InstanceIDField
- "hcloud-volumes", # InstanceType
- f"{volume_hourly:.8f}", # MarketPriceHourly
- version # Version
- ])
-
+ rows.append(
+ [
+ "", # EndTimestamp
+ "hcloud-volumes", # InstanceID
+ region, # Region
+ "pv", # AssetClass
+ "spec.storageClassName", # InstanceIDField
+ "hcloud-volumes", # InstanceType
+ f"{volume_hourly:.8f}", # MarketPriceHourly
+ version, # Version
+ ]
+ )
+
# Write CSV
print(f"Writing {len(rows)} pricing entries to {output_file}...")
with open(output_file, "w", newline="") as f:
@@ -197,11 +220,11 @@ def generate_csv(output_file: str = "hetzner_pricing.csv"):
f.write("# Source: Hetzner Cloud API (https://api.hetzner.cloud/v1/pricing)\n")
f.write("# Prices in EUR per hour (net, excluding VAT)\n")
f.write("#\n")
-
+
writer = csv.writer(f)
writer.writerow(header)
writer.writerows(rows)
-
+
print(f"Done! Generated {output_file} with {len(rows)} pricing entries.")
print("\nTo use with OpenCost:")
print(" 1. Copy the CSV to your OpenCost deployment")
@@ -212,16 +235,17 @@ def generate_csv(output_file: str = "hetzner_pricing.csv"):
def main():
"""Main entry point."""
import argparse
-
+
parser = argparse.ArgumentParser(
description="Generate OpenCost-compatible pricing CSV from Hetzner Cloud API"
)
parser.add_argument(
- "-o", "--output",
+ "-o",
+ "--output",
default="hetzner_pricing.csv",
- help="Output CSV file path (default: hetzner_pricing.csv)"
+ help="Output CSV file path (default: hetzner_pricing.csv)",
)
-
+
args = parser.parse_args()
generate_csv(args.output)