apiVersion: v1 kind: Secret metadata: name: scaleway-billing namespace: foxhunt type: Opaque stringData: SCW_SECRET_KEY: "84fa878b-81c2-401a-8aef-22996d30e9ea" SCW_ORG_ID: "a55098a3-2cf6-4f73-b011-fff5a299ed94" SCW_PROJECT_ID: "c293eb98-228d-427d-9b16-f0941f3f2adb" SCW_CLUSTER_ID: "34a1e3c4-ac35-48c8-ab49-5f6ec4df32c1" --- apiVersion: v1 kind: ConfigMap metadata: name: scaleway-billing-script namespace: foxhunt data: scrape.py: | #!/usr/bin/env python3 """Scaleway infrastructure scraper → Prometheus pushgateway.""" import json, os, sys, urllib.request, urllib.error, re def sanitize(s): """Sanitize label values for Prometheus text format.""" return str(s).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") PUSHGATEWAY = os.environ.get("PUSHGATEWAY_URL", "http://pushgateway.foxhunt.svc.cluster.local:9091") SECRET = os.environ["SCW_SECRET_KEY"] ORG = os.environ["SCW_ORG_ID"] PROJECT = os.environ["SCW_PROJECT_ID"] CLUSTER = os.environ["SCW_CLUSTER_ID"] SCW_BILLING = "https://api.scaleway.com/billing/v2beta1" SCW_INSTANCE = "https://api.scaleway.com/instance/v1/zones/fr-par-2" SCW_K8S = "https://api.scaleway.com/k8s/v1/regions/fr-par" SCW_BLOCK = "https://api.scaleway.com/block/v1alpha1/zones/fr-par-2" SCW_IPAM = "https://api.scaleway.com/ipam/v1/regions/fr-par" def fetch(url): req = urllib.request.Request(url) req.add_header("X-Auth-Token", SECRET) try: return json.loads(urllib.request.urlopen(req, timeout=30).read()) except Exception as e: print(f"WARN: {url}: {e}", file=sys.stderr) return {} # ── Fetch all data (dynamic — auto-discovers any infra changes) ── billing = fetch(f"{SCW_BILLING}/consumptions?organization_id={ORG}") instances = fetch(f"{SCW_INSTANCE}/servers?project={PROJECT}&per_page=50") pools = fetch(f"{SCW_K8S}/clusters/{CLUSTER}/pools") volumes = fetch(f"{SCW_BLOCK}/volumes?project_id={PROJECT}&per_page=100") ips = fetch(f"{SCW_IPAM}/ips?project_id={PROJECT}&is_ipv6=false&per_page=100") L = [] # ── Billing ── total = 0 cats = {} products = {} for c in billing.get("consumptions", []): cat = c.get("category_name", "other").lower().replace(" ", "_").replace("&", "and") product = c.get("product_name", "unknown").lower().replace(" ", "_").replace("-", "_") sku = c.get("sku", "") val = c.get("value", {}) amount = int(val.get("units", 0) or 0) + int(val.get("nanos", 0) or 0) / 1e9 total += amount cats[cat] = cats.get(cat, 0) + amount products[(cat, product, sku)] = products.get((cat, product, sku), 0) + amount L.append("# HELP scaleway_billing_total_eur Total current month billing in EUR") L.append("# TYPE scaleway_billing_total_eur gauge") L.append(f"scaleway_billing_total_eur {total:.4f}") L.append("# HELP scaleway_billing_consumption_eur Billing by category in EUR") L.append("# TYPE scaleway_billing_consumption_eur gauge") for cat, amt in sorted(cats.items(), key=lambda x: -x[1]): L.append(f'scaleway_billing_consumption_eur{{category="{sanitize(cat)}"}} {amt:.4f}') L.append("# HELP scaleway_billing_product_eur Billing by product in EUR") L.append("# TYPE scaleway_billing_product_eur gauge") for (cat, product, sku), amt in sorted(products.items(), key=lambda x: -x[1]): if amt >= 0.01: L.append(f'scaleway_billing_product_eur{{category="{sanitize(cat)}",product="{sanitize(product)}",sku="{sanitize(sku)}"}} {amt:.4f}') # ── Instances (dynamic — auto-discovers new/removed instances) ── L.append("# HELP scaleway_instance_info Instance metadata") L.append("# TYPE scaleway_instance_info gauge") L.append("# HELP scaleway_instance_running Instance running state (1=running)") L.append("# TYPE scaleway_instance_running gauge") type_counts = {} for s in instances.get("servers", []): name = s.get("name", "?") itype = s.get("commercial_type", "?") state = s.get("state", "?") tags = dict(t.split("=", 1) for t in s.get("tags", []) if "=" in t) pool = tags.get("pool-name", "unknown") L.append(f'scaleway_instance_info{{name="{sanitize(name)}",type="{sanitize(itype)}",pool="{sanitize(pool)}",state="{sanitize(state)}"}} 1') L.append(f'scaleway_instance_running{{name="{sanitize(name)}",type="{sanitize(itype)}",pool="{sanitize(pool)}"}} {1 if state == "running" else 0}') type_counts[itype] = type_counts.get(itype, 0) + 1 L.append("# HELP scaleway_instances_total Total instances by type") L.append("# TYPE scaleway_instances_total gauge") for t, c in type_counts.items(): L.append(f'scaleway_instances_total{{type="{sanitize(t)}"}} {c}') # ── K8s Pools (dynamic — auto-discovers pool changes/autoscaler config) ── L.append("# HELP scaleway_pool_size Current pool node count") L.append("# TYPE scaleway_pool_size gauge") L.append("# HELP scaleway_pool_min Minimum pool size") L.append("# TYPE scaleway_pool_min gauge") L.append("# HELP scaleway_pool_max Maximum pool size") L.append("# TYPE scaleway_pool_max gauge") L.append("# HELP scaleway_pool_autoscaling Pool autoscaling enabled") L.append("# TYPE scaleway_pool_autoscaling gauge") L.append("# HELP scaleway_pool_root_volume_bytes Pool root volume size") L.append("# TYPE scaleway_pool_root_volume_bytes gauge") for p in pools.get("pools", []): name = p.get("name", "?") ntype = p.get("node_type", "?") status = p.get("status", "?") lb = f'pool="{sanitize(name)}",type="{sanitize(ntype)}",status="{sanitize(status)}"' L.append(f"scaleway_pool_size{{{lb}}} {p.get('size', 0)}") L.append(f"scaleway_pool_min{{{lb}}} {p.get('min_size', 0)}") L.append(f"scaleway_pool_max{{{lb}}} {p.get('max_size', 0)}") L.append(f"scaleway_pool_autoscaling{{{lb}}} {1 if p.get('autoscaling') else 0}") L.append(f"scaleway_pool_root_volume_bytes{{{lb}}} {p.get('root_volume_size', 0)}") # ── Block Storage (dynamic — auto-discovers volume additions/removals) ── L.append("# HELP scaleway_volume_size_bytes Volume size in bytes") L.append("# TYPE scaleway_volume_size_bytes gauge") L.append("# HELP scaleway_volume_attached Volume attachment state (1=in_use, 0=available)") L.append("# TYPE scaleway_volume_attached gauge") total_bytes = attached_bytes = detached_bytes = 0 vol_count = attached_count = detached_count = 0 for v in volumes.get("volumes", []): vid = v.get("id", "?")[:36] name = v.get("name", "?")[:60] size = v.get("size", 0) state = v.get("status", "unknown") attached = 1 if state == "in_use" else 0 total_bytes += size; vol_count += 1 if attached: attached_bytes += size; attached_count += 1 else: detached_bytes += size; detached_count += 1 L.append(f'scaleway_volume_size_bytes{{id="{sanitize(vid)}",name="{sanitize(name)}",state="{sanitize(state)}"}} {size}') L.append(f'scaleway_volume_attached{{id="{sanitize(vid)}",name="{sanitize(name)}"}} {attached}') L.append("# HELP scaleway_volumes_total_bytes Total block storage bytes") L.append("# TYPE scaleway_volumes_total_bytes gauge") L.append(f"scaleway_volumes_total_bytes {total_bytes}") L.append("# HELP scaleway_volumes_attached_bytes Attached volume bytes") L.append("# TYPE scaleway_volumes_attached_bytes gauge") L.append(f"scaleway_volumes_attached_bytes {attached_bytes}") L.append("# HELP scaleway_volumes_detached_bytes Detached (wasted) volume bytes") L.append("# TYPE scaleway_volumes_detached_bytes gauge") L.append(f"scaleway_volumes_detached_bytes {detached_bytes}") L.append("# HELP scaleway_volumes_count Volume counts") L.append("# TYPE scaleway_volumes_count gauge") L.append(f'scaleway_volumes_count{{state="total"}} {vol_count}') L.append(f'scaleway_volumes_count{{state="attached"}} {attached_count}') L.append(f'scaleway_volumes_count{{state="detached"}} {detached_count}') # ── IPs (dynamic) ── L.append("# HELP scaleway_ips_total Total allocated IPs") L.append("# TYPE scaleway_ips_total gauge") L.append(f'scaleway_ips_total {len(ips.get("ips", []))}') # ── Push to pushgateway ── metrics = "\n".join(L) + "\n" count = sum(1 for line in L if line.startswith("scaleway_")) print(f"Generated {count} metrics ({len(metrics)} bytes)", file=sys.stderr) # Validate: no empty lines between metrics (pushgateway is strict) metrics = re.sub(r'\n{2,}', '\n', metrics) url = f"{PUSHGATEWAY}/metrics/job/scaleway_billing" req = urllib.request.Request(url, data=metrics.encode(), method="PUT") req.add_header("Content-Type", "text/plain; version=0.0.4") req.add_header("Connection", "close") try: resp = urllib.request.urlopen(req, timeout=30) print(f"OK: pushed {count} metrics (HTTP {resp.status})") except Exception as e: print(f"WARN: PUT failed ({e}), trying POST...", file=sys.stderr) req2 = urllib.request.Request(url, data=metrics.encode(), method="POST") req2.add_header("Content-Type", "text/plain; version=0.0.4") req2.add_header("Connection", "close") try: resp = urllib.request.urlopen(req2, timeout=30) print(f"OK: pushed {count} metrics via POST (HTTP {resp.status})") except Exception as e2: print(f"ERROR: push failed: PUT={e}, POST={e2}", file=sys.stderr) sys.exit(1) --- apiVersion: batch/v1 kind: CronJob metadata: name: scaleway-billing-scraper namespace: foxhunt spec: schedule: "0 * * * *" successfulJobsHistoryLimit: 1 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 2 activeDeadlineSeconds: 120 template: metadata: labels: app: scaleway-billing spec: nodeSelector: k8s.scaleway.com/pool-name: platform restartPolicy: OnFailure containers: - name: scraper image: python:3.12-alpine command: ["python3", "/scripts/scrape.py"] envFrom: - secretRef: name: scaleway-billing volumeMounts: - name: script mountPath: /scripts resources: requests: cpu: 10m memory: 32Mi limits: cpu: 100m memory: 64Mi volumes: - name: script configMap: name: scaleway-billing-script items: - key: scrape.py path: scrape.py defaultMode: 0755