Stop the slugifier from deleting the letter D-stroke

This commit is contained in:
2026-08-19 13:07:13 +07:00
parent 24c55d1627
commit 4490a1abf0
14 changed files with 739 additions and 198 deletions
@@ -0,0 +1,162 @@
"""Turn apiGateway back on, prove chat still works, and self-revert if it does not.
Enabling the gateway is the step that broke chat on 2026-08-19: web.yaml sets
API_GATEWAY_URL whenever apiGateway is enabled, and every RAG route used to
prefer it over AI_SERVICE_URL even though the gateway proxies `/auth/*` only.
That preference is gone (PR #37), so this should now affect auth alone -- but
"should" is exactly what was believed last time, so this verifies instead of
assuming.
Chat is the product and takes absolute priority over login. So the revert is
automatic and unconditional: if chat does not answer correctly after the
rollout, apiGateway goes straight back off without waiting for a human. Login
returning to a broken state is an acceptable outcome; chat being down is not.
Verification drives the real user path -- POST /api/chat, the same route the
browser calls -- rather than pod health, which stayed green throughout the
outage while every data route was dead.
Required env: ARGOCD_PRACTICE_URL, ARGOCD_PRACTICE_PASSWORD.
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.error
import urllib.request
APP_NAME = "medical-chatbot-app"
SITE = "https://realvuxbaro.me"
ROLLOUT_WAIT = 20
ROLLOUT_ROUNDS = 12
def call(base, method, path, token=None, body=None):
req = urllib.request.Request(
f"{base}{path}",
data=json.dumps(body).encode() if body is not None else None,
method=method,
headers={"Content-Type": "application/json"},
)
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def set_api_gateway(values: str, enabled: bool) -> str:
"""Flip `enabled` under the apiGateway key, and nowhere else."""
want_from = "enabled: false" if enabled else "enabled: true"
want_to = "enabled: true" if enabled else "enabled: false"
out, in_gateway, changed = [], False, 0
for line in values.splitlines():
stripped = line.strip()
if line and not line[0].isspace():
in_gateway = stripped == "apiGateway:"
elif in_gateway and stripped == want_from:
indent = line[: len(line) - len(line.lstrip())]
line = f"{indent}{want_to}"
changed += 1
out.append(line)
if changed != 1:
raise SystemExit(f"Expected exactly one `{want_from}` under apiGateway, changed {changed}.")
return "\n".join(out) + "\n"
def apply(base, token, enabled: bool) -> None:
app = call(base, "GET", f"/api/v1/applications/{APP_NAME}", token=token)
app["spec"]["source"]["helm"]["values"] = set_api_gateway(
app["spec"]["source"]["helm"].get("values", ""), enabled
)
call(base, "PUT", f"/api/v1/applications/{APP_NAME}", token=token, body=app)
try:
call(base, "POST", f"/api/v1/applications/{APP_NAME}/sync", token=token, body={})
except urllib.error.HTTPError as exc:
if exc.code != 400:
raise
print(f"apiGateway.enabled -> {enabled}")
def chat_works() -> bool:
"""Drive the real user path. True only on a genuine grounded answer."""
req = urllib.request.Request(
f"{SITE}/api/chat",
data=json.dumps({"content": "Chống chỉ định của Metformin là gì?"}).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
body = resp.read().decode(errors="replace")
ok = resp.status == 200 and "disclaimer" in body and "gặp sự cố" not in body
print(f" chat: HTTP {resp.status}, grounded={ok}")
return ok
except Exception as exc:
print(f" chat: FAILED -- {exc}")
return False
def login_works() -> bool:
req = urllib.request.Request(
f"{SITE}/api/auth/login",
data=json.dumps({"username": "demo", "password": "1"}).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
print(f" login: HTTP {resp.status}")
return resp.status == 200
except Exception as exc:
print(f" login: {exc}")
return False
def main() -> int:
base = os.environ["ARGOCD_PRACTICE_URL"].rstrip("/")
password = os.environ["ARGOCD_PRACTICE_PASSWORD"]
token = call(base, "POST", "/api/v1/session",
body={"username": "admin", "password": password})["token"]
print("=== baseline (gateway off) ===")
if not chat_works():
print("Chat is already broken before any change -- refusing to touch anything.", file=sys.stderr)
return 1
print("=== enabling apiGateway ===")
apply(base, token, True)
for i in range(ROLLOUT_ROUNDS):
time.sleep(ROLLOUT_WAIT)
app = call(base, "GET", f"/api/v1/applications/{APP_NAME}", token=token)
status = app.get("status", {})
health = status.get("health", {}).get("status")
print(f"poll {i + 1}/{ROLLOUT_ROUNDS}: sync={status.get('sync', {}).get('status')} health={health}")
if health == "Healthy" and i >= 2:
break
print("=== verifying the real user path ===")
chat_ok = chat_works()
login_ok = login_works()
if not chat_ok:
print("CHAT IS DOWN -- reverting apiGateway immediately.", file=sys.stderr)
apply(base, token, False)
for _ in range(6):
time.sleep(ROLLOUT_WAIT)
if chat_works():
print("Chat restored. apiGateway left OFF.")
return 1
print("Chat still down after revert -- needs a human.", file=sys.stderr)
return 1
print(f"\nchat=OK login={'OK' if login_ok else 'STILL BROKEN'}; apiGateway left ON.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+11 -1
View File
@@ -21,7 +21,17 @@ import urllib.error
import urllib.request
APP_NAME = "medical-chatbot-app"
IMAGES = ("vsf-duocthu-ai-service", "vsf-duocthu-web")
IMAGES = (
"vsf-duocthu-ai-service",
"vsf-duocthu-web",
# Added 2026-08-19. These were excluded because this script fails the
# whole run if a tag it expects to rewrite is not already inline on the
# Application, and their image blocks were not there yet. They are now,
# so leaving these out would instead pin auth-service and api-gateway
# at whatever SHA they were first deployed with, silently, forever.
"vsf-duocthu-auth-service",
"vsf-duocthu-api-gateway",
)
def call(base: str, method: str, path: str, token: str | None = None, body=None):