cluster

Infrastructure files for Nordgedanken and Midnightthoughts.
git clone git://archive.git.mtrnord.blog/MTRNord/cluster.git
Log | Files | Refs | README

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:
Mapps/talos_cluster/kustomization.yaml | 1+
Aapps/talos_cluster/proxmox-ccm/kustomization.yaml | 7+++++++
Aapps/talos_cluster/proxmox-ccm/release.yaml | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps/talos_cluster/proxmox-ccm/repository.yaml | 8++++++++
Aapps/talos_cluster/proxmox-ccm/secret.yaml | 39+++++++++++++++++++++++++++++++++++++++
Mscripts/hetzner_pricing.csv | 8++++----
Mscripts/hetzner_pricing_generator.py | 150++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
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)