Fix deterministic Local Agent update package SHA256
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user