installer v1 (C+D): Windows EXE installer trio + build script + local Hub E2E
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
# Build the three Windows installer artifacts with PyInstaller:
|
||||
#
|
||||
# dist/installer/GTIFiscalAgent-Setup-<version>.exe (onefile, --uac-admin) <- main download artifact
|
||||
# dist/installer/GTIFiscalAgentService/<host> (onedir) <- embedded in Setup, extracted to Program Files
|
||||
# dist/installer/Uninstall.exe (onefile, --uac-admin) <- embedded in Setup, ARP uninstaller
|
||||
# dist/installer/GTIFiscalAgent-Setup-<version>.exe.sha256
|
||||
#
|
||||
# Reproducibility = automated, repeatable, deterministic inputs + immutable
|
||||
# PUBLISHED release (byte-identical hashes are NOT required).
|
||||
#
|
||||
# Requires (fails fast if missing): python with PyInstaller, pywin32, fdb,
|
||||
# httpx; icacls for the ACL on credentials.bin.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Python = "python",
|
||||
[switch]$SkipSetup = $false,
|
||||
[switch]$SkipService = $false,
|
||||
[switch]$SkipUninstall = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$Version = (Get-Content (Join-Path $Root "release\VERSION") -Raw).Trim()
|
||||
$Out = Join-Path $Root "dist\installer"
|
||||
$Work = Join-Path $Root "build\installer"
|
||||
$SpecDir = Join-Path $Root "build\installer"
|
||||
|
||||
function Invoke-Step([string]$Title, [scriptblock]$Body) {
|
||||
Write-Host "== $Title ==" -ForegroundColor Cyan
|
||||
& $Body
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Title failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
Write-Host "Building GTI Fiscal Agent installer v$Version" -ForegroundColor Green
|
||||
|
||||
$py = (Get-Command $Python).Source
|
||||
& $py -c "import PyInstaller, fdb, httpx, win32serviceutil; print('deps ok, pyinstaller', PyInstaller.__version__)"
|
||||
if ($LASTEXITCODE -ne 0) { throw "build environment is missing PyInstaller/fdb/httpx/pywin32" }
|
||||
if (-not (Get-Command "icacls" -ErrorAction SilentlyContinue)) { throw "missing required tool: icacls" }
|
||||
|
||||
$Paths = @("--paths", (Join-Path $Root "agent\src"), "--paths", (Join-Path $Root "agent\scripts"),
|
||||
"--paths", (Join-Path $Root "agent\service"), "--paths", (Join-Path $Root "central"))
|
||||
$Common = @("--noconfirm", "--clean", "--distpath", $Out, "--workpath", $Work, "--specpath", $SpecDir,
|
||||
"--log-level", "WARN")
|
||||
# win32crypt is imported lazily inside credential_store (function body), so the
|
||||
# AST scan misses it; the agent service modules also need the pywin32 service libs.
|
||||
$Hidden = @("--hidden-import=win32timezone", "--hidden-import=win32crypt",
|
||||
"--hidden-import=win32serviceutil", "--hidden-import=win32service",
|
||||
"--hidden-import=win32event", "--hidden-import=servicemanager")
|
||||
# None of the reachable runtime modules import these (verified by tracing
|
||||
# __import__ while loading run_agent_service's full chain); they only add
|
||||
# ~100MB+ of dead weight via hook-gathered submodules. Exclude them.
|
||||
$Excludes = @("--exclude-module=numpy", "--exclude-module=PIL", "--exclude-module=yaml",
|
||||
"--exclude-module=scipy", "--exclude-module=pandas", "--exclude-module=matplotlib",
|
||||
"--exclude-module=openpyxl", "--exclude-module=jinja2")
|
||||
|
||||
if (-not $SkipService) {
|
||||
Invoke-Step "Build service host (onedir)" -Body {
|
||||
& $py -m PyInstaller @Common @Paths @Hidden @Excludes "--onedir" `
|
||||
"--name" "GTIFiscalAgentService" (Join-Path $Root "installer\service_main.py")
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipUninstall) {
|
||||
Invoke-Step "Build Uninstall.exe (onefile, uac-admin)" -Body {
|
||||
& $py -m PyInstaller @Common @Paths @Hidden @Excludes "--onefile" "--uac-admin" "--name" "Uninstall" `
|
||||
(Join-Path $Root "installer\uninstall_main.py")
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipSetup) {
|
||||
$ServiceData = (Join-Path $Out "GTIFiscalAgentService") + ";" + "GTIFiscalAgentService"
|
||||
$UninstallData = (Join-Path $Out "Uninstall.exe") + ";" + "."
|
||||
$ExecutorData = (Join-Path $Root "scripts\firebird_write_executor.py") + ";" + "scripts"
|
||||
Invoke-Step "Build Setup.exe (onefile, uac-admin)" -Body {
|
||||
& $py -m PyInstaller @Common @Paths @Hidden @Excludes "--onefile" "--uac-admin" "--name" "GTIFiscalAgent-Setup" `
|
||||
"--add-data" $ServiceData "--add-data" $UninstallData "--add-data" $ExecutorData `
|
||||
(Join-Path $Root "installer\setup_main.py")
|
||||
}
|
||||
}
|
||||
|
||||
$BuiltSetup = Join-Path $Out "GTIFiscalAgent-Setup.exe"
|
||||
$FinalSetup = Join-Path $Out "GTIFiscalAgent-Setup-$Version.exe"
|
||||
if (Test-Path $BuiltSetup) {
|
||||
Move-Item -Force $BuiltSetup $FinalSetup
|
||||
$Digest = (Get-FileHash -Algorithm SHA256 -Path $FinalSetup).Hash.ToLowerInvariant()
|
||||
Set-Content -Path "$FinalSetup.sha256" -Value "$Digest $([IO.Path]::GetFileName($FinalSetup))`n" -NoNewline -Encoding Ascii
|
||||
Write-Host "Installer: $FinalSetup" -ForegroundColor Green
|
||||
Write-Host "sha256: $Digest" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Setup artifact not built (skipped); service host + Uninstall.exe still available under $Out" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Get-ChildItem $Out -Recurse | Select-Object FullName, Length | Format-Table -AutoSize
|
||||
Write-Host "Build complete." -ForegroundColor Green
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Local E2E hub for Phase D — Windows installer against a real HTTP Hub.
|
||||
|
||||
Boots the control plane against a scratch SQLite DB, seeds a tenant + admin,
|
||||
publishes the real built EXE as release 1.1.1, creates a PENDING enrollment,
|
||||
generates an install code, then serves uvicorn on 127.0.0.1:PORT.
|
||||
|
||||
The operator/Setup CLI then runs against this local Hub. Usage:
|
||||
|
||||
python scripts/e2e_local_hub.py --port 8901 --exe dist/installer/GTIFiscalAgent-Setup-1.1.1.exe
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "central"))
|
||||
|
||||
from central.app import config, enrollment # noqa: E402
|
||||
from central.app.models import AgentEnrollment, Tenant # noqa: E402
|
||||
from central.scripts.publish_agent_release import publish # noqa: E402
|
||||
from central.tests.test_control_plane import create_admin, seed # noqa: E402
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--exe", default=str(ROOT / "dist/installer/GTIFiscalAgent-Setup-1.1.1.exe"))
|
||||
args = parser.parse_args()
|
||||
|
||||
workdir = Path(tempfile.mkdtemp(prefix="gti_e2e_hub_"))
|
||||
print(f"[e2e] workdir={workdir}")
|
||||
|
||||
replaced = dataclasses.replace(
|
||||
config.settings,
|
||||
agent_release_storage_path=str(workdir / "releases"),
|
||||
base_url=f"http://127.0.0.1:{args.port}",
|
||||
web_cookie_secure=False,
|
||||
)
|
||||
config.settings = replaced
|
||||
if "app.config" in sys.modules:
|
||||
sys.modules["app.config"].settings = replaced
|
||||
|
||||
url = f"sqlite:///{workdir / 'e2e_hub.db'}"
|
||||
os.environ["DATABASE_URL"] = url
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.models import Base
|
||||
|
||||
engine = create_engine(url, connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
seed(factory)
|
||||
|
||||
with factory() as session:
|
||||
data = Path(args.exe)
|
||||
if not data.exists():
|
||||
print(f"[e2e] EXE artefact not found: {data}", file=sys.stderr)
|
||||
return 2
|
||||
release, _created = publish(session, data, version="1.1.1")
|
||||
release_id = release.id
|
||||
print(f"[e2e] published release {release.version} id={release_id}")
|
||||
|
||||
tenant = session.scalars(select(Tenant).limit(1)).first()
|
||||
enr = AgentEnrollment(enrollment_uuid=str(uuid.uuid4()), tenant_id=tenant.id,
|
||||
display_name="Servidor E2E LOCAL", release_id=release_id, status="PENDING")
|
||||
session.add(enr)
|
||||
session.flush()
|
||||
|
||||
code = enrollment.generate_install_code()
|
||||
enr.install_code_hash = enrollment.install_code_digest(code)
|
||||
enr.install_code_expires_at = datetime.now(timezone.utc) + timedelta(seconds=86400)
|
||||
enr.install_code_created_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
print(f"[e2e] enrollment_uuid={enr.enrollment_uuid}", flush=True)
|
||||
print(f"[e2e] INSTALL_CODE={code}", flush=True)
|
||||
(workdir / "install_code.txt").write_text(code)
|
||||
(workdir / "enrollment_uuid.txt").write_text(enr.enrollment_uuid)
|
||||
print(f"[e2e] workdir={workdir}", flush=True)
|
||||
|
||||
import uvicorn
|
||||
|
||||
from central.app.main import create_app
|
||||
|
||||
app = create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None)
|
||||
print(f"[e2e] serving hub on http://127.0.0.1:{args.port}")
|
||||
uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="info")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user