From 7dc437c01c63787c291931b1d002788b554f7b01 Mon Sep 17 00:00:00 2001 From: gti Date: Thu, 24 Sep 2026 10:02:28 -0300 Subject: [PATCH] feat: official operator-run Hub deploy script (backup, migrate, health, release, rollback) Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 3 + docs/DEPLOY_HUB.md | 40 ++++++++ scripts/deploy_hub.ps1 | 60 ++++++++++++ scripts/deploy_hub_remote.py | 173 +++++++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 docs/DEPLOY_HUB.md create mode 100644 scripts/deploy_hub.ps1 create mode 100644 scripts/deploy_hub_remote.py diff --git a/CLAUDE.md b/CLAUDE.md index cf00c84..e07e615 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,3 +76,6 @@ For the "GTI FISCAL LOCAL COMPLETION V1" mission the operator explicitly authori path only — never by directory or `.FDB` extension. Any other Firebird under `D:\PROJETOS\GTISISTEMAS` stays strictly READ-ONLY. Writes go through the guarded executor (path allowlist, company-context guard, schema/type/length guard, compare-before-write, rowcount, transaction, audit) and only to `C000025` fields whose contract in `docs/FISCAL_FIELD_CONTRACT_V1.json` has `WRITE_ALLOWED=true`. This authorization does not extend to production or client databases. + +## Production deploy rule (Hub) +Claude/agents must NOT run production deploys (no ad-hoc SSH/SFTP/docker against 10.0.10.117). They prepare changes, test, commit, and tell the operator to run, outside Claude Code: `powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 [-PublishAgent -Version X]` (see docs/DEPLOY_HUB.md). diff --git a/docs/DEPLOY_HUB.md b/docs/DEPLOY_HUB.md new file mode 100644 index 0000000..24dffb1 --- /dev/null +++ b/docs/DEPLOY_HUB.md @@ -0,0 +1,40 @@ +# Deploy do GTI Fiscal Hub + +## Pré-requisitos +- Python com `paramiko` e as dependências dos testes; Git; acesso à rede do 10.0.10.117. +- Working tree limpa (o script aborta se houver alteração pendente). +- Para `-PublishAgent`: instalador `dist\installer\GTIFiscalAgent-Setup-.exe` já gerado. + +## .env.local +Na raiz do repositório (já ignorado pelo Git; nunca commitar): + +``` +GTI_SSH_PASSWORD= +``` +A variável de ambiente `GTI_SSH_PASSWORD`, se existir, tem prioridade. A senha nunca é impressa nem gravada em log. + +## Deploy +``` +powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 -PublishAgent -Version 1.1.5 +``` +Parâmetros: `-Version` (padrão `release/VERSION`), `-PublishAgent`, `-SkipTests`, `-DryRun`. + +## Dry-run +``` +powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 -DryRun +``` +Roda as verificações locais, testa o SSH e lista as etapas de produção sem alterá-la. + +## O que o script faz +backup (Postgres, release storage, `.env`, árvore anterior em `/home/administrador/backups/gti-fiscal-deploy-/`; falhou = aborta) → envia `git archive HEAD` e extrai sobre a árvore (preserva `.env` e volumes) → rebuild só do `fiscal-hub` (migrations `alembic upgrade head` rodam no start) → health/version (container, `127.0.0.1:8000`, público) → publica o instalador (imutável: sha256 diferente para a mesma versão é recusado). Log em `logs/deploy-*.log`. + +## Rollback +Falha depois de tocar produção: o script restaura a árvore anterior (`previous_tree.tgz`), reconstrói o Hub e imprime `ROLLBACK_REQUIRED=true`. O volume do Postgres nunca é removido. Se uma migration já foi aplicada e for preciso voltar o banco: `deploy/hub/restore.sh` com o dump do diretório de backup. + +## Troubleshooting +- `GTI_SSH_PASSWORD not found`: crie `.env.local` ou defina a variável. +- `working tree is not clean`: commite antes. +- `version mismatch`: `release/VERSION` e `central/app/version.py` devem coincidir com `-Version`. +- `refused: ... already published`: a versão já existe com outro hash; gere uma nova versão. +- Permissão negada em `backup.sh`: a extração perde o bit de execução; o script não depende dele. diff --git a/scripts/deploy_hub.ps1 b/scripts/deploy_hub.ps1 new file mode 100644 index 0000000..78399d8 --- /dev/null +++ b/scripts/deploy_hub.ps1 @@ -0,0 +1,60 @@ +# Official production deploy of the GTI Fiscal Hub (10.0.10.117). Run by the OPERATOR, outside Claude Code: +# powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 [-PublishAgent] [-Version 1.1.5] [-SkipTests] [-DryRun] +# SSH password: env GTI_SSH_PASSWORD, else GTI_SSH_PASSWORD=... in .env.local (gitignored). Never printed or logged. +[CmdletBinding()] +param( + [string]$Version = "", + [switch]$PublishAgent, + [switch]$SkipTests, + [switch]$DryRun, + [string]$Python = "python" +) +$ErrorActionPreference = "Stop" +$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +Set-Location $Root +$LogDir = Join-Path $Root "logs"; New-Item -ItemType Directory -Force $LogDir | Out-Null +$Log = Join-Path $LogDir ("deploy-{0}.log" -f (Get-Date -Format "yyyyMMdd-HHmmss")) +Start-Transcript -Path $Log | Out-Null +try { + if (-not $Version) { $Version = (Get-Content (Join-Path $Root "release\VERSION") -Raw).Trim() } + + # 3. credential: process env wins, then .env.local + $src = "env" + if (-not $env:GTI_SSH_PASSWORD) { + $envFile = Join-Path $Root ".env.local" + if (Test-Path $envFile) { + $line = Get-Content $envFile | Where-Object { $_ -match '^\s*GTI_SSH_PASSWORD\s*=' } | Select-Object -First 1 + if ($line) { $env:GTI_SSH_PASSWORD = ($line -replace '^\s*GTI_SSH_PASSWORD\s*=\s*', '').Trim().Trim('"').Trim("'"); $src = ".env.local" } + } + } + if (-not $env:GTI_SSH_PASSWORD) { throw "GTI_SSH_PASSWORD not found in the environment or .env.local" } + Write-Host "SSH_AUTH_SOURCE=$src PASSWORD_LOGGED=false" + + # 2. local pre-checks + git check-ignore -q .env.local + if ($LASTEXITCODE -ne 0 -and (Test-Path ".env.local")) { throw ".env.local is not gitignored" } + $dirty = git status --porcelain + if ($dirty) { throw "working tree is not clean - commit or stash first:`n$dirty" } + $branch = git rev-parse --abbrev-ref HEAD; $head = git rev-parse --short HEAD + Write-Host "BRANCH=$branch HEAD=$head VERSION=$Version" + $fileVersion = (Get-Content (Join-Path $Root "release\VERSION") -Raw).Trim() + if ($fileVersion -ne $Version) { throw "release/VERSION is $fileVersion but -Version is $Version" } + if ($PublishAgent) { + $exe = Join-Path $Root "dist\installer\GTIFiscalAgent-Setup-$Version.exe" + if (-not (Test-Path $exe)) { throw "installer not built: $exe (run scripts\build_windows_installer.ps1)" } + } + if (-not $SkipTests) { + & $Python -m pytest tests central/tests agent/tests -q -p no:cacheprovider + if ($LASTEXITCODE -ne 0) { throw "tests failed - deploy aborted" } + } + + $deployArgs = @("scripts\deploy_hub_remote.py", "--version", $Version) + if ($DryRun) { $deployArgs += "--dry-run" } + if ($PublishAgent) { $deployArgs += "--publish-agent" } + & $Python @args + if ($LASTEXITCODE -ne 0) { throw "remote deploy failed (see ROLLBACK_REQUIRED above)" } + Write-Host "DEPLOY_SCRIPT_OK log=$Log" +} finally { + Remove-Item Env:GTI_SSH_PASSWORD -ErrorAction SilentlyContinue + Stop-Transcript | Out-Null +} diff --git a/scripts/deploy_hub_remote.py b/scripts/deploy_hub_remote.py new file mode 100644 index 0000000..d7b754b --- /dev/null +++ b/scripts/deploy_hub_remote.py @@ -0,0 +1,173 @@ +"""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())