feat(ops): automate metrics edge controlled apply
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
56
scripts/ops/apply_metrics_edge_policy.py
Executable file
56
scripts/ops/apply_metrics_edge_policy.py
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check or apply the source-owned EwoooC metrics edge policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from services.metrics_edge_controlled_apply_service import ( # noqa: E402
|
||||
apply_metrics_edge_plan,
|
||||
build_metrics_edge_plan,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--nginx-config", type=Path, required=True)
|
||||
parser.add_argument("--prometheus-config", type=Path, required=True)
|
||||
parser.add_argument("--nginx-fragment", type=Path, required=True)
|
||||
parser.add_argument("--prometheus-snippet", type=Path, required=True)
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--work-item-id", default="SEC-P0-001")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--canary-timeout", type=int, default=90)
|
||||
parser.add_argument("--receipt", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
plan = build_metrics_edge_plan(
|
||||
nginx_config=args.nginx_config,
|
||||
prometheus_config=args.prometheus_config,
|
||||
nginx_fragment=args.nginx_fragment,
|
||||
prometheus_snippet=args.prometheus_snippet,
|
||||
run_id=args.run_id,
|
||||
work_item_id=args.work_item_id,
|
||||
)
|
||||
payload = apply_metrics_edge_plan(
|
||||
plan,
|
||||
canary_timeout=args.canary_timeout,
|
||||
) if args.apply else {key: value for key, value in plan.items() if not key.startswith("_")}
|
||||
output = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
if args.receipt:
|
||||
args.receipt.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.receipt.write_text(f"{output}\n", encoding="utf-8")
|
||||
print(output)
|
||||
return 0 if payload.get("status") not in {"rolled_back", "rollback_degraded"} else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
297
services/metrics_edge_controlled_apply_service.py
Normal file
297
services/metrics_edge_controlled_apply_service.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Controlled apply for the EwoooC metrics edge and Prometheus target."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
POLICY = "ewoooc_metrics_edge_controlled_apply_v1"
|
||||
NGINX_BEGIN = "# BEGIN MANAGED EWOOOC METRICS"
|
||||
NGINX_END = "# END MANAGED EWOOOC METRICS"
|
||||
PROMETHEUS_BEGIN = "# BEGIN MANAGED EWOOOC METRICS"
|
||||
PROMETHEUS_END = "# END MANAGED EWOOOC METRICS"
|
||||
IDENTITY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$")
|
||||
|
||||
|
||||
def _managed_replace(text: str, begin: str, end: str, managed: str) -> str | None:
|
||||
begin_count = text.count(begin)
|
||||
end_count = text.count(end)
|
||||
if begin_count == 0 and end_count == 0:
|
||||
return None
|
||||
if begin_count != 1 or end_count != 1:
|
||||
raise ValueError(f"invalid managed marker count for {begin}")
|
||||
start = text.index(begin)
|
||||
finish = text.index(end, start) + len(end)
|
||||
return f"{text[:start]}{managed}{text[finish:]}"
|
||||
|
||||
|
||||
def _nginx_server_blocks(lines: list[str]) -> list[tuple[int, int]]:
|
||||
blocks: list[tuple[int, int]] = []
|
||||
start: int | None = None
|
||||
depth = 0
|
||||
for index, line in enumerate(lines):
|
||||
code = line.split("#", 1)[0]
|
||||
if start is None and re.match(r"^\s*server\s*\{", code):
|
||||
start = index
|
||||
depth = 0
|
||||
if start is None:
|
||||
continue
|
||||
depth += code.count("{") - code.count("}")
|
||||
if depth == 0:
|
||||
blocks.append((start, index + 1))
|
||||
start = None
|
||||
if start is not None:
|
||||
raise ValueError("unbalanced nginx server block")
|
||||
return blocks
|
||||
|
||||
|
||||
def render_nginx_config(config_text: str, fragment_text: str) -> str:
|
||||
fragment = fragment_text.strip()
|
||||
if "location = /metrics" not in fragment or "deny all;" not in fragment:
|
||||
raise ValueError("metrics nginx fragment is incomplete")
|
||||
indented_fragment = "\n".join(f" {line}" if line else "" for line in fragment.splitlines())
|
||||
managed = f"{NGINX_BEGIN}\n{indented_fragment}\n {NGINX_END}"
|
||||
replaced = _managed_replace(config_text, NGINX_BEGIN, NGINX_END, managed)
|
||||
if replaced is not None:
|
||||
return replaced
|
||||
|
||||
lines = config_text.splitlines(keepends=True)
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for start, end in _nginx_server_blocks(lines):
|
||||
block = "".join(lines[start:end])
|
||||
if "server_name mo.wooo.work;" in block and re.search(r"\blisten\s+443\b", block):
|
||||
candidates.append((start, end))
|
||||
if len(candidates) != 1:
|
||||
raise ValueError(f"expected one mo.wooo.work TLS server, found {len(candidates)}")
|
||||
|
||||
start, end = candidates[0]
|
||||
location_indexes = [
|
||||
index
|
||||
for index in range(start, end)
|
||||
if re.match(r"^\s*location\s+/\s*\{", lines[index])
|
||||
]
|
||||
if len(location_indexes) != 1:
|
||||
raise ValueError(f"expected one fallback location, found {len(location_indexes)}")
|
||||
insert_at = location_indexes[0]
|
||||
lines.insert(insert_at, f" {managed}\n\n")
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def render_prometheus_config(config_text: str, snippet_text: str) -> str:
|
||||
snippet = snippet_text.rstrip()
|
||||
if "job_name: 'ewoooc-app'" not in snippet or "metrics_path: /metrics" not in snippet:
|
||||
raise ValueError("EwoooC Prometheus snippet is incomplete")
|
||||
if not re.search(r"(?m)^scrape_configs:\s*$", config_text):
|
||||
raise ValueError("Prometheus config has no scrape_configs section")
|
||||
managed = f"{PROMETHEUS_BEGIN}\n{snippet}\n{PROMETHEUS_END}"
|
||||
replaced = _managed_replace(
|
||||
config_text,
|
||||
PROMETHEUS_BEGIN,
|
||||
PROMETHEUS_END,
|
||||
managed,
|
||||
)
|
||||
if replaced is not None:
|
||||
return replaced
|
||||
return f"{config_text.rstrip()}\n\n{managed}\n"
|
||||
|
||||
|
||||
def _sha256(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def build_metrics_edge_plan(
|
||||
*,
|
||||
nginx_config: Path,
|
||||
prometheus_config: Path,
|
||||
nginx_fragment: Path,
|
||||
prometheus_snippet: Path,
|
||||
run_id: str,
|
||||
work_item_id: str,
|
||||
) -> dict[str, Any]:
|
||||
if not IDENTITY_PATTERN.fullmatch(run_id) or not IDENTITY_PATTERN.fullmatch(work_item_id):
|
||||
raise ValueError("run_id and work_item_id must use bounded safe characters")
|
||||
nginx_before = nginx_config.read_text(encoding="utf-8")
|
||||
prometheus_before = prometheus_config.read_text(encoding="utf-8")
|
||||
nginx_after = render_nginx_config(
|
||||
nginx_before,
|
||||
nginx_fragment.read_text(encoding="utf-8"),
|
||||
)
|
||||
prometheus_after = render_prometheus_config(
|
||||
prometheus_before,
|
||||
prometheus_snippet.read_text(encoding="utf-8"),
|
||||
)
|
||||
return {
|
||||
"policy": POLICY,
|
||||
"trace_id": run_id,
|
||||
"run_id": run_id,
|
||||
"work_item_id": work_item_id,
|
||||
"mode": "check",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"nginx": {
|
||||
"path": str(nginx_config),
|
||||
"before_sha256": _sha256(nginx_before),
|
||||
"candidate_sha256": _sha256(nginx_after),
|
||||
"changed": nginx_before != nginx_after,
|
||||
},
|
||||
"prometheus": {
|
||||
"path": str(prometheus_config),
|
||||
"before_sha256": _sha256(prometheus_before),
|
||||
"candidate_sha256": _sha256(prometheus_after),
|
||||
"changed": prometheus_before != prometheus_after,
|
||||
},
|
||||
"_candidate_text": {
|
||||
"nginx": nginx_after,
|
||||
"prometheus": prometheus_after,
|
||||
},
|
||||
"controlled_apply_allowed": True,
|
||||
"risk": "medium",
|
||||
}
|
||||
|
||||
|
||||
def _run(command: list[str], timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
message = (completed.stderr or completed.stdout or "command failed").strip()
|
||||
raise RuntimeError(f"{command[0]} failed: {message[:500]}")
|
||||
return completed
|
||||
|
||||
|
||||
def _atomic_write(path: Path, content: str) -> None:
|
||||
current = path.stat()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
delete=False,
|
||||
) as handle:
|
||||
handle.write(content)
|
||||
temporary = Path(handle.name)
|
||||
os.chmod(temporary, current.st_mode)
|
||||
os.chown(temporary, current.st_uid, current.st_gid)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def _prometheus_target_health(timeout_seconds: int) -> str:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
url = "http://127.0.0.1:9090/api/v1/targets?state=active"
|
||||
last_health = "missing"
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=8) as response:
|
||||
payload = json.load(response)
|
||||
targets = ((payload.get("data") or {}).get("activeTargets") or [])
|
||||
matching = [
|
||||
target for target in targets
|
||||
if (target.get("labels") or {}).get("job") == "ewoooc-app"
|
||||
]
|
||||
if matching:
|
||||
last_health = str(matching[0].get("health") or "unknown")
|
||||
if last_health == "up":
|
||||
return last_health
|
||||
except (OSError, ValueError, TypeError):
|
||||
last_health = "query_error"
|
||||
time.sleep(3)
|
||||
return last_health
|
||||
|
||||
|
||||
def apply_metrics_edge_plan(plan: dict[str, Any], *, canary_timeout: int = 90) -> dict[str, Any]:
|
||||
candidate = plan.pop("_candidate_text")
|
||||
nginx_path = Path(plan["nginx"]["path"])
|
||||
prometheus_path = Path(plan["prometheus"]["path"])
|
||||
suffix = re.sub(r"[^A-Za-z0-9_.-]", "_", plan["run_id"])
|
||||
nginx_backup = nginx_path.with_name(f"{nginx_path.name}.bak.{suffix}")
|
||||
prometheus_backup = prometheus_path.with_name(f"{prometheus_path.name}.bak.{suffix}")
|
||||
shutil.copy2(nginx_path, nginx_backup)
|
||||
shutil.copy2(prometheus_path, prometheus_backup)
|
||||
receipt = {
|
||||
key: value for key, value in plan.items()
|
||||
if not key.startswith("_")
|
||||
}
|
||||
receipt.update({
|
||||
"receipt_kind": "metrics_edge_controlled_apply_receipt",
|
||||
"mode": "apply",
|
||||
"status": "in_progress",
|
||||
"backup_paths": {
|
||||
"nginx": str(nginx_backup),
|
||||
"prometheus": str(prometheus_backup),
|
||||
},
|
||||
"rollback_performed": False,
|
||||
"post_verifier": {},
|
||||
})
|
||||
try:
|
||||
_atomic_write(nginx_path, candidate["nginx"])
|
||||
_atomic_write(prometheus_path, candidate["prometheus"])
|
||||
_run(["nginx", "-t"])
|
||||
_run([
|
||||
"docker", "exec", "prometheus", "promtool", "check", "config",
|
||||
"/etc/prometheus/prometheus.yml",
|
||||
])
|
||||
_run(["systemctl", "reload", "nginx"])
|
||||
_run(["docker", "kill", "--signal=SIGHUP", "prometheus"])
|
||||
internal = _run([
|
||||
"curl", "-ksS", "--max-time", "20", "--resolve",
|
||||
"mo.wooo.work:443:127.0.0.1", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"https://mo.wooo.work/metrics",
|
||||
])
|
||||
internal_code = internal.stdout.strip()
|
||||
target_health = _prometheus_target_health(max(10, canary_timeout))
|
||||
receipt["post_verifier"] = {
|
||||
"nginx_config_valid": True,
|
||||
"prometheus_config_valid": True,
|
||||
"internal_metrics_http_code": internal_code,
|
||||
"metrics_internal_canary": internal_code == "200",
|
||||
"metrics_target_health": target_health,
|
||||
}
|
||||
if internal_code != "200" or target_health != "up":
|
||||
raise RuntimeError(
|
||||
f"post-verifier failed: internal={internal_code}, target={target_health}"
|
||||
)
|
||||
receipt["status"] = "pass"
|
||||
receipt["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
return receipt
|
||||
except Exception as exc:
|
||||
shutil.copy2(nginx_backup, nginx_path)
|
||||
shutil.copy2(prometheus_backup, prometheus_path)
|
||||
rollback_errors: list[str] = []
|
||||
for command in (
|
||||
["nginx", "-t"],
|
||||
["systemctl", "reload", "nginx"],
|
||||
["docker", "kill", "--signal=SIGHUP", "prometheus"],
|
||||
):
|
||||
try:
|
||||
_run(command)
|
||||
except Exception as rollback_exc:
|
||||
rollback_errors.append(str(rollback_exc))
|
||||
receipt["status"] = "rolled_back" if not rollback_errors else "rollback_degraded"
|
||||
receipt["rollback_performed"] = True
|
||||
receipt["error"] = str(exc)
|
||||
receipt["rollback_errors"] = rollback_errors
|
||||
receipt["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
return receipt
|
||||
|
||||
|
||||
__all__ = [
|
||||
"POLICY",
|
||||
"apply_metrics_edge_plan",
|
||||
"build_metrics_edge_plan",
|
||||
"render_nginx_config",
|
||||
"render_prometheus_config",
|
||||
]
|
||||
103
tests/test_metrics_edge_controlled_apply_service.py
Normal file
103
tests/test_metrics_edge_controlled_apply_service.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
NGINX_CONFIG = """server {
|
||||
listen 80;
|
||||
server_name mo.wooo.work;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name mo.wooo.work;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5003;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
NGINX_FRAGMENT = """location = /metrics {
|
||||
allow 127.0.0.1;
|
||||
allow 172.16.0.0/12;
|
||||
deny all;
|
||||
proxy_pass http://127.0.0.1:5003/metrics;
|
||||
}
|
||||
"""
|
||||
|
||||
PROMETHEUS_CONFIG = """global:
|
||||
scrape_interval: 60s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: existing
|
||||
static_configs:
|
||||
- targets: ['127.0.0.1:9090']
|
||||
"""
|
||||
|
||||
PROMETHEUS_SNIPPET = """ - job_name: 'ewoooc-app'
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ['mo.wooo.work']
|
||||
"""
|
||||
|
||||
|
||||
def test_render_nginx_config_targets_only_tls_server_and_is_idempotent():
|
||||
from services.metrics_edge_controlled_apply_service import render_nginx_config
|
||||
|
||||
rendered = render_nginx_config(NGINX_CONFIG, NGINX_FRAGMENT)
|
||||
second = render_nginx_config(rendered, NGINX_FRAGMENT)
|
||||
|
||||
assert rendered == second
|
||||
assert rendered.count("location = /metrics") == 1
|
||||
assert rendered.index("listen 443") < rendered.index("location = /metrics")
|
||||
assert rendered.index("location = /metrics") < rendered.index("location / {")
|
||||
|
||||
|
||||
def test_render_prometheus_config_is_idempotent():
|
||||
from services.metrics_edge_controlled_apply_service import render_prometheus_config
|
||||
|
||||
rendered = render_prometheus_config(PROMETHEUS_CONFIG, PROMETHEUS_SNIPPET)
|
||||
second = render_prometheus_config(rendered, PROMETHEUS_SNIPPET)
|
||||
|
||||
assert rendered == second
|
||||
assert rendered.count("job_name: 'ewoooc-app'") == 1
|
||||
|
||||
|
||||
def test_build_plan_is_no_write_and_commit_safe(tmp_path: Path):
|
||||
from services.metrics_edge_controlled_apply_service import build_metrics_edge_plan
|
||||
|
||||
paths = {
|
||||
"nginx_config": tmp_path / "nginx.conf",
|
||||
"prometheus_config": tmp_path / "prometheus.yml",
|
||||
"nginx_fragment": tmp_path / "location.conf",
|
||||
"prometheus_snippet": tmp_path / "scrape.yml",
|
||||
}
|
||||
paths["nginx_config"].write_text(NGINX_CONFIG, encoding="utf-8")
|
||||
paths["prometheus_config"].write_text(PROMETHEUS_CONFIG, encoding="utf-8")
|
||||
paths["nginx_fragment"].write_text(NGINX_FRAGMENT, encoding="utf-8")
|
||||
paths["prometheus_snippet"].write_text(PROMETHEUS_SNIPPET, encoding="utf-8")
|
||||
|
||||
plan = build_metrics_edge_plan(
|
||||
**paths,
|
||||
run_id="sec-p0-001-test",
|
||||
work_item_id="SEC-P0-001",
|
||||
)
|
||||
|
||||
assert plan["mode"] == "check"
|
||||
assert plan["nginx"]["changed"] is True
|
||||
assert plan["prometheus"]["changed"] is True
|
||||
assert paths["nginx_config"].read_text(encoding="utf-8") == NGINX_CONFIG
|
||||
|
||||
|
||||
def test_render_rejects_incomplete_templates():
|
||||
from services.metrics_edge_controlled_apply_service import (
|
||||
render_nginx_config,
|
||||
render_prometheus_config,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="incomplete"):
|
||||
render_nginx_config(NGINX_CONFIG, "location = /metrics {}")
|
||||
with pytest.raises(ValueError, match="incomplete"):
|
||||
render_prometheus_config(PROMETHEUS_CONFIG, "job_name: other")
|
||||
Reference in New Issue
Block a user