feat: official operator-run Hub deploy script (backup, migrate, health, release, rollback)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 10:02:28 -03:00
co-authored by Claude Sonnet 5
parent 71bbf3e0bd
commit 7dc437c01c
4 changed files with 276 additions and 0 deletions
+3
View File
@@ -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).
+40
View File
@@ -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-<versão>.exe` já gerado.
## .env.local
Na raiz do repositório (já ignorado pelo Git; nunca commitar):
```
GTI_SSH_PASSWORD=<senha do usuário administrador>
```
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-<data>/`; 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.
+60
View File
@@ -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
}
+173
View File
@@ -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())