Files
gti d21a9d1140 Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
2026-09-29 14:43:23 -03:00

225 lines
11 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.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/login", 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)
try: # Gerenciador/SSO reachability is reported, never a reason to roll a healthy Hub back
public_extra()
except Exception as exc: # noqa: BLE001
log(f"WARN extra smoke: {exc}")
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())