From 31bf105d12dab17b08adc1fb99caa71810b79837 Mon Sep 17 00:00:00 2001 From: Nicholas Kuechler Date: Thu, 18 Jun 2026 10:54:03 -0500 Subject: [PATCH] feat(metadata): Adds site deploy metadata to cluster-metadata ConfigMap --- .../application-cluster-metadata.yaml | 27 +++ .../application-prometheus-pushgateway.yaml | 36 ++++ charts/argocd-understack/values.yaml | 12 ++ ...onfigmap-cluster-metadata-sync-script.yaml | 189 ++++++++++++++++++ .../job-sync-cluster-metadata.yaml | 64 ++++++ .../cluster-metadata/kustomization.yaml | 7 + operators/prometheus-pushgateway/values.yaml | 4 + 7 files changed, 339 insertions(+) create mode 100644 charts/argocd-understack/templates/application-cluster-metadata.yaml create mode 100644 charts/argocd-understack/templates/application-prometheus-pushgateway.yaml create mode 100644 components/cluster-metadata/configmap-cluster-metadata-sync-script.yaml create mode 100644 components/cluster-metadata/job-sync-cluster-metadata.yaml create mode 100644 components/cluster-metadata/kustomization.yaml create mode 100644 operators/prometheus-pushgateway/values.yaml diff --git a/charts/argocd-understack/templates/application-cluster-metadata.yaml b/charts/argocd-understack/templates/application-cluster-metadata.yaml new file mode 100644 index 000000000..a583547c0 --- /dev/null +++ b/charts/argocd-understack/templates/application-cluster-metadata.yaml @@ -0,0 +1,27 @@ +{{- if eq (include "understack.isEnabled" (list $.Values.site "cluster_metadata")) "true" }} +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ printf "%s-%s" $.Release.Name "cluster-metadata" }} + finalizers: + - resources-finalizer.argocd.argoproj.io +{{- include "understack.appLabelsBlock" $ | nindent 2 }} +spec: + destination: + namespace: openstack + server: {{ $.Values.cluster_server }} + project: understack + sources: + - path: components/cluster-metadata + repoURL: {{ include "understack.understack_url" $ }} + targetRevision: {{ include "understack.understack_ref" $ }} + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - ServerSideApply=true + - RespectIgnoreDifferences=true + - ApplyOutOfSyncOnly=true +{{- end }} diff --git a/charts/argocd-understack/templates/application-prometheus-pushgateway.yaml b/charts/argocd-understack/templates/application-prometheus-pushgateway.yaml new file mode 100644 index 000000000..1f2a4e135 --- /dev/null +++ b/charts/argocd-understack/templates/application-prometheus-pushgateway.yaml @@ -0,0 +1,36 @@ +{{- if or (eq (include "understack.isEnabled" (list $.Values.global "prometheus_pushgateway")) "true") (eq (include "understack.isEnabled" (list $.Values.site "prometheus_pushgateway")) "true") }} +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ printf "%s-%s" $.Release.Name "prometheus-pushgateway" }} + annotations: + argocd.argoproj.io/compare-options: ServerSideDiff=true,IncludeMutationWebhook=true +{{- include "understack.appLabelsBlock" $ | nindent 2 }} +spec: + destination: + namespace: monitoring + server: {{ $.Values.cluster_server }} + project: understack-operators + sources: + - chart: prometheus-pushgateway + helm: + ignoreMissingValueFiles: true + releaseName: prometheus-pushgateway + valueFiles: + - $understack/operators/prometheus-pushgateway/values.yaml + repoURL: https://prometheus-community.github.io/helm-charts + targetRevision: 3.2.0 + - ref: understack + repoURL: {{ include "understack.understack_url" $ }} + targetRevision: {{ include "understack.understack_ref" $ }} + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ServerSideApply=true + - RespectIgnoreDifferences=true + - ApplyOutOfSyncOnly=true +{{- end }} diff --git a/charts/argocd-understack/values.yaml b/charts/argocd-understack/values.yaml index 1b107505a..61ff70234 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -508,6 +508,18 @@ site: # @default -- false enabled: false + # -- Cluster metadata sync to Prometheus via Pushgateway + cluster_metadata: + # -- Enable/disable syncing cluster-metadata ConfigMap to Prometheus + # @default -- true + enabled: true + + # -- Prometheus Pushgateway for batch job metrics + prometheus_pushgateway: + # -- Enable/disable deploying Prometheus Pushgateway + # @default -- true + enabled: true + # -- OpenEBS openebs: # -- Enable/disable deploying OpenEBS diff --git a/components/cluster-metadata/configmap-cluster-metadata-sync-script.yaml b/components/cluster-metadata/configmap-cluster-metadata-sync-script.yaml new file mode 100644 index 000000000..827dec7fd --- /dev/null +++ b/components/cluster-metadata/configmap-cluster-metadata-sync-script.yaml @@ -0,0 +1,189 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cluster-metadata-sync-script + namespace: openstack + labels: + app.kubernetes.io/name: cluster-metadata + app.kubernetes.io/component: prometheus-sync +data: + sync-cluster-metadata.py: | + #!/usr/bin/env python3 + """Sync cluster-metadata ConfigMap data to Prometheus Pushgateway. + + Reads the cluster-metadata ConfigMap mounted as files and pushes + its key/value pairs as labels on an info-style gauge metric to the + Prometheus Pushgateway. This makes cluster identity information + (partition, site, etc.) available for Prometheus queries and joins. + + Files ending in .yaml/.yml are parsed and their top-level scalar + keys are flattened into individual labels. Nested structures are + skipped since Prometheus labels must be simple strings. + """ + + import os + import sys + import urllib.request + + # PyYAML is not available in the slim image, so we use a minimal + # subset parser for simple key: value YAML files. + import json + import re + + METADATA_DIR = "/etc/cluster-metadata" + PUSHGATEWAY_URL = os.environ.get( + "PUSHGATEWAY_URL", + "http://prometheus-pushgateway.monitoring.svc.cluster.local:9091", + ) + METRIC_JOB_NAME = os.environ.get("METRIC_JOB_NAME", "cluster_metadata") + + + def parse_yaml_scalars(content, prefix=""): + """Extract key/value pairs from a YAML file, flattening nested mappings. + + Top-level scalar values become labels directly. + Nested mappings get their parent key prepended as a prefix + (e.g., site.argo_events.enabled -> site_argo_events_enabled). + """ + results = {} + lines = content.splitlines() + i = 0 + # Track indentation-based hierarchy + stack = [] # list of (indent_level, key_prefix) + + while i < len(lines): + line = lines[i] + stripped = line.rstrip() + if not stripped or stripped.lstrip().startswith("#") or stripped.strip() == "---": + i += 1 + continue + + # Determine indentation level + indent = len(line) - len(line.lstrip()) + + # Pop stack entries that are at same or deeper indent + while stack and stack[-1][0] >= indent: + stack.pop() + + # Build current prefix from stack + current_prefix = "_".join(s[1] for s in stack) + if current_prefix and prefix: + current_prefix = prefix + "_" + current_prefix + elif prefix: + current_prefix = prefix + # If no stack and no prefix, current_prefix stays "" + + match = re.match(r"^(\s*)([A-Za-z_][A-Za-z0-9_.\-]*)\s*:\s*(.*)", line) + if match: + key = match.group(2) + value = match.group(3).strip() + + # If value is empty, it's a mapping parent — push onto stack + if value == "" or value == "|" or value == ">": + stack.append((indent, key)) + i += 1 + continue + + # Strip inline comments + if " #" in value: + value = value[:value.index(" #")].rstrip() + + # Strip surrounding quotes if present + if (value.startswith("'") and value.endswith("'")) or \ + (value.startswith('"') and value.endswith('"')): + value = value[1:-1] + + full_key = f"{current_prefix}_{key}" if current_prefix else key + results[full_key] = value + i += 1 + return results + + + def read_metadata(): + """Read all key/value pairs from the mounted ConfigMap directory. + + Plain files become a single key=value pair. + YAML files (.yaml/.yml) are parsed and top-level scalar keys are + extracted as individual entries. + """ + data = {} + for filename in os.listdir(METADATA_DIR): + filepath = os.path.join(METADATA_DIR, filename) + if not os.path.isfile(filepath) or filename.startswith(".."): + continue + with open(filepath) as f: + content = f.read().strip() + + if filename.endswith((".yaml", ".yml")): + # Parse YAML and flatten all scalar keys with hierarchy as prefix + scalars = parse_yaml_scalars(content) + for key, value in scalars.items(): + data[key] = value + else: + data[filename] = content + return data + + + def format_pushgateway_payload(cm_data): + """Format ConfigMap data as Prometheus exposition format. + + Creates an info-style gauge metric 'cluster_metadata_info' with value 1 + and all ConfigMap keys as labels. + """ + labels = {} + for key, value in cm_data.items(): + label_name = key.lower().replace("-", "_").replace(".", "_") + label_value = ( + value.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace('"', '\\"') + ) + labels[label_name] = label_value + + if not labels: + print("WARNING: ConfigMap has no data keys, nothing to push") + return None + + label_str = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items())) + lines = [ + "# HELP cluster_metadata_info Cluster metadata from ConfigMap as labels.", + "# TYPE cluster_metadata_info gauge", + f"cluster_metadata_info{{{label_str}}} 1", + "", + ] + return "\n".join(lines) + + + def push_to_gateway(payload): + """Push metrics payload to the Prometheus Pushgateway.""" + url = f"{PUSHGATEWAY_URL}/metrics/job/{METRIC_JOB_NAME}" + data = payload.encode("utf-8") + req = urllib.request.Request( + url, + data=data, + method="PUT", + headers={"Content-Type": "text/plain; version=0.0.4"}, + ) + with urllib.request.urlopen(req) as resp: + if resp.status not in (200, 202): + print(f"ERROR: Pushgateway returned status {resp.status}") + sys.exit(1) + + + def main(): + print("Reading cluster-metadata from mounted ConfigMap...") + cm_data = read_metadata() + print(f"Found {len(cm_data)} keys: {list(cm_data.keys())}") + + payload = format_pushgateway_payload(cm_data) + if payload is None: + sys.exit(0) + + print(f"Pushing cluster_metadata_info metric to {PUSHGATEWAY_URL}...") + push_to_gateway(payload) + print("Successfully pushed cluster metadata to Prometheus Pushgateway") + + + if __name__ == "__main__": + main() diff --git a/components/cluster-metadata/job-sync-cluster-metadata.yaml b/components/cluster-metadata/job-sync-cluster-metadata.yaml new file mode 100644 index 000000000..768b3e4b6 --- /dev/null +++ b/components/cluster-metadata/job-sync-cluster-metadata.yaml @@ -0,0 +1,64 @@ +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: sync-cluster-metadata-to-prometheus + namespace: openstack + labels: + app.kubernetes.io/name: cluster-metadata + app.kubernetes.io/component: prometheus-sync + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/sync-wave: "5" + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + backoffLimit: 3 + ttlSecondsAfterFinished: 3600 + template: + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65534 + fsGroup: 65534 + seccompProfile: + type: RuntimeDefault + restartPolicy: OnFailure + containers: + - name: sync-metadata + image: python:3-slim + imagePullPolicy: IfNotPresent + command: + - python + - /scripts/sync-cluster-metadata.py + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + cpu: "200m" + memory: "128Mi" + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + env: + - name: PUSHGATEWAY_URL + value: http://prometheus-pushgateway.monitoring.svc.cluster.local:9091 + - name: METRIC_JOB_NAME + value: cluster_metadata + volumeMounts: + - name: scripts + mountPath: /scripts + readOnly: true + - name: cluster-metadata + mountPath: /etc/cluster-metadata + readOnly: true + volumes: + - name: scripts + configMap: + name: cluster-metadata-sync-script + - name: cluster-metadata + configMap: + name: cluster-metadata diff --git a/components/cluster-metadata/kustomization.yaml b/components/cluster-metadata/kustomization.yaml new file mode 100644 index 000000000..66089512f --- /dev/null +++ b/components/cluster-metadata/kustomization.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - configmap-cluster-metadata-sync-script.yaml + - job-sync-cluster-metadata.yaml diff --git a/operators/prometheus-pushgateway/values.yaml b/operators/prometheus-pushgateway/values.yaml new file mode 100644 index 000000000..33793812e --- /dev/null +++ b/operators/prometheus-pushgateway/values.yaml @@ -0,0 +1,4 @@ +serviceMonitor: + enabled: true + additionalLabels: + release: monitoring