"""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.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}") 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") 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) 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 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 try: r.run("echo SSH_OK; docker ps --format '{{.Names}}' | grep -c gti-fiscal-hub", mutating=False) # 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") log(f"BACKUP_DIR={bdir}") # 5. transfer (git archive never contains .env / secrets; .env.local is gitignored) r.put(tar_local, "/tmp/gti_fiscal_deploy.tar") touched = True 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") # 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) 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") wait_health(r, version) # 9. Agent release (immutable: publish_agent_release refuses a different sha256 for an existing version) if a.publish_agent: 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}") else: log("RELEASE_PUBLISH=skipped") if not a.dry_run: public_smoke(version) 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"DEPLOY_FAILED: {exc}") 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"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())