mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 12:03:59 +00:00
Bringing the long-running session-feature branch back into main in one deliberate sweep. The branch carried the cluster work that's been live for weeks (cross-cluster CNPG metrics, Grafana w/ Google OAuth, supabase oauth2-proxy, cluster recovery, pg.0.knoe.dev + per-engineer onboarding, GCS-backed CNPG backups via Workload Identity, the env-contamination guard, the Junie brief queue, the cnpg-grafana CSRF + memory-request fixes from today), while main accumulated Junie's parallel knoe-auth Phase 2 OIDC work (full provider surface: discovery, authorize, token, userinfo, JWKS, RS256 signing, code exchange, session services). Key decision: the two branches did COMPETING rebrands off the same starting point (5ba9b63, 2026-04-27): - claude branch (commit b355855, earlier): org.prole.authority.* → dev.knoe.auth.* (artifact renamed to knoe-auth.jar) - main (commit9daa94b, recent): org.prole.authority.* → dev.knoe.authority.* (kept "authority" artifact name) dev.knoe.auth wins: cluster runs from this name, the Maven artifact is already knoe-auth.jar, and the broader rename is the documented namespace direction (per ~/.claude/projects/-Users-chrisfu-dev-knoe-db/ memory/MEMORY.md). All of main's recent Phase 2 OIDC content was ported from authority/src/.../dev/knoe/authority/ into authority/src/.../dev/knoe/auth/ with package declarations rewritten. == File-level resolution summary == Textual conflicts (4): authority/pom.xml - Took our artifactId="auth" - Took our branch's removal of spring-security-kerberos-client (verified: Junie's Phase 2 OIDC code does not import it; the dep was already-dead config) docs/pipeline-phases.md - Took our branch's "Phase 1 not started" status. Main had a misplaced "✅ Complete" with a knoe-auth-Phase-1 commit ref in the autobuild Phase 1 section — different domain. docs/plans/knoe-auth-round-1.md - Took our branch's dev.knoe.auth file table (vs main's dev.knoe.authority listing). Pure rename mismatch. supabase/helm/knoe-supabase/templates/kong/config.yaml - Took our branch's onboard route + plain dashboard wiring. Main had an oauth2proxy.enabled toggle that put oauth2-proxy as a Kong upstream — but the deployed architecture (commit 25f1b2e) has oauth2-proxy in FRONT of Kong, not behind. Main's wrapper reflected an architecture that was never deployed. - Took our branch's removal of basic-auth from dashboard route (queue #15 brief still tracks the matching values.yaml / kong/deployment.yaml cleanup). Java tree reconciliation (44 file-pairs): 20 dual-path source files + 2 dual-path tests Body-identical between main's authority/ and our branch's auth/ after stripping package decls — main's commit9daa94bwas a pure rebrand. Took our branch's auth/ version for all 22. 8 main-only source files (Phase 2 OIDC), ported into auth/: web/JwksController.java web/OidcAuthorizeController.java web/OidcDiscoveryController.java web/OidcTokenController.java web/OidcUserInfoController.java session/OidcCodeService.java session/OidcTokenService.java session/SessionService.java 12 main-only test files, ported into auth/: HealthControllerTest.java enroll/EnrollValueTypesTest.java enroll/EnrollmentControllerTest.java enroll/TotpServiceTest.java kerberos/KadminClientTest.java kerberos/KerberosSpnegoResultTest.java web/LoginControllerTest.java admin/AdminControllerTest.java user/PrincipalNormalizerTest.java regression/IdentityRegressionTest.java session/OidcCodeServiceTest.java session/SessionServiceTest.java Port mechanics: read main:authority/...<file> via git show, then sed rewrite `package dev.knoe.authority` → `package dev.knoe.auth` and `import dev.knoe.authority` → `import dev.knoe.auth`. Body content unchanged. authority/src/main/java/dev/knoe/authority/ — DELETED (duplicate) authority/src/test/java/dev/knoe/authority/ — DELETED (duplicate) == Verification == - grep -rln '<<<<<<<' across .java/.md/.yaml/.yml/.sh/.xml/.tpl: clean - find authority/src -path '*/dev/knoe/authority*': empty (subtree gone) - grep 'package dev.knoe.authority' across repo: clean - bash -n install.sh deploy.sh etc/preflight_kubecontext.sh: clean - git ls-files -u | wc -l: 0 unmerged paths - helm lint supabase/helm/knoe-supabase: pre-existing failure on studioIngress.enabled undefined in values.yaml (introduced by Junie on main; unrelated to this merge — flagging as follow-up). == Followups (carried into TODO ranked queue or noted here) == - helm lint failure: studioIngress block in values.yaml is missing enable flag; templates/studio/{ingress,oauth2proxy-deployment, oauth2proxy-service}.yaml all reference studioIngress.enabled with no default. Pre-existing on main; not introduced by this merge. - The five Junie briefs filed on this branch are now reachable from main at docs/plans/junie/{02,06,07,13,15}-*.md. Junie can pick them up in any order. - knoe-auth Phase 2 OIDC source (now at dev.knoe.auth.*) is not yet deployed to the cluster. Deployment is its own task. - The branch claude/crazy-bose-fec256 stays in place (worktree at .claude/worktrees/crazy-bose-fec256 may have ongoing context for Claude Code sessions). Safe to delete once next session starts cleanly from main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
419 lines
16 KiB
Python
Executable File
419 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Self-updating sync tool for the cloudnative-pg Grafana dashboard.
|
|
|
|
Designed for unattended / background-agent runs: fetches the upstream
|
|
dashboard JSON, applies the transformations declared in
|
|
`monitoring/cnpg-dashboard-transforms.yaml`, writes the result to
|
|
`knoe-db/grafana-dashboard.json` (the canonical source the
|
|
`knoe-db-grafana-dashboard` ConfigMap is built from), and optionally
|
|
applies the ConfigMap to a live cluster + reloads Grafana provisioning.
|
|
|
|
Usage:
|
|
|
|
# Default: fetch upstream, apply transforms, write to disk if changed.
|
|
# Exit 0 = no change, 1 = updated, 2 = error.
|
|
./etc/sync_cnpg_grafana_dashboard.py
|
|
|
|
# Dry run — print what would change, don't write anything.
|
|
./etc/sync_cnpg_grafana_dashboard.py --dry-run
|
|
|
|
# Drift check — exit 0 if our committed copy matches the upstream-after-
|
|
# transforms, 1 if it has drifted (newer upstream waiting to be merged
|
|
# OR a local hand-edit that the transforms don't cover). Doesn't write.
|
|
./etc/sync_cnpg_grafana_dashboard.py --check
|
|
|
|
# Apply to live cluster after writing (rolls Grafana provisioning).
|
|
./etc/sync_cnpg_grafana_dashboard.py --apply --context $APP_CLUSTER_KUBECONTEXT
|
|
|
|
Exit codes:
|
|
0 no change (output matches transformed upstream)
|
|
1 output updated (or would-be-updated in --dry-run / --check)
|
|
2 error (network, parse, transform mismatch, kubectl failure, etc.)
|
|
|
|
Background-agent run pattern (cron / k8s CronJob):
|
|
1. Run with `--check` once. If exit 1, work to do.
|
|
2. Run with `--apply --context=<ctx>` to update + roll out.
|
|
3. Optionally `git commit -m 'sync(cnpg): ...'` to persist the file change.
|
|
|
|
Transformation philosophy: edits live in
|
|
`monitoring/cnpg-dashboard-transforms.yaml`, not in this tool. Adding a
|
|
new transformation type means extending `Transformer.apply()` here AND
|
|
documenting it in the transforms YAML's preamble. Today there's just one
|
|
type, `regex_replace`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import difflib
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print(
|
|
"PyYAML required. Install via: pip install pyyaml (or apt install python3-yaml)",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(2)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Constants
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
DEFAULT_TRANSFORMS = REPO_ROOT / "monitoring" / "cnpg-dashboard-transforms.yaml"
|
|
DEFAULT_OUTPUT = REPO_ROOT / "knoe-db" / "grafana-dashboard.json"
|
|
DEFAULT_CM_NAME = "knoe-db-grafana-dashboard"
|
|
DEFAULT_GRAFANA_STS = "kps-grafana"
|
|
DEFAULT_GRAFANA_SECRET = "kps-grafana"
|
|
|
|
EXIT_NOCHANGE = 0
|
|
EXIT_CHANGED = 1
|
|
EXIT_ERROR = 2
|
|
|
|
logger = logging.getLogger("cnpg-dashboard-sync")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Helpers
|
|
|
|
def sha256_short(s: str) -> str:
|
|
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:12]
|
|
|
|
|
|
def fetch_upstream(url: str, timeout: int = 30) -> str:
|
|
"""Download the upstream dashboard JSON. Raises urllib.error on HTTP failure."""
|
|
logger.info("fetching upstream: %s", url)
|
|
req = urllib.request.Request(url, headers={"User-Agent": "knoe-cnpg-dashboard-sync/1"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
return r.read().decode("utf-8")
|
|
|
|
|
|
def load_transforms(path: Path) -> dict:
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"transforms file not found: {path}")
|
|
with path.open() as f:
|
|
data = yaml.safe_load(f)
|
|
if not isinstance(data, dict) or "source" not in data:
|
|
raise ValueError(f"transforms file missing required `source` block: {path}")
|
|
return data
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Transformer
|
|
|
|
class TransformError(Exception):
|
|
pass
|
|
|
|
|
|
class Transformer:
|
|
"""Applies an ordered list of transformations to a JSON string."""
|
|
|
|
def __init__(self, transforms: list[dict]):
|
|
self.transforms = transforms
|
|
|
|
def apply(self, content: str) -> tuple[str, list[dict]]:
|
|
"""Apply all transforms; return (transformed_content, summaries).
|
|
|
|
Each summary is a dict {name, type, matches, ...} for logging / drift
|
|
detection.
|
|
"""
|
|
summaries: list[dict] = []
|
|
for t in self.transforms:
|
|
name = t.get("name", "<unnamed>")
|
|
ttype = t.get("type")
|
|
try:
|
|
if ttype == "regex_replace":
|
|
content, summary = self._apply_regex(content, t)
|
|
elif ttype == "set_template_variable_default":
|
|
content, summary = self._apply_set_template_default(content, t)
|
|
else:
|
|
raise TransformError(f"unknown transformation type: {ttype!r}")
|
|
except TransformError:
|
|
raise
|
|
except Exception as e:
|
|
raise TransformError(f"transform {name!r} failed: {e}") from e
|
|
summary["name"] = name
|
|
summary["type"] = ttype
|
|
summaries.append(summary)
|
|
return content, summaries
|
|
|
|
@staticmethod
|
|
def _apply_regex(content: str, t: dict) -> tuple[str, dict]:
|
|
pattern = t["pattern"]
|
|
replacement = t["replacement"]
|
|
compiled = re.compile(pattern)
|
|
new_content, n = compiled.subn(replacement, content)
|
|
|
|
emin = t.get("expected_min")
|
|
emax = t.get("expected_max")
|
|
warn = None
|
|
if emin is not None and n < emin:
|
|
warn = f"matched {n} < expected_min {emin} — upstream may have moved on"
|
|
elif emax is not None and n > emax:
|
|
warn = f"matched {n} > expected_max {emax} — upstream changed shape"
|
|
|
|
return new_content, {"matches": n, "warning": warn}
|
|
|
|
@staticmethod
|
|
def _apply_set_template_default(content: str, t: dict) -> tuple[str, dict]:
|
|
"""Set the `current` value of a template variable.
|
|
|
|
Useful for pinning DS_PROMETHEUS (or any other variable) to a specific
|
|
value so the dashboard renders correctly on first load without the user
|
|
needing to override via URL parameters or the variable picker.
|
|
|
|
Spec:
|
|
type: set_template_variable_default
|
|
variable: DS_PROMETHEUS # required, name of variable
|
|
value: cnpg-prometheus # required, current.value
|
|
text: cnpg-prometheus # optional, defaults to value
|
|
selected: true # optional, defaults to true
|
|
"""
|
|
var_name = t["variable"]
|
|
value = t["value"]
|
|
text = t.get("text", value)
|
|
selected = t.get("selected", True)
|
|
|
|
data = json.loads(content)
|
|
templating = data.get("templating", {}).get("list", [])
|
|
found = False
|
|
for v in templating:
|
|
if v.get("name") == var_name:
|
|
v["current"] = {"selected": selected, "text": text, "value": value}
|
|
found = True
|
|
break
|
|
if not found:
|
|
warn = f"variable {var_name!r} not found in dashboard.templating.list"
|
|
return content, {"matches": 0, "warning": warn}
|
|
|
|
# Re-serialize with the same format the upstream JSON uses (2-space indent,
|
|
# which is what `json.dumps(..., indent=2)` produces; matches what we get
|
|
# from the upstream so diffs stay clean).
|
|
return json.dumps(data, indent=2), {"matches": 1, "warning": None}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Cluster apply
|
|
|
|
def kubectl(args: list[str], context: str, namespace: str | None = None,
|
|
stdin: bytes | None = None, capture: bool = False) -> bytes:
|
|
"""Run kubectl with explicit context (no ambient context drift)."""
|
|
cmd = ["kubectl", "--context", context]
|
|
if namespace:
|
|
cmd += ["-n", namespace]
|
|
cmd += args
|
|
logger.debug("running: %s", " ".join(cmd))
|
|
if capture:
|
|
return subprocess.check_output(cmd, input=stdin)
|
|
subprocess.run(cmd, input=stdin, check=True)
|
|
return b""
|
|
|
|
|
|
def apply_to_cluster(json_content: str, context: str, namespace: str = "monitoring",
|
|
cm_name: str = DEFAULT_CM_NAME) -> None:
|
|
"""Apply the dashboard JSON as a ConfigMap + reload Grafana provisioning.
|
|
|
|
Uses --server-side apply because the JSON is large enough to hit the 256KB
|
|
last-applied-configuration annotation limit on client-side apply.
|
|
"""
|
|
if not context:
|
|
raise ValueError("--apply requires --context (or APP_CLUSTER_KUBECONTEXT env)")
|
|
|
|
logger.info("applying ConfigMap %s/%s in context %s", namespace, cm_name, context)
|
|
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
f.write(json_content)
|
|
tmp = f.name
|
|
try:
|
|
# Render the cm yaml via `kubectl create --dry-run`
|
|
cm_yaml = kubectl(
|
|
["create", "configmap", cm_name,
|
|
f"--from-file=knoe-db.json={tmp}",
|
|
"--dry-run=client", "-o", "yaml"],
|
|
context=context, namespace=namespace, capture=True,
|
|
)
|
|
# Apply server-side
|
|
kubectl(
|
|
["apply", "--server-side", "--force-conflicts",
|
|
"--field-manager=cnpg-dashboard-sync", "-f", "-"],
|
|
context=context, namespace=namespace, stdin=cm_yaml,
|
|
)
|
|
# Sidecar discovers grafana_dashboard=1 labelled cms; ensure label is present
|
|
kubectl(
|
|
["label", "configmap", cm_name, "grafana_dashboard=1", "--overwrite"],
|
|
context=context, namespace=namespace,
|
|
)
|
|
finally:
|
|
os.unlink(tmp)
|
|
|
|
# Reload Grafana provisioning so the new dashboard JSON is picked up
|
|
# without waiting for the sidecar's poll interval.
|
|
logger.info("reloading Grafana provisioning")
|
|
admin_pw_b64 = kubectl(
|
|
["get", "secret", DEFAULT_GRAFANA_SECRET,
|
|
"-o", "jsonpath={.data.admin-password}"],
|
|
context=context, namespace=namespace, capture=True,
|
|
)
|
|
admin_pw = base64.b64decode(admin_pw_b64).decode()
|
|
kubectl(
|
|
["exec", f"sts/{DEFAULT_GRAFANA_STS}", "-c", "grafana", "--",
|
|
"wget", "-qO-", "--post-data", "",
|
|
f"http://admin:{admin_pw}@localhost:3000/api/admin/provisioning/dashboards/reload"],
|
|
context=context, namespace=namespace, capture=True,
|
|
)
|
|
logger.info("grafana provisioning reload complete")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Main
|
|
|
|
def render_diff(old: str, new: str, max_lines: int = 60) -> str:
|
|
diff = list(difflib.unified_diff(
|
|
old.splitlines(keepends=True),
|
|
new.splitlines(keepends=True),
|
|
fromfile="committed", tofile="upstream-transformed", n=2,
|
|
))
|
|
if len(diff) > max_lines:
|
|
diff = diff[:max_lines] + [f"... ({len(diff) - max_lines} more lines elided)\n"]
|
|
return "".join(diff)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
p.add_argument("--transforms", type=Path, default=DEFAULT_TRANSFORMS,
|
|
help="Path to the transforms YAML (default: %(default)s)")
|
|
p.add_argument("--output", type=Path, default=DEFAULT_OUTPUT,
|
|
help="Path to write the transformed JSON (default: %(default)s)")
|
|
mode = p.add_mutually_exclusive_group()
|
|
mode.add_argument("--dry-run", action="store_true",
|
|
help="Show what would change; don't write or apply.")
|
|
mode.add_argument("--check", action="store_true",
|
|
help="Exit 1 if output is drifted from upstream-transformed; don't write.")
|
|
p.add_argument("--apply", action="store_true",
|
|
help="After writing, apply the ConfigMap to the live cluster and reload Grafana.")
|
|
p.add_argument("--context", default=os.environ.get("APP_CLUSTER_KUBECONTEXT", ""),
|
|
help="kubectl context for --apply (default: $APP_CLUSTER_KUBECONTEXT)")
|
|
p.add_argument("--namespace", default="monitoring",
|
|
help="ConfigMap namespace for --apply (default: %(default)s)")
|
|
p.add_argument("--show-diff", action="store_true",
|
|
help="Print a unified diff of committed-vs-transformed.")
|
|
p.add_argument("-v", "--verbose", action="store_true",
|
|
help="Verbose logging.")
|
|
args = p.parse_args(argv)
|
|
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
|
|
try:
|
|
config = load_transforms(args.transforms)
|
|
except (FileNotFoundError, ValueError) as e:
|
|
logger.error("failed to load transforms: %s", e)
|
|
return EXIT_ERROR
|
|
|
|
src = config["source"]
|
|
upstream_url = src["url"]
|
|
expected_uid = src.get("expected_uid")
|
|
transforms = config.get("transformations", [])
|
|
|
|
# 1. Fetch
|
|
try:
|
|
upstream = fetch_upstream(upstream_url)
|
|
except urllib.error.URLError as e:
|
|
logger.error("upstream fetch failed: %s", e)
|
|
return EXIT_ERROR
|
|
logger.info("upstream: sha256=%s, %d bytes", sha256_short(upstream), len(upstream))
|
|
|
|
# 2. Validate UID (catch silent renames)
|
|
if expected_uid:
|
|
try:
|
|
uid = json.loads(upstream).get("uid")
|
|
except json.JSONDecodeError as e:
|
|
logger.error("upstream is not valid JSON: %s", e)
|
|
return EXIT_ERROR
|
|
if uid != expected_uid:
|
|
logger.error("upstream uid %r != expected %r — refusing to proceed; "
|
|
"dashboard URLs would silently break. Update transforms YAML if intentional.",
|
|
uid, expected_uid)
|
|
return EXIT_ERROR
|
|
|
|
# 3. Transform
|
|
try:
|
|
transformed, summaries = Transformer(transforms).apply(upstream)
|
|
except TransformError as e:
|
|
logger.error("transform failed: %s", e)
|
|
return EXIT_ERROR
|
|
|
|
logger.info("applied %d transformation(s):", len(summaries))
|
|
saw_warning = False
|
|
for s in summaries:
|
|
msg = f" - {s['name']} ({s['type']}): {s.get('matches', '?')} match(es)"
|
|
if s.get("warning"):
|
|
msg += f" [WARNING: {s['warning']}]"
|
|
saw_warning = True
|
|
logger.info(msg)
|
|
logger.info("transformed: sha256=%s, %d bytes", sha256_short(transformed), len(transformed))
|
|
|
|
# 4. Compare to existing
|
|
existing = args.output.read_text() if args.output.exists() else ""
|
|
drifted = (existing != transformed)
|
|
|
|
if args.show_diff and drifted:
|
|
logger.info("DIFF (committed vs transformed-upstream):")
|
|
sys.stderr.write(render_diff(existing, transformed))
|
|
|
|
# 5. Act per mode
|
|
if args.check:
|
|
if drifted:
|
|
logger.warning("DRIFT: %s differs from upstream after transforms (run without --check to update)", args.output)
|
|
return EXIT_CHANGED
|
|
logger.info("OK: %s matches upstream after transforms", args.output)
|
|
return EXIT_ERROR if saw_warning else EXIT_NOCHANGE
|
|
|
|
if args.dry_run:
|
|
if drifted:
|
|
logger.info("WOULD UPDATE: %s (run without --dry-run to apply)", args.output)
|
|
return EXIT_CHANGED
|
|
logger.info("NO CHANGE: %s", args.output)
|
|
return EXIT_ERROR if saw_warning else EXIT_NOCHANGE
|
|
|
|
# Default mode: write if drifted, optionally apply
|
|
if drifted:
|
|
args.output.write_text(transformed)
|
|
logger.info("wrote %s", args.output)
|
|
rc = EXIT_CHANGED
|
|
else:
|
|
logger.info("no change to %s", args.output)
|
|
rc = EXIT_NOCHANGE
|
|
|
|
if args.apply:
|
|
try:
|
|
apply_to_cluster(transformed, context=args.context, namespace=args.namespace)
|
|
except (subprocess.CalledProcessError, ValueError) as e:
|
|
logger.error("cluster apply failed: %s", e)
|
|
return EXIT_ERROR
|
|
|
|
return EXIT_ERROR if saw_warning else rc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|