|
| 1 | +--- |
| 2 | +apiVersion: v1 |
| 3 | +kind: ConfigMap |
| 4 | +metadata: |
| 5 | + name: cluster-metadata-sync-script |
| 6 | + namespace: openstack |
| 7 | + labels: |
| 8 | + app.kubernetes.io/name: cluster-metadata |
| 9 | + app.kubernetes.io/component: prometheus-sync |
| 10 | +data: |
| 11 | + sync-cluster-metadata.py: | |
| 12 | + #!/usr/bin/env python3 |
| 13 | + """Sync cluster-metadata ConfigMap data to Prometheus Pushgateway. |
| 14 | +
|
| 15 | + Reads the cluster-metadata ConfigMap mounted as files and pushes |
| 16 | + its key/value pairs as labels on an info-style gauge metric to the |
| 17 | + Prometheus Pushgateway. This makes cluster identity information |
| 18 | + (partition, site, etc.) available for Prometheus queries and joins. |
| 19 | +
|
| 20 | + Files ending in .yaml/.yml are parsed and their top-level scalar |
| 21 | + keys are flattened into individual labels. Nested structures are |
| 22 | + skipped since Prometheus labels must be simple strings. |
| 23 | + """ |
| 24 | +
|
| 25 | + import os |
| 26 | + import sys |
| 27 | + import urllib.request |
| 28 | +
|
| 29 | + # PyYAML is not available in the slim image, so we use a minimal |
| 30 | + # subset parser for simple key: value YAML files. |
| 31 | + import json |
| 32 | + import re |
| 33 | +
|
| 34 | + METADATA_DIR = "/etc/cluster-metadata" |
| 35 | + PUSHGATEWAY_URL = os.environ.get( |
| 36 | + "PUSHGATEWAY_URL", |
| 37 | + "http://kube-prometheus-stack-prometheus-pushgateway.monitoring.svc.cluster.local:9091", |
| 38 | + ) |
| 39 | + METRIC_JOB_NAME = os.environ.get("METRIC_JOB_NAME", "cluster_metadata") |
| 40 | +
|
| 41 | +
|
| 42 | + def parse_yaml_scalars(content, prefix=""): |
| 43 | + """Extract key/value pairs from a YAML file, flattening nested mappings. |
| 44 | +
|
| 45 | + Top-level scalar values become labels directly. |
| 46 | + Nested mappings get their parent key prepended as a prefix |
| 47 | + (e.g., site.argo_events.enabled -> site_argo_events_enabled). |
| 48 | + """ |
| 49 | + results = {} |
| 50 | + lines = content.splitlines() |
| 51 | + i = 0 |
| 52 | + # Track indentation-based hierarchy |
| 53 | + stack = [] # list of (indent_level, key_prefix) |
| 54 | +
|
| 55 | + while i < len(lines): |
| 56 | + line = lines[i] |
| 57 | + stripped = line.rstrip() |
| 58 | + if not stripped or stripped.lstrip().startswith("#") or stripped.strip() == "---": |
| 59 | + i += 1 |
| 60 | + continue |
| 61 | +
|
| 62 | + # Determine indentation level |
| 63 | + indent = len(line) - len(line.lstrip()) |
| 64 | +
|
| 65 | + # Pop stack entries that are at same or deeper indent |
| 66 | + while stack and stack[-1][0] >= indent: |
| 67 | + stack.pop() |
| 68 | +
|
| 69 | + # Build current prefix from stack |
| 70 | + current_prefix = "_".join(s[1] for s in stack) |
| 71 | + if current_prefix and prefix: |
| 72 | + current_prefix = prefix + "_" + current_prefix |
| 73 | + elif prefix: |
| 74 | + current_prefix = prefix |
| 75 | + # If no stack and no prefix, current_prefix stays "" |
| 76 | +
|
| 77 | + match = re.match(r"^(\s*)([A-Za-z_][A-Za-z0-9_.\-]*)\s*:\s*(.*)", line) |
| 78 | + if match: |
| 79 | + key = match.group(2) |
| 80 | + value = match.group(3).strip() |
| 81 | +
|
| 82 | + # If value is empty, it's a mapping parent — push onto stack |
| 83 | + if value == "" or value == "|" or value == ">": |
| 84 | + stack.append((indent, key)) |
| 85 | + i += 1 |
| 86 | + continue |
| 87 | +
|
| 88 | + # Strip inline comments |
| 89 | + if " #" in value: |
| 90 | + value = value[:value.index(" #")].rstrip() |
| 91 | +
|
| 92 | + # Strip surrounding quotes if present |
| 93 | + if (value.startswith("'") and value.endswith("'")) or \ |
| 94 | + (value.startswith('"') and value.endswith('"')): |
| 95 | + value = value[1:-1] |
| 96 | +
|
| 97 | + full_key = f"{current_prefix}_{key}" if current_prefix else key |
| 98 | + results[full_key] = value |
| 99 | + i += 1 |
| 100 | + return results |
| 101 | +
|
| 102 | +
|
| 103 | + def read_metadata(): |
| 104 | + """Read all key/value pairs from the mounted ConfigMap directory. |
| 105 | +
|
| 106 | + Plain files become a single key=value pair. |
| 107 | + YAML files (.yaml/.yml) are parsed and top-level scalar keys are |
| 108 | + extracted as individual entries. |
| 109 | + """ |
| 110 | + data = {} |
| 111 | + for filename in os.listdir(METADATA_DIR): |
| 112 | + filepath = os.path.join(METADATA_DIR, filename) |
| 113 | + if not os.path.isfile(filepath) or filename.startswith(".."): |
| 114 | + continue |
| 115 | + with open(filepath) as f: |
| 116 | + content = f.read().strip() |
| 117 | +
|
| 118 | + if filename.endswith((".yaml", ".yml")): |
| 119 | + # Parse YAML and flatten all scalar keys with hierarchy as prefix |
| 120 | + scalars = parse_yaml_scalars(content) |
| 121 | + for key, value in scalars.items(): |
| 122 | + data[key] = value |
| 123 | + else: |
| 124 | + data[filename] = content |
| 125 | + return data |
| 126 | +
|
| 127 | +
|
| 128 | + def format_pushgateway_payload(cm_data): |
| 129 | + """Format ConfigMap data as Prometheus exposition format. |
| 130 | +
|
| 131 | + Creates an info-style gauge metric 'cluster_metadata_info' with value 1 |
| 132 | + and all ConfigMap keys as labels. |
| 133 | + """ |
| 134 | + labels = {} |
| 135 | + for key, value in cm_data.items(): |
| 136 | + label_name = key.lower().replace("-", "_").replace(".", "_") |
| 137 | + label_value = ( |
| 138 | + value.replace("\\", "\\\\") |
| 139 | + .replace("\n", "\\n") |
| 140 | + .replace('"', '\\"') |
| 141 | + ) |
| 142 | + labels[label_name] = label_value |
| 143 | +
|
| 144 | + if not labels: |
| 145 | + print("WARNING: ConfigMap has no data keys, nothing to push") |
| 146 | + return None |
| 147 | +
|
| 148 | + label_str = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items())) |
| 149 | + lines = [ |
| 150 | + "# HELP cluster_metadata_info Cluster metadata from ConfigMap as labels.", |
| 151 | + "# TYPE cluster_metadata_info gauge", |
| 152 | + f"cluster_metadata_info{{{label_str}}} 1", |
| 153 | + "", |
| 154 | + ] |
| 155 | + return "\n".join(lines) |
| 156 | +
|
| 157 | +
|
| 158 | + def push_to_gateway(payload): |
| 159 | + """Push metrics payload to the Prometheus Pushgateway.""" |
| 160 | + url = f"{PUSHGATEWAY_URL}/metrics/job/{METRIC_JOB_NAME}" |
| 161 | + data = payload.encode("utf-8") |
| 162 | + req = urllib.request.Request( |
| 163 | + url, |
| 164 | + data=data, |
| 165 | + method="PUT", |
| 166 | + headers={"Content-Type": "text/plain; version=0.0.4"}, |
| 167 | + ) |
| 168 | + with urllib.request.urlopen(req) as resp: |
| 169 | + if resp.status not in (200, 202): |
| 170 | + print(f"ERROR: Pushgateway returned status {resp.status}") |
| 171 | + sys.exit(1) |
| 172 | +
|
| 173 | +
|
| 174 | + def main(): |
| 175 | + print("Reading cluster-metadata from mounted ConfigMap...") |
| 176 | + cm_data = read_metadata() |
| 177 | + print(f"Found {len(cm_data)} keys: {list(cm_data.keys())}") |
| 178 | +
|
| 179 | + payload = format_pushgateway_payload(cm_data) |
| 180 | + if payload is None: |
| 181 | + sys.exit(0) |
| 182 | +
|
| 183 | + print(f"Pushing cluster_metadata_info metric to {PUSHGATEWAY_URL}...") |
| 184 | + push_to_gateway(payload) |
| 185 | + print("Successfully pushed cluster metadata to Prometheus Pushgateway") |
| 186 | +
|
| 187 | +
|
| 188 | + if __name__ == "__main__": |
| 189 | + main() |
0 commit comments