fix: deploy health via docker exec (prod port bound to VM IP), safe rollback, error propagation, VPN retry, release default + download verification

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 10:17:47 -03:00
co-authored by Claude Sonnet 5
parent b01e3b230a
commit 7080ef4d73
3 changed files with 117 additions and 5 deletions
+52 -4
View File
@@ -13,6 +13,7 @@ import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
@@ -75,8 +76,9 @@ def wait_health(r: Remote, expected: str) -> None:
time.sleep(4)
else:
raise RuntimeError("containers did not become healthy")
ver = json.loads(r.run("curl -sf http://127.0.0.1:8000/version", mutating=False))
r.run("curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/health", mutating=False)
probe = ("python -c \"import urllib.request as u;print(u.urlopen('http://localhost:8000/%s').read().decode())\"")
ver = json.loads(r.run(f"docker exec {HUB} " + probe % "version", mutating=False))
r.run(f"docker exec {HUB} " + probe % "health", mutating=False)
if ver.get("version") != expected:
raise RuntimeError(f"version mismatch: production {ver.get('version')} != expected {expected}")
log(f"HEALTH_OK version={ver.get('version')}")
@@ -93,6 +95,26 @@ def public_smoke(expected: str) -> None:
log(f"PUBLIC_HEALTH=200 PUBLIC_VERSION={expected}")
def write_result(status: str, error: str, rollback: bool) -> None:
out = REPO / "logs"
out.mkdir(exist_ok=True)
text = json.dumps({"status": status, "error": SECRET_RE.sub(r"***", error).replace(PASSWORD, "***"), "rollback_required": rollback})
(out / "last_deploy_result.json").write_text(text, encoding="utf-8")
def public_extra() -> None:
body = urllib.request.urlopen("https://gtigerenciador.gtigrupo.com.br/health", timeout=20).status
log(f"GERENCIADOR_HEALTH={body}")
req = urllib.request.Request(PUBLIC + "/auth/sso/callback", data=b"assertion=bogus", method="POST")
try:
code = urllib.request.urlopen(req, timeout=20).status
except urllib.error.HTTPError as e:
code = e.code
if code >= 500:
raise RuntimeError(f"SSO callback misbehaves: {code}")
log(f"SSO_SMOKE=ok (bogus assertion -> {code}, no 5xx)")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
@@ -115,28 +137,41 @@ def main() -> int:
r = Remote(a.dry_run)
touched = False
st = {"stage": "connect"}
try:
st["stage"] = "preflight"
r.run("echo SSH_OK; docker ps --format '{{.Names}}' | grep -c gti-fiscal-hub", mutating=False)
free = r.run(f"df -Pm {REMOTE} | tail -1 | awk '{{print $4}}'", mutating=False).split()
if free and free[0].isdigit() and int(free[0]) < 2000:
raise RuntimeError(f"remote disk too low: {free[0]} MB free")
r.run(f"docker exec {HUB} python -c \"import urllib.request as u;print(u.urlopen('http://localhost:8000/version').read().decode())\"", check=False, mutating=False)
st["stage"] = "backup"
# 4. backup: DB + release storage + env/config + previous tree. Failure aborts before anything changes.
r.run(f"mkdir -p {bdir}")
r.run(f"docker exec {PG} pg_dump -U gti -d gti_fiscal -Fc > {bdir}/gti_fiscal.dump && test -s {bdir}/gti_fiscal.dump")
r.run(f"docker exec {HUB} tar -C /app/data -cz releases > {bdir}/releases.tgz && test -s {bdir}/releases.tgz")
r.run(f"cp {COMPOSE_DIR}/.env {bdir}/hub.env && chmod 600 {bdir}/hub.env")
r.run(f"tar -czf {bdir}/previous_tree.tgz -C {REMOTE} . && test -s {bdir}/previous_tree.tgz")
r.run(f"tar -czf {bdir}/previous_tree.tgz -C {REMOTE} --exclude=./deploy/hub/backups . && test -s {bdir}/previous_tree.tgz")
log(f"BACKUP_DIR={bdir}")
# 5. transfer (git archive never contains .env / secrets; .env.local is gitignored)
st["stage"] = "upload"
r.put(tar_local, "/tmp/gti_fiscal_deploy.tar")
touched = True
st["stage"] = "extract"
r.run(f"tar -xf /tmp/gti_fiscal_deploy.tar -C {REMOTE} && rm -f /tmp/gti_fiscal_deploy.tar")
r.run(f"sed -i 's/^GTI_FISCAL_VERSION=.*/GTI_FISCAL_VERSION={version}/' {COMPOSE_DIR}/.env")
st["stage"] = "docker_build_up"
# 6/7. only the Hub is rebuilt; Postgres and the Gerenciador stack are untouched. Migrations run at start.
r.run(f"cd {COMPOSE_DIR} && {COMPOSE} build fiscal-hub && {COMPOSE} up -d fiscal-hub", timeout=900)
st["stage"] = "migration_head"
head = r.run(f"sleep 10; docker exec {HUB} python -m alembic -c central/alembic.ini current", check=False, mutating=False)
if not a.dry_run and "(head)" not in head:
raise RuntimeError("alembic is not at head after start")
st["stage"] = "health"
wait_health(r, version)
# 9. Agent release (immutable: publish_agent_release refuses a different sha256 for an existing version)
if a.publish_agent:
st["stage"] = "publish_release"
digest = hashlib.sha256(installer.read_bytes()).hexdigest()
r.put(installer, f"/tmp/{installer.name}")
r.run(f"docker cp /tmp/{installer.name} {HUB}:/tmp/ && rm -f /tmp/{installer.name}")
@@ -145,19 +180,32 @@ def main() -> int:
f"print(engine.connect().execute(text(\\\"select version,status,size_bytes,sha256 from agent_releases where version='{version}'\\\")).fetchall())\"",
check=False)
log(f"EXPECTED_SHA256={digest} SIZE={installer.stat().st_size}")
# older Setup installers stop being offered (never deleted); the newest PUBLISHED release is the default
r.run(f"docker exec {HUB} python -c \"from central.app.db import engine; from sqlalchemy import text; c=engine.begin().__enter__(); "
f"print('revoked', c.execute(text(\\\"update agent_releases set status='REVOKED' where status='PUBLISHED' and filename like 'GTIFiscalAgent-Setup-%.exe' and version<>'{version}'\\\")).rowcount)\"",
check=False)
st["stage"] = "verify_download"
r.run(f"docker exec {HUB} python central/scripts/verify_release_download.py {version}")
else:
log("RELEASE_PUBLISH=skipped")
if not a.dry_run:
st["stage"] = "public_smoke"
public_smoke(version)
public_extra()
st["stage"] = "done"
write_result("OK", "", False)
log("ROLLBACK_REQUIRED=false")
log("DEPLOY_OK" if not a.dry_run else "DRY_RUN_OK")
return 0
except Exception as exc: # noqa: BLE001
log(f"FAILED_STAGE={st['stage']}")
log(f"REMOTE_ERROR={exc}")
log(f"DEPLOY_FAILED: {exc}")
write_result("FAILED", f"{st['stage']}: {exc}", touched and not a.dry_run)
if touched and not a.dry_run:
log("ROLLBACK_REQUIRED=true — restoring previous tree (Postgres volume is NOT touched)")
try:
r.run(f"tar -xzf {bdir}/previous_tree.tgz -C {REMOTE}")
r.run(f"tar -xzf {bdir}/previous_tree.tgz -C {REMOTE} --no-same-owner -m --exclude=./deploy/hub/backups")
r.run(f"cd {COMPOSE_DIR} && {COMPOSE} build fiscal-hub && {COMPOSE} up -d fiscal-hub", timeout=900)
log(f"ROLLBACK_APPLIED. DB dump kept at {bdir}/gti_fiscal.dump (restore manually only if a migration ran)")
except Exception as rb: # noqa: BLE001