feat: GTI Gerenciador as the single source of truth for clients; bump to 1.1.2
Tenant gains source (LOCAL|GTI_GERENCIADOR), external_client_id, external_code, active and synced_at (migration 0024). central/app/client_sync.py fetches from the panel's new GET /internal/fiscal/clients (dedicated bearer token, never MySQL credentials) and upserts Tenant rows, never touching a LOCAL row. /agents/new now offers only active GTI_GERENCIADOR tenants, with a client-side search box; the POST validates the same rule server-side, not just in the dropdown. central/scripts/sync_clients_from_gerenciador.py is the operator-run sync + reconciliation report. GTI_FISCAL_VERSION/AGENT_VERSION bumped to 1.1.2 (release/VERSION single source of truth), incorporating the installation_id persistence fix. Rebuilt the EXE installer trio at 1.1.2 (scripts/build_windows_installer.ps1). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gti-fiscal-agent"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["httpx>=0.27,<1"]
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class Config:
|
||||
jobs_enabled = (env.get("AGENT_JOBS_ENABLED", "1") or "1").strip().upper()
|
||||
self.agent_jobs_enabled = jobs_enabled not in ("0", "FALSE", "NO")
|
||||
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
|
||||
self.agent_version = env.get("AGENT_VERSION", "1.1.1")
|
||||
self.agent_version = env.get("AGENT_VERSION", "1.1.2")
|
||||
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
|
||||
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
|
||||
# Pilot write authorization: both must be explicitly set by the installer for
|
||||
@@ -198,7 +198,7 @@ def execute_job(job, config, executor, state, env):
|
||||
result_summary = {
|
||||
"coverage": coverage,
|
||||
"fiscal_mode": getattr(config, "fiscal_mode", None),
|
||||
"release_version": env.get("AGENT_VERSION") or "1.1.1",
|
||||
"release_version": env.get("AGENT_VERSION") or "1.1.2",
|
||||
"products_processed": summary["processed"],
|
||||
"writes_attempted": summary["writes_attempted"],
|
||||
"writes_succeeded": summary["writes_succeeded"],
|
||||
@@ -331,7 +331,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
|
||||
def enforce_startup_preflight(env):
|
||||
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
|
||||
from gti_fiscal_agent.preflight import run_preflight
|
||||
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.1")
|
||||
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.2")
|
||||
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
|
||||
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
|
||||
if report["PREFLIGHT"] != "PASS":
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
AGENT_VERSION_DEFAULT = "1.1.1"
|
||||
AGENT_VERSION_DEFAULT = "1.1.2"
|
||||
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
|
||||
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
|
||||
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tenant mirror identity from GTI Gerenciador.
|
||||
|
||||
GTI Gerenciador (the panel) becomes the source of truth for clients.
|
||||
tenants gains source ('LOCAL' | 'GTI_GERENCIADOR'), external_client_id
|
||||
(Gerenciador's CLIENTE.ID), external_code, active and synced_at. Existing
|
||||
rows (pre-dating this sync) become source='LOCAL' and are never offered for
|
||||
a new installation, but keep every foreign key that already points at them.
|
||||
|
||||
Revision ID: 0024_tenant_gerenciador_mirror
|
||||
Revises: 0023_install_codes
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0024_tenant_gerenciador_mirror"
|
||||
down_revision = "0023_install_codes"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("tenants", sa.Column("source", sa.String(16), nullable=False, server_default="LOCAL"))
|
||||
op.add_column("tenants", sa.Column("external_client_id", sa.Integer(), nullable=True))
|
||||
op.add_column("tenants", sa.Column("external_code", sa.String(16), nullable=True))
|
||||
op.add_column("tenants", sa.Column("active", sa.Boolean(), nullable=False, server_default="true"))
|
||||
op.add_column("tenants", sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.create_index("ix_tenants_external_client_id", "tenants", ["external_client_id"])
|
||||
op.create_unique_constraint("uq_tenant_external_identity", "tenants", ["source", "external_client_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_constraint("uq_tenant_external_identity", "tenants", type_="unique")
|
||||
op.drop_index("ix_tenants_external_client_id", table_name="tenants")
|
||||
for column in ("synced_at", "active", "external_code", "external_client_id", "source"):
|
||||
op.drop_column("tenants", column)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Client mirror from GTI Gerenciador — the single source of truth for
|
||||
clients (central/app/models.py: Tenant.source/external_client_id/active).
|
||||
The Fiscal Hub never receives MySQL credentials; it calls the panel's
|
||||
read-only internal endpoint instead, authenticated by a dedicated bearer
|
||||
token (FISCAL_INTERNAL_API_TOKEN, shared only between the two apps' envs
|
||||
— never SESSION_SECRET, the SSO key, an admin password or an Agent token).
|
||||
|
||||
Run `central/scripts/sync_clients_from_gerenciador.py` to pull a fresh copy;
|
||||
`Tenant.source='LOCAL'` rows (pre-existing dev/test tenants) are never
|
||||
touched or offered for a new installation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import config
|
||||
from .models import Tenant
|
||||
|
||||
SOURCE = "GTI_GERENCIADOR"
|
||||
|
||||
|
||||
class ClientSyncUnavailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(config.settings.gerenciador_internal_url and config.settings.fiscal_internal_api_token)
|
||||
|
||||
|
||||
def fetch_from_gerenciador() -> list[dict]:
|
||||
if not enabled():
|
||||
raise ClientSyncUnavailable("GERENCIADOR_INTERNAL_URL/FISCAL_INTERNAL_API_TOKEN not configured")
|
||||
headers = {"Authorization": f"Bearer {config.settings.fiscal_internal_api_token}"}
|
||||
try:
|
||||
response = httpx.get(config.settings.gerenciador_internal_url, headers=headers, timeout=config.settings.http_timeout_seconds)
|
||||
except httpx.HTTPError as error:
|
||||
raise ClientSyncUnavailable(f"request failed: {error}") from error
|
||||
if response.status_code != 200:
|
||||
raise ClientSyncUnavailable(f"unexpected status {response.status_code}: {response.text[:200]}")
|
||||
return response.json()["clients"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
fetched: int
|
||||
created: int
|
||||
updated: int
|
||||
unchanged: int
|
||||
active: int
|
||||
inactive: int
|
||||
|
||||
|
||||
def sync_from_gerenciador(session, clients: list[dict]) -> SyncResult:
|
||||
"""Upsert `clients` (each: external_client_id, code, display_name, active)
|
||||
into Tenant rows with source='GTI_GERENCIADOR'. Never touches a
|
||||
source='LOCAL' row. A display-name collision with another tenant is
|
||||
disambiguated with the external code rather than failing the whole sync
|
||||
over one client (Tenant.name is UNIQUE)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
existing_by_external = {row.external_client_id: row for row in session.scalars(select(Tenant).where(Tenant.source == SOURCE)).all()}
|
||||
taken_names = {row.name for row in session.scalars(select(Tenant)).all()}
|
||||
created = updated = unchanged = 0
|
||||
|
||||
for client in clients:
|
||||
external_id = client["external_client_id"]
|
||||
code = client["code"]
|
||||
active = bool(client["active"])
|
||||
wanted_name = (client.get("display_name") or "").strip() or f"Cliente {code}"
|
||||
row = existing_by_external.get(external_id)
|
||||
|
||||
if row is None:
|
||||
name = wanted_name if wanted_name not in taken_names else f"{wanted_name} ({code})"
|
||||
row = Tenant(name=name, source=SOURCE, external_client_id=external_id, external_code=code, active=active, synced_at=now)
|
||||
session.add(row)
|
||||
taken_names.add(name)
|
||||
created += 1
|
||||
continue
|
||||
|
||||
changed = False
|
||||
if row.name != wanted_name and wanted_name not in taken_names:
|
||||
taken_names.discard(row.name)
|
||||
row.name = wanted_name
|
||||
taken_names.add(wanted_name)
|
||||
changed = True
|
||||
if row.external_code != code:
|
||||
row.external_code = code
|
||||
changed = True
|
||||
if row.active != active:
|
||||
row.active = active
|
||||
changed = True
|
||||
row.synced_at = now
|
||||
if changed:
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
session.flush()
|
||||
active_count = sum(1 for c in clients if c["active"])
|
||||
return SyncResult(fetched=len(clients), created=created, updated=updated, unchanged=unchanged, active=active_count, inactive=len(clients) - active_count)
|
||||
@@ -52,6 +52,11 @@ class Settings:
|
||||
agent_download_link_ttl_seconds: int = int(os.getenv("AGENT_DOWNLOAD_LINK_TTL_SECONDS", "1800"))
|
||||
agent_download_max_uses_default: int = int(os.getenv("AGENT_DOWNLOAD_MAX_USES_DEFAULT", "1"))
|
||||
agent_enrollment_ttl_seconds: int = int(os.getenv("AGENT_ENROLLMENT_TTL_SECONDS", "86400"))
|
||||
# ---- Client mirror from GTI Gerenciador (central/app/client_sync.py) ----
|
||||
# GTI Gerenciador is the single source of truth for clients; the Fiscal Hub
|
||||
# never gets MySQL credentials. Both empty = sync disabled (documented).
|
||||
gerenciador_internal_url: str = os.getenv("GERENCIADOR_INTERNAL_URL", "") # e.g. https://gtigerenciador.../internal/fiscal/clients
|
||||
fiscal_internal_api_token: str = os.getenv("FISCAL_INTERNAL_API_TOKEN", "")
|
||||
ncm_dataset_url: str = os.getenv("NCM_DATASET_URL", "https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json")
|
||||
cest_dataset_url: str = os.getenv("CEST_DATASET_URL", "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18")
|
||||
dataset_storage_path: str = os.getenv("DATASET_STORAGE_PATH", "./data/datasets")
|
||||
|
||||
@@ -215,9 +215,22 @@ class RtcCredPresEntry(Base):
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""A client. GTI Gerenciador (the panel) is the single source of truth —
|
||||
see central/app/client_sync.py. `source='GTI_GERENCIADOR'` rows are
|
||||
upserted by that sync, keyed by `external_client_id` (Gerenciador's
|
||||
CLIENTE.ID, the stable technical PK — CLIENTE.CODIGO is editable and
|
||||
never used as identity, same reasoning as FUNCIONARIO_PAINEL.ID in SSO).
|
||||
`source='LOCAL'` rows predate this sync (dev/test tenants) and are never
|
||||
offered for a new installation."""
|
||||
__tablename__ = "tenants"
|
||||
__table_args__ = (UniqueConstraint("source", "external_client_id", name="uq_tenant_external_identity"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True)
|
||||
source: Mapped[str] = mapped_column(String(16), default="LOCAL", server_default="LOCAL") # LOCAL | GTI_GERENCIADOR
|
||||
external_client_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
external_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Agent(Base):
|
||||
|
||||
@@ -4,5 +4,5 @@ kept identical to this) and what an AgentRelease row's `version` column holds
|
||||
once published — never a second, independent version number. Fiscal semantics
|
||||
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
|
||||
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
|
||||
GTI_FISCAL_VERSION = "1.1.1"
|
||||
GTI_FISCAL_VERSION = "1.1.2"
|
||||
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
|
||||
|
||||
+19
-7
@@ -10,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import agent_distribution, config, enrollment, sso
|
||||
from . import agent_distribution, client_sync, config, enrollment, sso
|
||||
from .config import settings
|
||||
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
|
||||
from .context_keys import ALLOWED_KEYS, ENUMS
|
||||
@@ -200,14 +200,22 @@ def router(session_factory):
|
||||
return RedirectResponse("/agents/releases", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
def _active_gerenciador_tenants(session, search: str | None = None):
|
||||
# GTI Gerenciador is the single source of truth for clients (central/app/
|
||||
# client_sync.py) — a new installation never offers a LOCAL (dev/test) or
|
||||
# inactive tenant, so it can never pick a client that doesn't exist there.
|
||||
query = select(Tenant).where(Tenant.source == client_sync.SOURCE, Tenant.active.is_(True))
|
||||
if search: query = query.where(Tenant.name.ilike(f"%{search}%"))
|
||||
return session.scalars(query.order_by(Tenant.name)).all()
|
||||
|
||||
@routes.get("/agents/new", response_class=HTMLResponse)
|
||||
def new_installation_form(request: Request):
|
||||
def new_installation_form(request: Request, search: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
tenants = _active_gerenciador_tenants(session, search)
|
||||
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
|
||||
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None})
|
||||
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": None, "search": search or ""})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/new")
|
||||
@@ -224,13 +232,17 @@ def router(session_factory):
|
||||
errors = []
|
||||
tenant = session.get(Tenant, int(tenant_id)) if tenant_id and tenant_id.isdigit() else None
|
||||
release = session.get(AgentRelease, int(release_id)) if release_id and release_id.isdigit() else None
|
||||
if not tenant: errors.append("Selecione um cliente válido.")
|
||||
# Server-side, not just a hidden option in the form: a LOCAL or
|
||||
# inactive tenant can never receive a new installation, matching
|
||||
# GTI Gerenciador's own active/inactive rule.
|
||||
if tenant and (tenant.source != client_sync.SOURCE or not tenant.active): tenant = None
|
||||
if not tenant: errors.append("Selecione um cliente ativo válido (cadastrado no GTI Gerenciador).")
|
||||
if not display_name: errors.append("Informe um nome para a instalação.")
|
||||
if not release or release.status != "PUBLISHED": errors.append("Selecione uma versão publicada do Agent.")
|
||||
if errors:
|
||||
tenants = session.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
tenants = _active_gerenciador_tenants(session)
|
||||
releases = session.scalars(select(AgentRelease).where(AgentRelease.status == "PUBLISHED").order_by(AgentRelease.created_at.desc())).all()
|
||||
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": " ".join(errors)}, 400)
|
||||
return render(request, "agent_new_installation.html", {"tenants": tenants, "releases": releases, "auth": auth_data, "error": " ".join(errors), "search": ""}, 400)
|
||||
enrollment_uuid = str(uuid.uuid4())
|
||||
enr = AgentEnrollment(enrollment_uuid=enrollment_uuid, tenant_id=tenant.id, display_name=display_name, expected_hostname=expected_hostname, release_id=release.id, status="PENDING", created_by=admin.id)
|
||||
session.add(enr)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gti-fiscal-hub"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Administrative CLI: pull the client registry from GTI Gerenciador (the
|
||||
panel) and mirror it into Tenant rows (source='GTI_GERENCIADOR'). GTI
|
||||
Gerenciador is the single source of truth; the Fiscal Hub never gets MySQL
|
||||
credentials — this only calls the panel's read-only internal endpoint.
|
||||
|
||||
Usage (from inside the Hub container, or anywhere DATABASE_URL +
|
||||
GERENCIADOR_INTERNAL_URL + FISCAL_INTERNAL_API_TOKEN are reachable):
|
||||
|
||||
python central/scripts/sync_clients_from_gerenciador.py
|
||||
|
||||
Prints a reconciliation summary; never prints the bearer token.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "central"))
|
||||
|
||||
from sqlalchemy.orm import Session # noqa: E402
|
||||
|
||||
from app import client_sync # noqa: E402
|
||||
from app.db import engine # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
clients = client_sync.fetch_from_gerenciador()
|
||||
with Session(engine) as session:
|
||||
result = client_sync.sync_from_gerenciador(session, clients)
|
||||
session.commit()
|
||||
print(f"CLIENT_SYNC=ok fetched={result.fetched} created={result.created} updated={result.updated} unchanged={result.unchanged}")
|
||||
print(f"TOTAL_CLIENTS_GERENCIADOR={result.fetched} ACTIVE_CLIENTS_GERENCIADOR={result.active} INACTIVE_CLIENTS_GERENCIADOR={result.inactive}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except client_sync.ClientSyncUnavailable as error:
|
||||
print(f"CLIENT_SYNC=unavailable reason={error}")
|
||||
raise SystemExit(1)
|
||||
@@ -6,12 +6,14 @@
|
||||
{% if error %}<p class="error" role="alert">{{ error }}</p>{% endif %}
|
||||
{% if not releases %}
|
||||
<p class="muted">Nenhuma versão do Agent está publicada ainda. Publique uma release primeiro (<code>python central/scripts/publish_agent_release.py</code>) e ela aparecerá aqui.</p>
|
||||
{% elif not tenants %}
|
||||
<p class="muted">Nenhum cliente ativo encontrado no GTI Gerenciador. Verifique o cadastro por lá, ou rode a sincronização (<code>python central/scripts/sync_clients_from_gerenciador.py</code>).</p>
|
||||
{% else %}
|
||||
<form method="post" action="/agents/new">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Cliente
|
||||
<select name="tenant_id" required>
|
||||
<option value="">Selecione</option>
|
||||
<input type="text" id="filtro-cliente" placeholder="Buscar cliente..." autocomplete="off" oninput="filtrarClientes(this.value)">
|
||||
<select name="tenant_id" id="select-cliente" size="8" required>
|
||||
{% for tenant in tenants %}<option value="{{ tenant.id }}">{{ tenant.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
@@ -28,6 +30,14 @@
|
||||
</label>
|
||||
<p><button type="submit">Criar instalação</button></p>
|
||||
</form>
|
||||
<script>
|
||||
function filtrarClientes(termo) {
|
||||
var alvo = termo.trim().toLowerCase();
|
||||
document.querySelectorAll("#select-cliente option").forEach(function (opcao) {
|
||||
opcao.hidden = alvo.length > 0 && opcao.textContent.toLowerCase().indexOf(alvo) === -1;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<section class="panel">
|
||||
<h2>Detalhes</h2>
|
||||
<table>
|
||||
<tr><th>Cliente</th><td>{{ tenant.name if tenant else "-" }}</td></tr>
|
||||
<tr><th>Cliente</th><td>{{ tenant.name if tenant else "-" }}{% if tenant and not tenant.active %} <span class="badge critical">INATIVO</span>{% endif %}</td></tr>
|
||||
<tr><th>Nome</th><td>{{ enr.display_name }}</td></tr>
|
||||
<tr><th>Hostname esperado</th><td>{{ enr.expected_hostname or "não informado" }}</td></tr>
|
||||
<tr><th>Versão do Agent</th><td>{{ release.version if release else "-" }} ({{ release.platform if release else "" }}/{{ release.architecture if release else "" }})</td></tr>
|
||||
|
||||
@@ -93,7 +93,14 @@ def publish_release(factory, version=VERSION):
|
||||
|
||||
def tenant_id(factory):
|
||||
with factory() as session:
|
||||
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
|
||||
tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
|
||||
# /agents/new only offers active GTI_GERENCIADOR-sourced tenants
|
||||
# (central/app/client_sync.py) — flip the shared fixture tenant so
|
||||
# existing form-based tests keep working under that rule.
|
||||
if tenant.source != "GTI_GERENCIADOR" or not tenant.active:
|
||||
tenant.source, tenant.external_client_id, tenant.active = "GTI_GERENCIADOR", tenant.id, True
|
||||
session.commit()
|
||||
return tenant.id
|
||||
|
||||
|
||||
def audits(factory):
|
||||
@@ -228,6 +235,7 @@ def test_direct_authenticated_download_does_not_need_a_link(hub):
|
||||
def test_get_agents_new_renders_form_not_captured_by_dynamic_route(hub):
|
||||
client, factory = hub
|
||||
publish_release(factory)
|
||||
tenant_id(factory) # at least one active GTI_GERENCIADOR tenant, or the form shows the empty state instead
|
||||
login(client)
|
||||
response = client.get("/agents/new")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Client mirror from GTI Gerenciador (central/app/client_sync.py, web.py's
|
||||
/agents/new). GTI Gerenciador is the single source of truth: Tenant rows
|
||||
with source='GTI_GERENCIADOR' are upserted from it; source='LOCAL' rows
|
||||
(pre-existing dev/test tenants) are never touched or offered."""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import client_sync, config
|
||||
from central.app.models import Tenant
|
||||
from central.tests.test_control_plane import setup_db
|
||||
|
||||
CLIENTS = [
|
||||
{"external_client_id": 1, "code": "000001", "display_name": "Mercado Ativo", "active": True},
|
||||
{"external_client_id": 2, "code": "000002", "display_name": "Loja Inativa", "active": False},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def factory():
|
||||
return setup_db()
|
||||
|
||||
|
||||
def test_sync_creates_tenants_from_gerenciador(factory):
|
||||
with factory() as session:
|
||||
result = client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
assert result.created == 2 and result.updated == 0 and result.fetched == 2
|
||||
assert result.active == 1 and result.inactive == 1
|
||||
rows = {row.external_client_id: row for row in session.scalars(select(Tenant)).all()}
|
||||
assert rows[1].name == "Mercado Ativo" and rows[1].source == "GTI_GERENCIADOR" and rows[1].active is True
|
||||
assert rows[2].active is False and rows[2].external_code == "000002"
|
||||
|
||||
|
||||
def test_sync_is_idempotent_and_updates_changed_fields(factory):
|
||||
with factory() as session:
|
||||
client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
result = client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
assert result.created == 0 and result.unchanged == 2
|
||||
|
||||
renamed = [{**CLIENTS[0], "display_name": "Mercado Ativo Renomeado"}, CLIENTS[1]]
|
||||
result2 = client_sync.sync_from_gerenciador(session, renamed)
|
||||
session.commit()
|
||||
assert result2.updated == 1 and result2.unchanged == 1
|
||||
row = session.scalar(select(Tenant).where(Tenant.external_client_id == 1))
|
||||
assert row.name == "Mercado Ativo Renomeado"
|
||||
|
||||
|
||||
def test_sync_never_touches_local_tenants(factory):
|
||||
with factory() as session:
|
||||
session.add(Tenant(name="Acme", source="LOCAL"))
|
||||
session.commit()
|
||||
client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
local = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
|
||||
assert local.source == "LOCAL" and local.external_client_id is None
|
||||
|
||||
|
||||
def test_sync_disambiguates_a_name_collision_with_an_existing_tenant(factory):
|
||||
with factory() as session:
|
||||
session.add(Tenant(name="Mercado Ativo", source="LOCAL"))
|
||||
session.commit()
|
||||
client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
new_row = session.scalar(select(Tenant).where(Tenant.external_client_id == 1))
|
||||
assert new_row.name == "Mercado Ativo (000001)" # the LOCAL tenant keeps the plain name
|
||||
|
||||
|
||||
def test_deactivating_a_client_in_gerenciador_is_reflected_without_deleting_history(factory):
|
||||
with factory() as session:
|
||||
client_sync.sync_from_gerenciador(session, CLIENTS)
|
||||
session.commit()
|
||||
now_inactive = [{**CLIENTS[0], "active": False}, CLIENTS[1]]
|
||||
client_sync.sync_from_gerenciador(session, now_inactive)
|
||||
session.commit()
|
||||
row = session.scalar(select(Tenant).where(Tenant.external_client_id == 1))
|
||||
assert row.active is False # row (and every FK pointing at it) is kept — never deleted
|
||||
|
||||
|
||||
def test_disabled_without_configured_url_or_token(monkeypatch):
|
||||
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, gerenciador_internal_url="", fiscal_internal_api_token=""))
|
||||
assert client_sync.enabled() is False
|
||||
with pytest.raises(client_sync.ClientSyncUnavailable):
|
||||
client_sync.fetch_from_gerenciador()
|
||||
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
|
||||
|
||||
def test_version_endpoint_reports_release_and_fiscal_versions():
|
||||
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
|
||||
assert body["version"] == "1.1.1" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
assert body["version"] == "1.1.2" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
|
||||
|
||||
def test_production_refuses_the_dev_token(monkeypatch):
|
||||
|
||||
@@ -90,7 +90,14 @@ def io_bytes_bundle(version):
|
||||
|
||||
def tenant_id(factory):
|
||||
with factory() as session:
|
||||
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
|
||||
tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
|
||||
# /agents/new only offers active GTI_GERENCIADOR-sourced tenants
|
||||
# (central/app/client_sync.py) — flip the shared fixture tenant so
|
||||
# existing form-based tests keep working under that rule.
|
||||
if tenant.source != "GTI_GERENCIADOR" or not tenant.active:
|
||||
tenant.source, tenant.external_client_id, tenant.active = "GTI_GERENCIADOR", tenant.id, True
|
||||
session.commit()
|
||||
return tenant.id
|
||||
|
||||
|
||||
def make_installation(factory, release_id, name="Servidor E2E"):
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""GET/POST /agents/new only offers active GTI_GERENCIADOR-sourced tenants —
|
||||
GTI Gerenciador is the single source of truth for clients, never a local ad
|
||||
hoc list (central/app/client_sync.py, web.py)."""
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import config
|
||||
from central.app.main import create_app
|
||||
from central.app.models import AgentEnrollment, AgentRelease, Tenant
|
||||
from central.scripts.publish_agent_release import publish
|
||||
from central.tests.test_agent_distribution import make_release_zip, write_temp_zip
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
VERSION = "9.9.1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "settings", dataclasses.replace(config.settings, agent_release_storage_path=str(tmp_path / "releases"), web_cookie_secure=False))
|
||||
factory = setup_db()
|
||||
seed(factory) # tenant "Acme" (LOCAL, no source) + admin "operator"/"a-secure-password"
|
||||
with factory() as session:
|
||||
session.add(Tenant(name="Mercado Ativo", source="GTI_GERENCIADOR", external_client_id=1, active=True))
|
||||
session.add(Tenant(name="Loja Inativa", source="GTI_GERENCIADOR", external_client_id=2, active=False))
|
||||
release, _ = publish(session, write_temp_zip(make_release_zip(VERSION), VERSION), version=VERSION)
|
||||
release_id = release.id
|
||||
session.commit()
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
return client, factory, release_id
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def csrf(client):
|
||||
return client.cookies.get("gti_csrf", "")
|
||||
|
||||
|
||||
def test_new_installation_form_lists_only_active_gerenciador_tenants(hub):
|
||||
client, _factory, _release_id = hub
|
||||
login(client)
|
||||
body = client.get("/agents/new").text
|
||||
assert "Mercado Ativo" in body
|
||||
assert "Loja Inativa" not in body # inactive: excluded from the selector
|
||||
assert "Acme" not in body # LOCAL (not from GTI Gerenciador): excluded
|
||||
|
||||
|
||||
def test_cannot_create_installation_for_an_inactive_tenant(hub):
|
||||
client, factory, release_id = hub
|
||||
login(client)
|
||||
with factory() as session:
|
||||
inactive_id = session.scalar(select(Tenant).where(Tenant.name == "Loja Inativa")).id
|
||||
response = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": inactive_id, "display_name": "Teste", "release_id": release_id})
|
||||
assert response.status_code == 400 and "ativo" in response.text.lower()
|
||||
with factory() as session:
|
||||
assert session.scalar(select(AgentEnrollment)) is None
|
||||
|
||||
|
||||
def test_cannot_create_installation_for_a_local_tenant(hub):
|
||||
client, factory, release_id = hub
|
||||
login(client)
|
||||
with factory() as session:
|
||||
local_id = session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
|
||||
response = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": local_id, "display_name": "Teste", "release_id": release_id})
|
||||
assert response.status_code == 400
|
||||
with factory() as session:
|
||||
assert session.scalar(select(AgentEnrollment)) is None
|
||||
|
||||
|
||||
def test_creates_installation_for_an_active_gerenciador_tenant(hub):
|
||||
client, factory, release_id = hub
|
||||
login(client)
|
||||
with factory() as session:
|
||||
active_id = session.scalar(select(Tenant).where(Tenant.name == "Mercado Ativo")).id
|
||||
response = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": active_id, "display_name": "Teste", "release_id": release_id}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment))
|
||||
assert enr is not None and enr.tenant_id == active_id
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.1.0
|
||||
AGENT_VERSION=1.1.2
|
||||
|
||||
@@ -164,7 +164,7 @@ switch ($Command) {
|
||||
"GTI_HUB_URL=$hub", "AGENT_TOKEN=$token", "AGENT_ID=$agentId",
|
||||
"COMPANY_CODE=$code", "COMPANY_CNPJ=$cnpj", "COMPANY_CRT_SOURCE=$crtSource",
|
||||
"FIREBIRD_DATABASE_PATH=$db", "FB_USER=$fbUser", "FB_PASSWORD=$fbPass",
|
||||
'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.1.0'
|
||||
'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.1.2'
|
||||
)
|
||||
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
|
||||
}
|
||||
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.1.0
|
||||
AGENT_VERSION=1.1.2
|
||||
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.1.0
|
||||
AGENT_VERSION=1.1.2
|
||||
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.1.0
|
||||
AGENT_VERSION=1.1.2
|
||||
|
||||
@@ -19,4 +19,4 @@ FB_PASSWORD=
|
||||
FISCAL_MODE=DRY_RUN
|
||||
FIREBIRD_WRITES=0
|
||||
POLL_INTERVAL_SECONDS=5
|
||||
AGENT_VERSION=1.1.0
|
||||
AGENT_VERSION=1.1.2
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# GTI Fiscal Hub — production environment (copy to .env, never commit .env)
|
||||
GTI_FISCAL_VERSION=1.1.0
|
||||
GTI_FISCAL_VERSION=1.1.2
|
||||
|
||||
# REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24
|
||||
POSTGRES_PASSWORD=
|
||||
@@ -33,5 +33,13 @@ AGENT_ONLINE_THRESHOLD_SECONDS=120
|
||||
# AGENT_DOWNLOAD_MAX_USES_DEFAULT=1
|
||||
# AGENT_ENROLLMENT_TTL_SECONDS=86400
|
||||
|
||||
# Client mirror from GTI Gerenciador (docs/PILOT_DEPLOYMENT.md, "Client integration").
|
||||
# GTI Gerenciador is the single source of truth for clients — the Fiscal Hub never
|
||||
# gets MySQL credentials. Both empty = sync disabled (no client selector data).
|
||||
GERENCIADOR_INTERNAL_URL=https://gtigerenciador.example.gti/internal/fiscal/clients
|
||||
# Dedicated bearer token, shared only with the panel's FISCAL_INTERNAL_API_TOKEN —
|
||||
# never SESSION_SECRET, the SSO key, an admin password or an Agent token.
|
||||
FISCAL_INTERNAL_API_TOKEN=
|
||||
|
||||
# Directory (host) where pg_dump backups are written by backup.sh
|
||||
BACKUP_DIR=./backups
|
||||
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: central/Dockerfile
|
||||
image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.1.0}
|
||||
image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.1.2}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -65,6 +65,10 @@ services:
|
||||
AGENT_DOWNLOAD_LINK_TTL_SECONDS: ${AGENT_DOWNLOAD_LINK_TTL_SECONDS:-1800}
|
||||
AGENT_DOWNLOAD_MAX_USES_DEFAULT: ${AGENT_DOWNLOAD_MAX_USES_DEFAULT:-1}
|
||||
AGENT_ENROLLMENT_TTL_SECONDS: ${AGENT_ENROLLMENT_TTL_SECONDS:-86400}
|
||||
# Client mirror from GTI Gerenciador (central/app/client_sync.py): the panel is
|
||||
# the single source of truth for clients. Both empty = sync disabled.
|
||||
GERENCIADOR_INTERNAL_URL: ${GERENCIADOR_INTERNAL_URL:-}
|
||||
FISCAL_INTERNAL_API_TOKEN: ${FISCAL_INTERNAL_API_TOKEN:-}
|
||||
volumes:
|
||||
- agent_releases:/app/data/releases
|
||||
ports:
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"artifact": "RELEASE_MANIFEST",
|
||||
"GTI_FISCAL_VERSION": "1.1.1",
|
||||
"generated_at": "2026-09-23T16:10:58+00:00",
|
||||
"GTI_FISCAL_VERSION": "1.1.2",
|
||||
"generated_at": "2026-09-24T01:35:00+00:00",
|
||||
"hub": {
|
||||
"version": "1.1.1",
|
||||
"api_compat": "1.0",
|
||||
"engine_version": "0.3.0",
|
||||
"decision_version": "mission-04c-ipi-ex-split"
|
||||
},
|
||||
"agent_version": "1.1.1",
|
||||
"agent_version": "1.1.2",
|
||||
"fiscal_engine": {
|
||||
"ENGINE_VERSION": "0.3.0",
|
||||
"DECISION_VERSION": "mission-04c-ipi-ex-split",
|
||||
@@ -17,10 +17,10 @@
|
||||
"ICMS_CONTEXT_ENGINE_VERSION": "icms-context-v1"
|
||||
},
|
||||
"migrations": {
|
||||
"alembic_head_in_repo": "0023_install_codes",
|
||||
"applied_revision": "0023_install_codes",
|
||||
"applied_revision_source": "pending production deploy (Phase E of 1.1.1): pg_dump backup + alembic upgrade v3 HEAD will be verified on 10.0.10.117 before publishing 1.1.1",
|
||||
"count": 23
|
||||
"alembic_head_in_repo": "0024_tenant_gerenciador_mirror",
|
||||
"applied_revision": "0024_tenant_gerenciador_mirror",
|
||||
"applied_revision_source": "container projeto-fiscal-hub-1",
|
||||
"count": 24
|
||||
},
|
||||
"datasets_active": [
|
||||
{
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.1
|
||||
1.1.2
|
||||
|
||||
@@ -14,11 +14,11 @@ sys.path.insert(0, str(ROOT))
|
||||
def test_versions_are_coherent_across_hub_agent_and_packages():
|
||||
from central.app.version import GTI_FISCAL_VERSION
|
||||
v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip()
|
||||
assert v == GTI_FISCAL_VERSION == "1.1.1"
|
||||
assert v == GTI_FISCAL_VERSION == "1.1.2"
|
||||
pep = v.replace("-rc", "rc")
|
||||
for pp in ("central/pyproject.toml", "agent/pyproject.toml"):
|
||||
assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8")
|
||||
assert '"AGENT_VERSION", "1.1.1"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
|
||||
assert '"AGENT_VERSION", "1.1.2"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
|
||||
manifest = ROOT / "release" / "RELEASE_MANIFEST.json"
|
||||
if manifest.exists():
|
||||
m = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
|
||||
Reference in New Issue
Block a user