"""Remote half of scripts/deploy_hub.ps1 (the official GTI Fiscal Hub deploy). Not meant to be run directly. SSH password: env GTI_SSH_PASSWORD only (never argv, never logged). Every output line is scrubbed of it. Flow: preflight -> backup (abort on failure) -> upload git-archive of HEAD -> extract IN PLACE (keeps .env, compose overrides and volumes) -> build/up (alembic upgrade head runs at container start) -> health -> optional Agent release publish -> public smoke. A failure after production was touched restores the previous tree from the backup tarball and rebuilds; the Postgres volume is never removed or restored automatically.""" import argparse import hashlib import json import os import re import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path import paramiko HOST = "10.0.10.117" USER = "administrador" REMOTE = "/home/administrador/gti-fiscal" COMPOSE_DIR = f"{REMOTE}/deploy/hub" COMPOSE = "docker compose -f docker-compose.prod.yml --env-file .env" HUB = "gti-fiscal-hub-fiscal-hub-1" PG = "gti-fiscal-hub-postgres-1" PUBLIC = "https://gtifiscal.gtigrupo.com.br" REPO = Path(__file__).resolve().parents[1] PASSWORD = os.environ.get("GTI_SSH_PASSWORD", "") SECRET_RE = re.compile(r"(?i)(password|passwd|token|secret)(\s*[=:]\s*)\S+") def log(msg: str) -> None: text = str(msg) if PASSWORD: text = text.replace(PASSWORD, "***") print(SECRET_RE.sub(r"\1\2***", text), flush=True) class Remote: def __init__(self, dry: bool): self.dry = dry self.client = paramiko.SSHClient() self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.client.connect(HOST, username=USER, password=PASSWORD, timeout=20, look_for_keys=False, allow_agent=False) def run(self, cmd: str, timeout: int = 600, check: bool = True, mutating: bool = True) -> str: if self.dry and mutating: log(f"[dry-run] $ {cmd}") return "" _, out, err = self.client.exec_command(cmd, timeout=timeout) text = out.read().decode(errors="replace") + err.read().decode(errors="replace") rc = out.channel.recv_exit_status() log(f"$ {cmd}\n{text.rstrip()[-1500:]}") if check and rc != 0: raise RuntimeError(f"exit {rc}: {cmd[:120]} :: {text.strip()[-400:]}") return text def put(self, local: Path, remote: str) -> None: if self.dry: log(f"[dry-run] upload {local.name} -> {remote}") return self.client.open_sftp().put(str(local), remote) def wait_health(r: Remote, expected: str) -> None: if r.dry: log("[dry-run] health checks skipped") return for _ in range(30): st = r.run(f"docker inspect -f '{{{{.State.Health.Status}}}}' {HUB} {PG}", check=False, mutating=False) if st.split() == ["healthy", "healthy"]: break time.sleep(4) else: raise RuntimeError("containers did not become healthy") 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')}") def public_smoke(expected: str) -> None: for path in ("/health", "/version"): with urllib.request.urlopen(PUBLIC + path, timeout=20) as resp: body = resp.read().decode() if resp.status != 200: raise RuntimeError(f"public {path} -> {resp.status}") if path == "/version" and json.loads(body).get("version") != expected: raise RuntimeError("public version mismatch") 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") ap.add_argument("--publish-agent", action="store_true") ap.add_argument("--version", required=True) a = ap.parse_args() if not PASSWORD: log("GTI_SSH_PASSWORD not provided") return 2 version = a.version stamp = time.strftime("%Y%m%d-%H%M%S") bdir = f"/home/administrador/backups/gti-fiscal-deploy-{stamp}" tar_local = REPO / "dist" / f"deploy_{stamp}.tar" tar_local.parent.mkdir(exist_ok=True) subprocess.run(["git", "archive", "--format=tar", "-o", str(tar_local), "HEAD"], check=True, cwd=REPO) installer = REPO / "dist" / "installer" / f"GTIFiscalAgent-Setup-{version}.exe" if a.publish_agent and not installer.exists(): log(f"installer not found: {installer}") return 2 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} --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}") r.run(f"docker exec {HUB} python central/scripts/publish_agent_release.py --artifact /tmp/{installer.name} --version {version}") r.run(f"docker exec {HUB} python -c \"from central.app.db import engine; from sqlalchemy import text; " 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; from sqlalchemy.orm import Session; " f"s=Session(engine); n=s.execute(text(\\\"update agent_releases set status='REVOKED' where status='PUBLISHED' and filename like 'GTIFiscalAgent-Setup-%.exe' and version<>'{version}'\\\")).rowcount; s.commit(); print('revoked', n)\"", 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} --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 log(f"ROLLBACK_FAILED: {rb}. Backup at {bdir}") else: log("ROLLBACK_REQUIRED=false (production untouched)") return 1 finally: tar_local.unlink(missing_ok=True) if __name__ == "__main__": sys.exit(main())