103 lines
3.8 KiB
Python
103 lines
3.8 KiB
Python
"""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())
|