Publish capacity, recovery, labor, and allocation-driver evidence for resource:railiance:apps-pg so resource-control can forecast and allocate without reading application data or inventing booked cost.
276 lines
9.3 KiB
Python
Executable file
276 lines
9.3 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Capture non-secret apps-pg capacity evidence for resource-control.
|
|
|
|
Used by RAILIANCE-WP-0016. Never prints credentials or role passwords.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
DEFAULT_REMOTE = "railiance01"
|
|
NAMESPACE = "databases"
|
|
CLUSTER = "apps-pg"
|
|
POD = "apps-pg-1"
|
|
RESOURCE_ID = "resource:railiance:apps-pg"
|
|
|
|
CONSUMERS = {
|
|
"vergabe_db": {
|
|
"name": "vergabe-teilnahme",
|
|
"role": "vergabe",
|
|
"cost_attribution_key": "platform:vergabe-teilnahme",
|
|
},
|
|
"coulomb_social_db": {
|
|
"name": "coulomb-social",
|
|
"role": "coulomb_social",
|
|
"cost_attribution_key": "platform:coulomb-social",
|
|
},
|
|
}
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
|
|
parser.add_argument("--remote", default=DEFAULT_REMOTE)
|
|
parser.add_argument("-o", "--output")
|
|
return parser.parse_args()
|
|
|
|
|
|
def ssh_run(remote: str, remote_cmd: str) -> str:
|
|
cmd = ["ssh", "-o", "BatchMode=yes", remote, remote_cmd]
|
|
result = subprocess.run(cmd, check=False, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"{remote_cmd} failed: {(result.stderr or '').strip()}")
|
|
return result.stdout
|
|
|
|
|
|
def ssh_kubectl(remote: str, *args: str) -> str:
|
|
return ssh_run(remote, "kubectl " + " ".join(shlex.quote(arg) for arg in args))
|
|
|
|
|
|
def ssh_psql(remote: str, sql: str) -> str:
|
|
remote_cmd = (
|
|
"kubectl exec -n {ns} {pod} -c postgres -- "
|
|
"psql -U postgres -d postgres -At -F , -c {sql}"
|
|
).format(ns=shlex.quote(NAMESPACE), pod=shlex.quote(POD), sql=shlex.quote(sql))
|
|
return ssh_run(remote, remote_cmd)
|
|
|
|
|
|
def as_int(value: str) -> int:
|
|
return int(value.strip())
|
|
|
|
|
|
def collect(remote: str) -> dict[str, Any]:
|
|
cluster = json.loads(
|
|
ssh_kubectl(remote, "get", "cluster", CLUSTER, "-n", NAMESPACE, "-o", "json")
|
|
)
|
|
pvc = json.loads(
|
|
ssh_kubectl(
|
|
remote,
|
|
"get",
|
|
"pvc",
|
|
"-n",
|
|
NAMESPACE,
|
|
"-l",
|
|
f"cnpg.io/cluster={CLUSTER}",
|
|
"-o",
|
|
"json",
|
|
)
|
|
)
|
|
top = ssh_kubectl(
|
|
remote, "top", "pod", "-n", NAMESPACE, "-l", f"cnpg.io/cluster={CLUSTER}", "--no-headers"
|
|
).strip()
|
|
cpu_obs, mem_obs = None, None
|
|
if top:
|
|
parts = top.split()
|
|
if len(parts) >= 3:
|
|
cpu_obs, mem_obs = parts[1], parts[2]
|
|
|
|
pgdata = ssh_kubectl(
|
|
remote,
|
|
"exec",
|
|
"-n",
|
|
NAMESPACE,
|
|
POD,
|
|
"-c",
|
|
"postgres",
|
|
"--",
|
|
"du",
|
|
"-sb",
|
|
"/var/lib/postgresql/data/pgdata",
|
|
).split()[0]
|
|
|
|
db_rows = []
|
|
for line in ssh_psql(
|
|
remote,
|
|
"SELECT datname, pg_database_size(datname) FROM pg_database "
|
|
"WHERE datistemplate = false ORDER BY 1",
|
|
).splitlines():
|
|
if not line.strip():
|
|
continue
|
|
name, size = line.split(",", 1)
|
|
db_rows.append({"name": name, "bytes": as_int(size)})
|
|
|
|
stats = {}
|
|
for line in ssh_psql(
|
|
remote,
|
|
"SELECT datname, numbackends, xact_commit, xact_rollback, blks_read, blks_hit, "
|
|
"tup_inserted, tup_updated, tup_deleted FROM pg_stat_database "
|
|
"WHERE datname IS NOT NULL AND datname NOT LIKE 'template%' ORDER BY 1",
|
|
).splitlines():
|
|
if not line.strip():
|
|
continue
|
|
cols = line.split(",")
|
|
stats[cols[0]] = {
|
|
"backends": as_int(cols[1]),
|
|
"xact_commit": as_int(cols[2]),
|
|
"xact_rollback": as_int(cols[3]),
|
|
"blks_read": as_int(cols[4]),
|
|
"blks_hit": as_int(cols[5]),
|
|
"tup_inserted": as_int(cols[6]),
|
|
"tup_updated": as_int(cols[7]),
|
|
"tup_deleted": as_int(cols[8]),
|
|
}
|
|
|
|
sessions = as_int(ssh_psql(remote, "SELECT count(*) FROM pg_stat_activity"))
|
|
max_conn = as_int(ssh_psql(remote, "SELECT current_setting('max_connections')"))
|
|
wal = ssh_psql(
|
|
remote, "SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')"
|
|
).strip()
|
|
version = ssh_psql(remote, "SELECT version()").strip()
|
|
|
|
backups = ssh_kubectl(
|
|
remote, "get", "backup,scheduledbackup", "-n", NAMESPACE, "--ignore-not-found"
|
|
).strip()
|
|
|
|
spec = cluster.get("spec") or {}
|
|
status = cluster.get("status") or {}
|
|
resources = spec.get("resources") or {}
|
|
pvc_item = (pvc.get("items") or [{}])[0]
|
|
provisioned = ((pvc_item.get("status") or {}).get("capacity") or {}).get("storage")
|
|
storage_class = (pvc_item.get("spec") or {}).get("storageClassName")
|
|
|
|
conditions = {c.get("type"): c for c in status.get("conditions") or []}
|
|
consumers = []
|
|
consumer_bytes = 0
|
|
for db in db_rows:
|
|
meta = CONSUMERS.get(db["name"])
|
|
if not meta:
|
|
continue
|
|
stat = stats.get(db["name"], {})
|
|
consumer_bytes += db["bytes"]
|
|
consumers.append(
|
|
{
|
|
"database": db["name"],
|
|
"workload": meta["name"],
|
|
"role": meta["role"],
|
|
"cost_attribution_key": meta["cost_attribution_key"],
|
|
"bytes": db["bytes"],
|
|
"xact_commit": stat.get("xact_commit", 0),
|
|
"backends": stat.get("backends", 0),
|
|
}
|
|
)
|
|
|
|
pgdata_bytes = as_int(pgdata)
|
|
residual_bytes = max(pgdata_bytes - sum(db["bytes"] for db in db_rows), 0)
|
|
for row in consumers:
|
|
row["share_of_consumer_bytes"] = (
|
|
round(row["bytes"] / consumer_bytes, 4) if consumer_bytes else None
|
|
)
|
|
|
|
return {
|
|
"schema_version": "0.1",
|
|
"record_type": "usage_observation",
|
|
"resource_id": RESOURCE_ID,
|
|
"source": "railiance-platform",
|
|
"workplan_id": "RAILIANCE-WP-0016",
|
|
"reef": "reef-railiance",
|
|
"captured_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"identity": {
|
|
"cluster": CLUSTER,
|
|
"namespace": NAMESPACE,
|
|
"pod": status.get("currentPrimary") or POD,
|
|
"image": status.get("image") or spec.get("imageName"),
|
|
"postgres_version": version,
|
|
"created_at": cluster.get("metadata", {}).get("creationTimestamp"),
|
|
"phase": status.get("phase"),
|
|
},
|
|
"capacity": {
|
|
"instances_provisioned": spec.get("instances"),
|
|
"instances_ready": status.get("readyInstances"),
|
|
"cpu_request": (resources.get("requests") or {}).get("cpu"),
|
|
"memory_request": (resources.get("requests") or {}).get("memory"),
|
|
"cpu_limit": (resources.get("limits") or {}).get("cpu"),
|
|
"memory_limit": (resources.get("limits") or {}).get("memory"),
|
|
"storage_provisioned": provisioned or spec.get("storage", {}).get("size"),
|
|
"storage_class": storage_class,
|
|
"max_connections": max_conn,
|
|
},
|
|
"utilization": {
|
|
"cpu_observed": cpu_obs,
|
|
"memory_observed": mem_obs,
|
|
"pgdata_bytes": pgdata_bytes,
|
|
"wal_bytes_since_init": as_int(wal) if wal else None,
|
|
"sessions": sessions,
|
|
"databases": [{**db, **stats.get(db["name"], {})} for db in db_rows],
|
|
},
|
|
"consumers": consumers,
|
|
"recovery": {
|
|
"cluster_ready": (conditions.get("Ready") or {}).get("status") == "True",
|
|
"continuous_archiving": (conditions.get("ContinuousArchiving") or {}).get(
|
|
"status"
|
|
)
|
|
== "True",
|
|
"scheduled_backup_present": bool(backups),
|
|
"option_a_target": False,
|
|
"restore_drill_recorded": False,
|
|
"rpo": "unbounded",
|
|
"rto": "undefined",
|
|
"failure_domain": "single-host local-path on railiance01",
|
|
},
|
|
"allocation": {
|
|
"method": "proportional",
|
|
"driver": "database_gb",
|
|
"method_version": "apps-pg-dbbytes-v1",
|
|
"consumer_bytes": consumer_bytes,
|
|
"residual_bytes": residual_bytes,
|
|
"residual_key": "platform:apps-pg-overhead",
|
|
"notes": [
|
|
"Consumer share is pg_database_size of declared consumer databases only.",
|
|
"Residual is PGDATA minus all non-template databases (WAL and catalogs).",
|
|
],
|
|
},
|
|
"provenance": {
|
|
"commands": [
|
|
f"kubectl get cluster {CLUSTER} -n {NAMESPACE} -o json",
|
|
f"kubectl top pod -n {NAMESPACE} -l cnpg.io/cluster={CLUSTER}",
|
|
f"kubectl exec -n {NAMESPACE} {POD} -c postgres -- du -sb /var/lib/postgresql/data/pgdata",
|
|
"psql non-secret catalog and pg_stat_database queries",
|
|
],
|
|
"authority": "reef-railiance Kubernetes API + local postgres catalog",
|
|
},
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
try:
|
|
payload = collect(args.remote)
|
|
except (RuntimeError, json.JSONDecodeError, ValueError) as exc:
|
|
print(f"capture failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
text = json.dumps(payload, indent=2) + "\n"
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
else:
|
|
sys.stdout.write(text)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|