"""Block until the k3s ArgoCD Application is running EXPECT_TAG and is Synced + Healthy, or time out. Companion to `sync_practice_argocd.py`, which fires a sync and returns immediately (it must, since ArgoCD's own selfHeal can race an explicit sync call — see its comment). A rollback caller needs the opposite guarantee: do not report success until the rollout has actually landed. Kept as a separate script rather than merged into that one, since the normal forward-deploy path in `build-practice-images.yml` confirms liveness a different way (an HTTP smoke check against readytochat) and doesn't need this blocking behaviour. Required env: ARGOCD_PRACTICE_URL, ARGOCD_PRACTICE_PASSWORD, EXPECT_TAG. Optional env: APP_NAME (default medical-chatbot-app), TIMEOUT_SECONDS (default 300), POLL_INTERVAL_SECONDS (default 10). """ from __future__ import annotations import json import os import re import sys import time import urllib.error import urllib.request IMAGES = ("vsf-duocthu-ai-service", "vsf-duocthu-web") def call(base: str, method: str, path: str, token: str | None = 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}") try: with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as exc: print(f"{method} {path} -> {exc.code}: {exc.read().decode(errors='replace')}", file=sys.stderr) raise def deployed_tag(values: str, image: str) -> str | None: # Same shape sync_practice_argocd.py writes: a `repository:` line # immediately followed by its `tag:` line. Reading it back with the # mirror-image regex, rather than a looser substring check, means this # can't be fooled by the tag also appearing in a comment or another # image's block. match = re.search( rf"repository:\s*ghcr\.io/baovu2k4/{re.escape(image)}\s*\n\s*tag:\s*(\S+)", values, ) return match.group(1) if match else None def main() -> int: base = os.environ["ARGOCD_PRACTICE_URL"].rstrip("/") password = os.environ["ARGOCD_PRACTICE_PASSWORD"] expect_tag = os.environ["EXPECT_TAG"] app_name = os.environ.get("APP_NAME", "medical-chatbot-app") timeout_seconds = int(os.environ.get("TIMEOUT_SECONDS", "300")) poll_interval = int(os.environ.get("POLL_INTERVAL_SECONDS", "10")) session = call(base, "POST", "/api/v1/session", body={"username": "admin", "password": password}) token = session["token"] deadline = time.monotonic() + timeout_seconds last_state = "no poll yet" while time.monotonic() < deadline: app = call(base, "GET", f"/api/v1/applications/{app_name}", token=token) values = app["spec"]["source"]["helm"]["values"] tags = {image: deployed_tag(values, image) for image in IMAGES} sync_status = app.get("status", {}).get("sync", {}).get("status") health_status = app.get("status", {}).get("health", {}).get("status") last_state = f"tags={tags} sync={sync_status} health={health_status}" if ( all(tag == expect_tag for tag in tags.values()) and sync_status == "Synced" and health_status == "Healthy" ): print(f"{app_name} is on {expect_tag}, Synced, Healthy: {last_state}") return 0 print(f"waiting: {last_state}") time.sleep(poll_interval) print( f"Timed out after {timeout_seconds}s waiting for {app_name} to reach " f"{expect_tag}/Synced/Healthy. Last observed: {last_state}", file=sys.stderr, ) return 1 if __name__ == "__main__": raise SystemExit(main())