Hub: installations.processing_enabled / sync_interval_seconds / last_sync_* (migration 0026). The first heartbeat of an installation without coverage queues exactly one INITIAL_SYNC; afterwards one SYNC_INCREMENTAL per interval (idempotent, backs off on failure). One fiscal job at a time per Agent (FIFO, stale RUNNING recovery). Job completion now updates the coverage snapshot, blockers and JOB_STARTED/COMPLETED/FAILED events. Panel: Sincronizar agora / Reprocessar tudo / Pausar / Retomar + status; pause is enforced Hub-side. Agent: local AGENT_JOBS_ENABLED is ignored (central control); SYNC_INCREMENTAL re-classifies only new/changed products via a per-product fingerprint+decision cache and rebuilds coverage from it; results carry operational summary and blockers; Firebird credentials come from a DPAPI firebird.bin. Setup: read-only SGV Firebird discovery (auto-select only when unambiguous, wizard chooser otherwise), starts the service, waits for the first heartbeat, upgrade-in-place mode. All jobs stay read-only (FIREBIRD_WRITES=0). Version 1.1.3. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
216 lines
13 KiB
PowerShell
216 lines
13 KiB
PowerShell
<#
|
|
GTI Fiscal Agent - Windows operations script (RC1). No Python editing needed.
|
|
|
|
Usage (elevated PowerShell for install/install-auto/start/stop/restart/uninstall):
|
|
.\gti-agent.ps1 install-auto bundle downloaded from the panel's "Nova instalação": verifies the
|
|
package's SHA-256, enrolls with the Hub using enrollment.json (deleted
|
|
right after, single use), writes .env, registers the service. Does NOT
|
|
start it — Firebird is still unconfigured; fill it in and run start.
|
|
.\gti-agent.ps1 install manual path: create venv, install dependencies, register the Windows Service (does NOT start it)
|
|
.\gti-agent.ps1 configure [-FromTemplate f] write <install>\.env (secrets are prompted, never echoed, never stored in the package)
|
|
.\gti-agent.ps1 preflight PREFLIGHT=PASS|FAIL with blockers (read-only)
|
|
.\gti-agent.ps1 start | stop | restart service control (start refuses to run when preflight fails)
|
|
.\gti-agent.ps1 status service state + last log lines
|
|
.\gti-agent.ps1 uninstall [-Purge] remove the service; -Purge also deletes venv/logs/state (never the .env unless -Purge)
|
|
|
|
The package folder is the install folder. Requires Python 3.12 (64-bit) on PATH and the Firebird client (fbclient.dll) already used by the SGV.
|
|
#>
|
|
param(
|
|
[Parameter(Position = 0, Mandatory = $true)]
|
|
[ValidateSet('install', 'install-auto', 'configure', 'preflight', 'start', 'stop', 'restart', 'status', 'uninstall')]
|
|
[string]$Command,
|
|
[string]$FromTemplate,
|
|
[switch]$Purge
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
$ServiceName = 'GTIFiscalAgent'
|
|
$Venv = Join-Path $Root 'venv'
|
|
$Py = Join-Path $Venv 'Scripts\python.exe'
|
|
$EnvFile = Join-Path $Root '.env'
|
|
$LogFile = Join-Path $Root 'agent\logs\agent.log'
|
|
|
|
function Assert-Admin {
|
|
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this command from an elevated (Administrator) PowerShell.' }
|
|
}
|
|
|
|
function Read-Plain([string]$Prompt, [string]$Default = '') {
|
|
$suffix = if ($Default) { " [$Default]" } else { '' }
|
|
$value = Read-Host "$Prompt$suffix"
|
|
if ([string]::IsNullOrWhiteSpace($value)) { return $Default } else { return $value.Trim() }
|
|
}
|
|
|
|
function Read-Secret([string]$Prompt) {
|
|
$secure = Read-Host $Prompt -AsSecureString
|
|
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
|
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
|
}
|
|
|
|
switch ($Command) {
|
|
'install' {
|
|
Assert-Admin
|
|
if (-not (Get-Command python -ErrorAction SilentlyContinue)) { throw 'Python 3.12 (64-bit) is required on PATH.' }
|
|
if (-not (Test-Path $Py)) { python -m venv $Venv }
|
|
& $Py -m pip install --upgrade pip | Out-Null
|
|
& $Py -m pip install -r (Join-Path $Root 'requirements-agent.txt')
|
|
$post = Join-Path $Venv 'Scripts\pywin32_postinstall.py'
|
|
if (Test-Path $post) { & $Py $post -install | Out-Null }
|
|
$svc = Join-Path $Root 'agent\service\gti_agent_windows_service.py'
|
|
& $Py $svc --startup=auto install
|
|
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null
|
|
Write-Host "Service $ServiceName registered (Automatic). Next: .\gti-agent.ps1 configure ; .\gti-agent.ps1 preflight ; .\gti-agent.ps1 start"
|
|
}
|
|
'install-auto' {
|
|
Assert-Admin
|
|
$EnrollmentFile = Join-Path $Root 'enrollment.json'
|
|
$ManifestFile = Join-Path $Root 'MANIFEST.json'
|
|
|
|
# Idempotent: a second run after a successful enroll must not re-enroll,
|
|
# re-register the service, or touch the existing identity.
|
|
if ((Test-Path $EnvFile) -and (Select-String -Path $EnvFile -Pattern '^AGENT_TOKEN=\S' -Quiet) -and (Get-Service $ServiceName -ErrorAction SilentlyContinue)) {
|
|
Write-Host "Already enrolled and installed ($EnvFile exists, service $ServiceName registered). Nothing to do."
|
|
exit 0
|
|
}
|
|
if (-not (Test-Path $EnrollmentFile)) { throw "enrollment.json not found in $Root — this bundle was already installed, or was not downloaded from a pending installation link." }
|
|
if (-not (Get-Command python -ErrorAction SilentlyContinue)) { throw 'Python 3.12 (64-bit) is required on PATH.' }
|
|
|
|
$enrollment = Get-Content $EnrollmentFile -Raw | ConvertFrom-Json
|
|
if ($enrollment.hub_url -notmatch '^https://') { throw "Refusing: hub_url must be https:// (got: $($enrollment.hub_url))" }
|
|
|
|
# Package integrity: every file this manifest lists must still match its
|
|
# recorded SHA-256 — catches a corrupted or tampered extraction before
|
|
# anything in it runs or is installed as a service.
|
|
if (Test-Path $ManifestFile) {
|
|
$manifest = Get-Content $ManifestFile -Raw | ConvertFrom-Json
|
|
$checked = 0
|
|
foreach ($entry in $manifest.files.PSObject.Properties) {
|
|
$target = Join-Path $Root $entry.Name
|
|
if (-not (Test-Path $target)) { throw "SHA256_VALIDATION=FAIL: missing file $($entry.Name)" }
|
|
$actual = (Get-FileHash -Path $target -Algorithm SHA256).Hash.ToLower()
|
|
if ($actual -ne $entry.Value) { throw "SHA256_VALIDATION=FAIL: $($entry.Name) does not match the published manifest" }
|
|
$checked++
|
|
}
|
|
Write-Host "SHA256_VALIDATION=PASS ($checked files)"
|
|
} else {
|
|
Write-Host 'SHA256_VALIDATION=SKIPPED (no MANIFEST.json in this bundle)'
|
|
}
|
|
|
|
$agentVersion = ((Get-Content (Join-Path $Root 'VERSION') -Raw) -replace '\s', '')
|
|
$machineId = try { (Get-CimInstance Win32_ComputerSystemProduct -ErrorAction Stop).UUID } catch { '' }
|
|
$body = @{
|
|
installation_id = $enrollment.installation_id
|
|
enrollment_token = $enrollment.enrollment_token
|
|
agent_version = $agentVersion
|
|
hostname = $env:COMPUTERNAME
|
|
platform = 'windows'
|
|
machine_fingerprint = $machineId
|
|
} | ConvertTo-Json
|
|
|
|
Write-Host "Enrolling installation $($enrollment.installation_id) at $($enrollment.hub_url)..."
|
|
try {
|
|
$response = Invoke-RestMethod -Method Post -Uri "$($enrollment.hub_url)$($enrollment.enroll_path)" -Body $body -ContentType 'application/json'
|
|
} catch {
|
|
throw "ENROLLMENT_REJECTED: $($_.Exception.Message)"
|
|
}
|
|
if ($response.hostname_mismatch) {
|
|
Write-Warning 'hostname_mismatch=true: the operator expected a different hostname for this installation. Enrollment still succeeded — confirm this is the intended machine.'
|
|
}
|
|
|
|
# The Hub already rejects any reuse of this token, but the bundle's own
|
|
# copy is deleted immediately too, so it never lingers on disk.
|
|
Remove-Item $EnrollmentFile -Force
|
|
|
|
$lines = @(
|
|
"GTI_HUB_URL=$($response.hub_url)", "AGENT_TOKEN=$($response.agent_token)", "AGENT_ID=$($response.agent_id)",
|
|
'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', "AGENT_VERSION=$agentVersion",
|
|
'FIREBIRD_DATABASE_PATH=', 'FB_USER=SYSDBA', 'FB_PASSWORD=', 'COMPANY_CODE=', 'COMPANY_CNPJ=', 'COMPANY_CRT_SOURCE=AUTO'
|
|
)
|
|
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
|
|
# Only Administrators and SYSTEM may read the file that now holds AGENT_TOKEN.
|
|
icacls $EnvFile /inheritance:r /grant:r 'Administrators:(F)' 'SYSTEM:(F)' | Out-Null
|
|
|
|
if (-not (Test-Path $Py)) { python -m venv $Venv }
|
|
& $Py -m pip install --upgrade pip | Out-Null
|
|
& $Py -m pip install -r (Join-Path $Root 'requirements-agent.txt')
|
|
$post = Join-Path $Venv 'Scripts\pywin32_postinstall.py'
|
|
if (Test-Path $post) { & $Py $post -install | Out-Null }
|
|
$svc = Join-Path $Root 'agent\service\gti_agent_windows_service.py'
|
|
& $Py $svc --startup=auto install
|
|
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null
|
|
|
|
Write-Host ''
|
|
Write-Host "ENROLLMENT_SUCCEEDED: agent_id=$($response.agent_id) hub=$($response.hub_url)"
|
|
Write-Host "Service $ServiceName registered (Automatic) but NOT started — Firebird is still unconfigured (by design: this script never guesses a client's database path or credentials)."
|
|
Write-Host "Next: edit $EnvFile (FIREBIRD_DATABASE_PATH, FB_USER, FB_PASSWORD, and COMPANY_CODE/COMPANY_CNPJ if needed),"
|
|
Write-Host ' then: .\gti-agent.ps1 preflight ; .\gti-agent.ps1 start'
|
|
}
|
|
'configure' {
|
|
if ($FromTemplate) {
|
|
Copy-Item -Path $FromTemplate -Destination $EnvFile -Force
|
|
Write-Host "Template copied to $EnvFile. Fill the empty secret values (AGENT_TOKEN, FB_PASSWORD) with a text editor, then run preflight."
|
|
} else {
|
|
$hub = Read-Plain 'Hub URL (https://...)'
|
|
$token = Read-Secret 'AGENT_TOKEN (from register_agent.py)'
|
|
$agentId = Read-Plain 'AGENT_ID (label for this installation)'
|
|
$db = Read-Plain 'FIREBIRD_DATABASE_PATH (real path of the SGV .FDB on THIS host)'
|
|
$fbUser = Read-Plain 'FB_USER (read-only account)' 'SYSDBA'
|
|
$fbPass = Read-Secret 'FB_PASSWORD'
|
|
$code = Read-Plain 'COMPANY_CODE (C000004.CODIGO; empty = auto when unambiguous)'
|
|
$cnpj = Read-Plain 'COMPANY_CNPJ (digits; optional alternative to COMPANY_CODE)'
|
|
$crtSource = Read-Plain 'COMPANY_CRT_SOURCE (AUTO | EMISSOR | C000004)' 'AUTO'
|
|
$lines = @(
|
|
"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.3'
|
|
)
|
|
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
|
|
}
|
|
# Only Administrators and SYSTEM may read the file that holds the secrets.
|
|
icacls $EnvFile /inheritance:r /grant:r 'Administrators:(F)' 'SYSTEM:(F)' | Out-Null
|
|
Write-Host "Configuration saved to $EnvFile (restricted ACL). Secrets are not part of the package."
|
|
}
|
|
'preflight' {
|
|
if (-not (Test-Path $Py)) { throw 'Run install first.' }
|
|
& $Py (Join-Path $Root 'agent\scripts\preflight.py') --env-file $EnvFile
|
|
exit $LASTEXITCODE
|
|
}
|
|
'start' {
|
|
Assert-Admin
|
|
& $Py (Join-Path $Root 'agent\scripts\preflight.py') --env-file $EnvFile
|
|
if ($LASTEXITCODE -ne 0) { throw 'PREFLIGHT=FAIL: fix the blockers above before starting the service.' }
|
|
Start-Service $ServiceName
|
|
Write-Host "$ServiceName started."
|
|
}
|
|
'stop' { Assert-Admin; Stop-Service $ServiceName -Force; Write-Host "$ServiceName stopped." }
|
|
'restart' {
|
|
Assert-Admin
|
|
& $Py (Join-Path $Root 'agent\scripts\preflight.py') --env-file $EnvFile
|
|
if ($LASTEXITCODE -ne 0) { throw 'PREFLIGHT=FAIL: not restarting.' }
|
|
Restart-Service $ServiceName -Force
|
|
Write-Host "$ServiceName restarted."
|
|
}
|
|
'status' {
|
|
$svc = Get-Service $ServiceName -ErrorAction SilentlyContinue
|
|
if ($null -eq $svc) { Write-Host 'Service not installed.'; exit 1 }
|
|
Write-Host ("Service {0}: {1} (StartType {2})" -f $svc.Name, $svc.Status, $svc.StartType)
|
|
if (Test-Path $LogFile) { Write-Host '--- last log lines ---'; Get-Content $LogFile -Tail 15 }
|
|
}
|
|
'uninstall' {
|
|
Assert-Admin
|
|
$svcScript = Join-Path $Root 'agent\service\gti_agent_windows_service.py'
|
|
if (Get-Service $ServiceName -ErrorAction SilentlyContinue) {
|
|
Stop-Service $ServiceName -Force -ErrorAction SilentlyContinue
|
|
if (Test-Path $Py) { & $Py $svcScript remove }
|
|
}
|
|
if ($Purge) {
|
|
foreach ($p in @($Venv, (Join-Path $Root 'agent\logs'), (Join-Path $Root 'agent\agent_state.sqlite'), $EnvFile)) { if (Test-Path $p) { Remove-Item $p -Recurse -Force } }
|
|
Write-Host 'Service removed; venv, logs, state and .env deleted.'
|
|
} else {
|
|
Write-Host 'Service removed. Configuration (.env), logs and state were kept (use -Purge to delete them).'
|
|
}
|
|
}
|
|
}
|