Files
GTI_GERENCIADOR_FISCAL/scripts/deploy_hub_remote.py
T

174 lines
8.2 KiB
Python

"""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())