installer v1 (A+B): install-code enrollment, panel code UI, EXE publish; DPAPI credential store

This commit is contained in:
gti
2026-09-23 16:38:19 -03:00
parent b058affa29
commit 91c4030b06
15 changed files with 1135 additions and 54 deletions
+40 -25
View File
@@ -1,19 +1,25 @@
"""Administrative CLI: publish a built Agent package (scripts/build_agent_package.py
output) as an AgentRelease, ready to be downloaded/enrolled from the panel.
"""Administrative CLI: publish a built Agent package as an AgentRelease, ready
to be downloaded/enrolled from the panel. Accepts either the legacy universal
ZIP (`scripts/build_agent_package.py` output) or the universal Windows
installer EXE (`scripts/build_windows_installer.ps1` output). Whatever is
published is served verbatim to every installation later.
Like register_agent.py, this is a one-time, operator-run action against
DATABASE_URL directly — never a browser upload endpoint, so the release
binary never has to pass through an HTTP request body. The zip is copied
binary never has to pass through an HTTP request body. The artifact is copied
into the Hub's local release storage (AGENT_RELEASE_STORAGE_PATH), never
into PostgreSQL; only its metadata (version, sha256, size, path) is a row.
Usage:
python central/scripts/publish_agent_release.py --zip dist/GTIFiscalAgent-1.0.0.zip \\
python central/scripts/publish_agent_release.py --artifact dist/GTIFiscalAgent-1.0.0.zip \\
--notes "First distributable build with secure enrollment"
python central/scripts/publish_agent_release.py --artifact dist/installer/GTIFiscalAgent-Setup-1.1.1.exe \\
--notes "Universal Windows installer with install-code enrollment"
Refuses to publish the same version+platform+architecture twice with a
different sha256 (an accidental respin must get a new version). Publishing
the exact same bytes again is a no-op (idempotent).
the exact same bytes again is a no-op (idempotent), and a PUBLISHED release
is never replaced with different content.
"""
import argparse
import hashlib
@@ -31,17 +37,18 @@ from app.db import engine # noqa: E402
from app.models import AgentRelease # noqa: E402
def publish(session, zip_path: Path, *, platform="windows", architecture="x64", notes=None, created_by=None, version=None):
data = zip_path.read_bytes()
def publish(session, artifact_path: Path, *, platform="windows", architecture="x64", notes=None, created_by=None, version=None, filename=None):
data = artifact_path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
version = version or _version_from_manifest(zip_path)
version = version or _version_from_artifact_name(artifact_path)
filename = filename or _release_filename(artifact_path, version, platform, architecture)
existing = session.query(AgentRelease).filter_by(version=version, platform=platform, architecture=architecture).one_or_none()
if existing:
if existing.sha256 == digest:
if existing.sha256 == digest and existing.filename == filename:
return existing, False # identical content already published: idempotent no-op
raise SystemExit(f"refused: {version}/{platform}/{architecture} is already published with a different sha256 "
f"({existing.sha256[:12]}... != {digest[:12]}...) — bump the version instead of overwriting it")
filename = release_storage.release_filename(version, platform, architecture)
raise SystemExit(f"refused: {version}/{platform}/{architecture} is already published "
f"(sha256 {existing.sha256[:12]}..., filename {existing.filename!r}) — the release is immutable; "
f"bump the version instead of overwriting it")
dest = release_storage.storage_root() / filename
dest.write_bytes(data)
release = AgentRelease(version=version, platform=platform, architecture=architecture, filename=filename,
@@ -53,32 +60,40 @@ def publish(session, zip_path: Path, *, platform="windows", architecture="x64",
return release, True
def _version_from_manifest(zip_path: Path) -> str:
# The package name is `GTIFiscalAgent-<version>.zip`; avoids depending on
# zip internals just to learn the version this script itself is publishing.
stem = zip_path.stem
prefix = "GTIFiscalAgent-"
if not stem.startswith(prefix):
raise SystemExit(f"cannot infer version from filename {zip_path.name!r} — pass --version explicitly")
return stem[len(prefix):]
def _release_filename(artifact_path: Path, version: str, platform: str, architecture: str) -> str:
"""Filename used for the stored artifact. Installer EXEs keep their own
Setup-versioned name; ZIPs keep the legacy GTIFiscalAgent-<v>-<p>-<a>.zip."""
if artifact_path.name.lower().endswith(".exe"):
return artifact_path.name if artifact_path.name.lower() == f"gtifiscalagent-setup-{version}.exe" else release_storage.setup_exe_filename(version)
return release_storage.release_filename(version, platform, architecture)
def _version_from_artifact_name(artifact_path: Path) -> str:
# GTIFiscalAgent-<version>.zip / GTIFiscalAgent-Setup-<version>.exe
stem = artifact_path.stem
for prefix in ("GTIFiscalAgent-Setup-", "GTIFiscalAgent-"):
if stem.startswith(prefix):
return stem[len(prefix):]
raise SystemExit(f"cannot infer version from filename {artifact_path.name!r} — pass --version explicitly")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--zip", required=True, type=Path, help="Path to the built package zip (scripts/build_agent_package.py output)")
parser.add_argument("--artifact", required=True, type=Path, help="Path to the built package zip or installer exe")
parser.add_argument("--version", default=None, help="Override the version instead of inferring it from the filename")
parser.add_argument("--platform", default="windows")
parser.add_argument("--architecture", default="x64")
parser.add_argument("--notes", default=None, help="Release notes shown in the panel")
args = parser.parse_args()
if not args.zip.exists():
raise SystemExit(f"not found: {args.zip}")
if not args.artifact.exists():
raise SystemExit(f"not found: {args.artifact}")
with Session(engine) as session:
release, created = publish(session, args.zip, platform=args.platform, architecture=args.architecture, notes=args.notes, version=args.version)
release, created = publish(session, args.artifact, platform=args.platform, architecture=args.architecture, notes=args.notes, version=args.version)
print(f"{'published' if created else 'already published (idempotent)'}: version={release.version} "
f"platform={release.platform} architecture={release.architecture} sha256={release.sha256} size_bytes={release.size_bytes}")
f"platform={release.platform} architecture={release.architecture} filename={release.filename} "
f"sha256={release.sha256} size_bytes={release.size_bytes}")
if __name__ == "__main__":