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:
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user