"""Point the k3s ArgoCD Application at an image tag, then trigger an immediate sync. `medical-chatbot-app` (argocd.realvuxbaro.me) is the production Application — it serves both realvuxbaro.me and readytochat.realvuxbaro.me — since the 2026-08-17 cutover. This script does not distinguish "forward" from "rollback": it points the Application at whatever IMAGE_TAG it is given and syncs, so `rollback-k3s.yml` reuses it unchanged with an older tag rather than duplicating this logic. Required env: ARGOCD_PRACTICE_URL, ARGOCD_PRACTICE_PASSWORD, IMAGE_TAG. """ from __future__ import annotations import json import os import re import sys import urllib.error import urllib.request APP_NAME = "medical-chatbot-app" 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): 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 main() -> int: base = os.environ["ARGOCD_PRACTICE_URL"].rstrip("/") password = os.environ["ARGOCD_PRACTICE_PASSWORD"] tag = os.environ["IMAGE_TAG"] session = call(base, "POST", "/api/v1/session", body={"username": "admin", "password": password}) token = session["token"] app = call(base, "GET", f"/api/v1/applications/{APP_NAME}", token=token) values = app["spec"]["source"]["helm"]["values"] for image in IMAGES: pattern = re.compile( rf"(repository:\s*ghcr\.io/baovu2k4/{re.escape(image)}\s*\n\s*tag:\s*)\S+" ) values, count = pattern.subn(rf"\g<1>{tag}", values) if count != 1: print(f"Expected exactly one tag: line after {image}'s repository line, found {count}", file=sys.stderr) return 1 app["spec"]["source"]["helm"]["values"] = values call(base, "PUT", f"/api/v1/applications/{APP_NAME}", token=token, body=app) # selfHeal (syncPolicy.automated) reacts to the PUT above on its own — # often before this explicit call lands, which then 400s with "another # operation is already in progress". That race means the sync we wanted # is already happening; only a genuinely different failure is fatal. try: call(base, "POST", f"/api/v1/applications/{APP_NAME}/sync", token=token, body={}) except urllib.error.HTTPError as exc: if exc.code == 400: print(f"Explicit sync raced with autosync (expected under selfHeal) — continuing.") else: raise print(f"{APP_NAME} pointed at tag {tag}; sync in progress.") return 0 if __name__ == "__main__": raise SystemExit(main())