Fix deterministic Local Agent update package SHA256

This commit is contained in:
A R R R Associates
2026-08-19 10:20:25 +05:30
parent ad73df1a78
commit c8e7f880d7
+48 -9
View File
@@ -8,6 +8,12 @@ ERP_LOCAL_AGENT_VERSION = "1.3.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
# ZIP metadata is part of the ZIP byte stream. If writestr() is allowed to use
# the current clock time, two packages built from identical source files have
# different SHA256 hashes. Keep update packages reproducible so the SHA256
# returned by update-manifest is exactly the SHA256 of update-package.
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id=None, branch_id=None, sync_interval_seconds: int = 30, request_timeout_seconds: int = 60, tunnel_enabled: bool = True, tunnel_reconnect_seconds: int = 10) -> str: def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id=None, branch_id=None, sync_interval_seconds: int = 30, request_timeout_seconds: int = 60, tunnel_enabled: bool = True, tunnel_reconnect_seconds: int = 10) -> str:
erp_base_url = (erp_base_url or "").strip().rstrip("/") erp_base_url = (erp_base_url or "").strip().rstrip("/")
@@ -38,34 +44,67 @@ def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, stor
) )
def _zip_info(name: str) -> zipfile.ZipInfo:
info = zipfile.ZipInfo(filename=name, date_time=_DETERMINISTIC_ZIP_TIMESTAMP)
info.compress_type = zipfile.ZIP_DEFLATED
info.create_system = 3
# Regular file with rw-r--r-- permissions. Stable metadata keeps ZIP bytes
# reproducible on every request and across Linux deployments.
info.external_attr = (0o100644 & 0xFFFF) << 16
return info
def _write_zip_bytes(dst: zipfile.ZipFile, name: str, payload: bytes) -> None:
dst.writestr(_zip_info(name), payload)
def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes: def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes:
if not RUNTIME_ROOT.exists(): if not RUNTIME_ROOT.exists():
raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}") raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}")
buffer = io.BytesIO() buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as dst: with zipfile.ZipFile(
for path in sorted(RUNTIME_ROOT.rglob("*")): buffer,
"w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as dst:
for path in sorted(RUNTIME_ROOT.rglob("*"), key=lambda item: item.as_posix()):
if not path.is_file() or "__pycache__" in path.parts: if not path.is_file() or "__pycache__" in path.parts:
continue continue
name = path.relative_to(RUNTIME_ROOT).as_posix() name = path.relative_to(RUNTIME_ROOT).as_posix()
dst.writestr(name, path.read_bytes()) _write_zip_bytes(dst, name, path.read_bytes())
if include_env and env_text is not None: if include_env and env_text is not None:
dst.writestr(".env", env_text) _write_zip_bytes(dst, ".env", env_text.encode("utf-8"))
if include_admin_readme: if include_admin_readme:
dst.writestr( readme = (
"README_ERP_LOCAL_AGENT.txt",
f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n" f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
"Existing storage, WebSocket tunnel, Tally, accounting .act and local dashboard functionality are preserved.\n" "Existing storage, WebSocket tunnel, Tally, accounting .act and local dashboard functionality are preserved.\n"
"Local dashboard: http://127.0.0.1:8788\n" "Local dashboard: http://127.0.0.1:8788\n"
"The agent checks for updates automatically but installation is always initiated by the local user.\n" "The agent checks for updates automatically but installation is always initiated by the local user.\n"
"The existing .env, data, logs, .venv and client storage are preserved during updates.\n", "The existing .env, data, logs, .venv and client storage are preserved during updates.\n"
) )
_write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", readme.encode("utf-8"))
return buffer.getvalue() return buffer.getvalue()
def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes: def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes:
return _build_zip(env_text=env_text, include_env=True, include_admin_readme=include_admin_readme) return _build_zip(
env_text=env_text,
include_env=True,
include_admin_readme=include_admin_readme,
)
def build_agent_update_zip() -> bytes: def build_agent_update_zip() -> bytes:
# Update package deliberately excludes .env, .venv, data, logs and client files. # Update package deliberately excludes .env, .venv, data, logs and client files.
return _build_zip(env_text=None, include_env=False, include_admin_readme=False) # The ZIP is deterministic so update-manifest and update-package always agree
# on SHA256 for the same deployed runtime/version.
return _build_zip(
env_text=None,
include_env=False,
include_admin_readme=False,
)