Repair Local Agent automatic update flow

This commit is contained in:
A R R R Associates
2026-09-03 12:30:54 +05:30
parent 7dff5ba393
commit 010d0969b4
4 changed files with 74 additions and 23 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.0" ERP_LOCAL_AGENT_VERSION = "1.22.2"
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"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -23,7 +23,7 @@ def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, stor
f"STORAGE_ROOT={storage_root}\n" f"TENANT_ID={'' if tenant_id is None else tenant_id}\n" f"AUDIT_FIRM_ID={'' if tenant_id is None else tenant_id}\n" f"STORAGE_ROOT={storage_root}\n" f"TENANT_ID={'' if tenant_id is None else tenant_id}\n" f"AUDIT_FIRM_ID={'' if tenant_id is None else tenant_id}\n"
f"BRANCH_ID={'' if branch_id is None else branch_id}\n" f"SYNC_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" f"POLL_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" f"BRANCH_ID={'' if branch_id is None else branch_id}\n" f"SYNC_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" f"POLL_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n"
f"REQUEST_TIMEOUT_SECONDS={int(request_timeout_seconds or 60)}\n" f"TUNNEL_ENABLED={str(bool(tunnel_enabled)).lower()}\n" f"TUNNEL_RECONNECT_SECONDS={int(tunnel_reconnect_seconds or 10)}\n" f"REQUEST_TIMEOUT_SECONDS={int(request_timeout_seconds or 60)}\n" f"TUNNEL_ENABLED={str(bool(tunnel_enabled)).lower()}\n" f"TUNNEL_RECONNECT_SECONDS={int(tunnel_reconnect_seconds or 10)}\n"
"AUTO_UPDATE=true\nAUTO_INSTALL_UPDATES=false\nUPDATE_CHECK_INTERVAL_SECONDS=300\nDASHBOARD_ENABLED=true\nDASHBOARD_HOST=127.0.0.1\nDASHBOARD_PORT=8788\n" "AUTO_UPDATE=true\nAUTO_INSTALL_UPDATES=true\nMANUAL_UPDATE_ONLY=false\nUPDATE_CHECK_INTERVAL_SECONDS=300\nDASHBOARD_ENABLED=true\nDASHBOARD_HOST=127.0.0.1\nDASHBOARD_PORT=8788\n"
) )
@@ -1,2 +1,2 @@
__version__ = "1.22.1" __version__ = "1.22.2"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -29,7 +29,8 @@ class AgentConfig:
tunnel_reconnect_seconds: int = 10 tunnel_reconnect_seconds: int = 10
auto_update: bool = True auto_update: bool = True
update_check_interval_seconds: int = 300 update_check_interval_seconds: int = 300
auto_install_updates: bool = False auto_install_updates: bool = True
manual_update_only: bool = False
dashboard_enabled: bool = True dashboard_enabled: bool = True
dashboard_host: str = "127.0.0.1" dashboard_host: str = "127.0.0.1"
dashboard_port: int = 8788 dashboard_port: int = 8788
@@ -107,6 +108,13 @@ def load_config(env_file: str | None = None) -> AgentConfig:
storage_root = Path(storage_root_raw).expanduser().resolve() storage_root = Path(storage_root_raw).expanduser().resolve()
storage_root.mkdir(parents=True, exist_ok=True) storage_root.mkdir(parents=True, exist_ok=True)
auto_update = _get_bool("AUTO_UPDATE", True)
# Packages through 1.22.1 generated AUTO_INSTALL_UPDATES=false even though the ERP
# described updates as automatic. Treat that legacy generated setting as migrated
# unless the administrator opts into the new explicit MANUAL_UPDATE_ONLY switch.
manual_update_only = _get_bool("MANUAL_UPDATE_ONLY", False)
auto_install_updates = bool(auto_update and not manual_update_only)
return AgentConfig( return AgentConfig(
erp_base_url=erp_base_url, erp_base_url=erp_base_url,
node_code=node_code, node_code=node_code,
@@ -122,9 +130,10 @@ def load_config(env_file: str | None = None) -> AgentConfig:
max_retries=_get_int("MAX_RETRIES", 3), max_retries=_get_int("MAX_RETRIES", 3),
tunnel_enabled=_get_bool("TUNNEL_ENABLED", True), tunnel_enabled=_get_bool("TUNNEL_ENABLED", True),
tunnel_reconnect_seconds=_get_int("TUNNEL_RECONNECT_SECONDS", 10), tunnel_reconnect_seconds=_get_int("TUNNEL_RECONNECT_SECONDS", 10),
auto_update=_get_bool("AUTO_UPDATE", True), auto_update=auto_update,
update_check_interval_seconds=max(60, _get_int("UPDATE_CHECK_INTERVAL_SECONDS", 300)), update_check_interval_seconds=max(60, _get_int("UPDATE_CHECK_INTERVAL_SECONDS", 300)),
auto_install_updates=_get_bool("AUTO_INSTALL_UPDATES", False), auto_install_updates=auto_install_updates,
manual_update_only=manual_update_only,
dashboard_enabled=_get_bool("DASHBOARD_ENABLED", True), dashboard_enabled=_get_bool("DASHBOARD_ENABLED", True),
dashboard_host=os.getenv("DASHBOARD_HOST", "127.0.0.1").strip() or "127.0.0.1", dashboard_host=os.getenv("DASHBOARD_HOST", "127.0.0.1").strip() or "127.0.0.1",
dashboard_port=max(1, min(65535, _get_int("DASHBOARD_PORT", 8788))), dashboard_port=max(1, min(65535, _get_int("DASHBOARD_PORT", 8788))),
@@ -3,8 +3,8 @@ from __future__ import annotations
import hashlib import hashlib
import os import os
from pathlib import Path from pathlib import Path
import re
import shutil import shutil
import subprocess
import threading import threading
import time import time
import zipfile import zipfile
@@ -12,11 +12,28 @@ import zipfile
from . import __version__ from . import __version__
class AgentUpdater: def _version_tuple(value: str) -> tuple[int, ...]:
"""Manual-install updater. """Return a stable numeric tuple for ERP Local Agent release versions."""
parts = [int(piece) for piece in re.findall(r"\d+", str(value or ""))]
return tuple(parts or [0])
Background work checks the ERP manifest only. Download and installation happen only
when the local user requests them from the dashboard. def _is_newer(latest: str, current: str) -> bool:
left = list(_version_tuple(latest))
right = list(_version_tuple(current))
width = max(len(left), len(right))
left.extend([0] * (width - len(left)))
right.extend([0] * (width - len(right)))
return tuple(left) > tuple(right)
class AgentUpdater:
"""Supervisor-controlled ERP Local Agent updater.
The worker checks the ERP manifest in the background. When automatic installation
is enabled it downloads the hash-verified package, stages it, and hands installation
to the persistent desktop supervisor. Manual dashboard download/install actions are
preserved and use the same code paths.
""" """
def __init__(self, config, client, logger, install_dir: Path, db=None): def __init__(self, config, client, logger, install_dir: Path, db=None):
@@ -48,7 +65,7 @@ class AgentUpdater:
latest = str(manifest.get("latest_version") or "").strip() latest = str(manifest.get("latest_version") or "").strip()
expected = str(manifest.get("sha256") or "").lower().strip() expected = str(manifest.get("sha256") or "").lower().strip()
self.latest_manifest = dict(manifest or {}) self.latest_manifest = dict(manifest or {})
available = bool(latest and latest != __version__) available = bool(latest and _is_newer(latest, __version__))
if self.db is not None: if self.db is not None:
self.db.set_meta("latest_agent_version", latest or __version__) self.db.set_meta("latest_agent_version", latest or __version__)
self.db.set_meta("update_available", "1" if available else "0") self.db.set_meta("update_available", "1" if available else "0")
@@ -65,8 +82,27 @@ class AgentUpdater:
return False return False
try: try:
state = self.check_for_update(force=force) state = self.check_for_update(force=force)
return bool(state.get("update_available")) available = bool(state.get("update_available"))
except Exception: if not available:
return False
if not self.config.auto_install_updates:
return True
latest = str(state.get("latest_version") or "").strip()
request_path = self.install_dir / "updates" / "supervisor_request.json"
if request_path.exists():
# The persistent supervisor will consume the pending request. Avoid
# repeatedly downloading the same package while restart is underway.
return True
staged_version = str(state.get("staged_version") or "").strip()
if not state.get("downloaded") or staged_version != latest:
state = self.download_latest()
if state.get("downloaded"):
self.install_downloaded()
return True
except Exception as exc:
self.logger.exception("ERP Local Agent automatic update failed: %s", exc)
return False return False
def download_latest(self) -> dict: def download_latest(self) -> dict:
@@ -79,7 +115,7 @@ class AgentUpdater:
self.check_for_update(force=True) self.check_for_update(force=True)
latest = str(self.latest_manifest.get("latest_version") or "").strip() latest = str(self.latest_manifest.get("latest_version") or "").strip()
expected = str(self.latest_manifest.get("sha256") or "").lower().strip() expected = str(self.latest_manifest.get("sha256") or "").lower().strip()
if not latest or latest == __version__: if not latest or not _is_newer(latest, __version__):
return self.status() return self.status()
package = self.client.download_update_package() package = self.client.download_update_package()
actual = hashlib.sha256(package).hexdigest().lower() actual = hashlib.sha256(package).hexdigest().lower()
@@ -97,6 +133,8 @@ class AgentUpdater:
archive.extractall(staged) archive.extractall(staged)
if not (staged / "erp_local_agent" / "__init__.py").exists(): if not (staged / "erp_local_agent" / "__init__.py").exists():
raise RuntimeError("ERP Local Agent update package is incomplete.") raise RuntimeError("ERP Local Agent update package is incomplete.")
if not (staged / "requirements.txt").exists():
raise RuntimeError("ERP Local Agent update package is missing requirements.txt.")
self._record("download", latest, "downloaded", sha256=actual, package_path=str(package_path)) self._record("download", latest, "downloaded", sha256=actual, package_path=str(package_path))
if self.db is not None: if self.db is not None:
self.db.set_meta("staged_update_version", latest) self.db.set_meta("staged_update_version", latest)
@@ -110,11 +148,7 @@ class AgentUpdater:
self.busy = False self.busy = False
def install_downloaded(self) -> dict: def install_downloaded(self) -> dict:
"""Ask the persistent desktop supervisor to install the staged worker. """Ask the persistent desktop supervisor to install the staged worker."""
The worker does not terminate itself. This lets the HTTP request finish cleanly,
then the supervisor owns stop/replace/restart/rollback.
"""
with self.lock: with self.lock:
latest = str( latest = str(
(self.db.get_meta("staged_update_version") if self.db else "") (self.db.get_meta("staged_update_version") if self.db else "")
@@ -123,6 +157,8 @@ class AgentUpdater:
).strip() ).strip()
if not latest: if not latest:
raise RuntimeError("No downloaded ERP Local Agent update is available to install.") raise RuntimeError("No downloaded ERP Local Agent update is available to install.")
if not _is_newer(latest, __version__):
return self.status()
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update" safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
staged = self.install_dir / "updates" / f"staged_{safe}" staged = self.install_dir / "updates" / f"staged_{safe}"
@@ -144,6 +180,7 @@ class AgentUpdater:
"staged_dir": str(staged.resolve()), "staged_dir": str(staged.resolve()),
"requested_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "requested_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"requested_by_pid": os.getpid(), "requested_by_pid": os.getpid(),
"automatic": bool(self.config.auto_install_updates),
} }
import json import json
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
@@ -164,7 +201,11 @@ class AgentUpdater:
} }
def status(self) -> dict: def status(self) -> dict:
latest = str(self.latest_manifest.get("latest_version") or (self.db.get_meta("latest_agent_version") if self.db else "") or __version__).strip() latest = str(
self.latest_manifest.get("latest_version")
or (self.db.get_meta("latest_agent_version") if self.db else "")
or __version__
).strip()
staged_version = str((self.db.get_meta("staged_update_version") if self.db else "") or "").strip() staged_version = str((self.db.get_meta("staged_update_version") if self.db else "") or "").strip()
staged_ok = False staged_ok = False
if staged_version: if staged_version:
@@ -182,11 +223,12 @@ class AgentUpdater:
return { return {
"current_version": __version__, "current_version": __version__,
"latest_version": latest, "latest_version": latest,
"update_available": bool(latest and latest != __version__), "update_available": bool(latest and _is_newer(latest, __version__)),
"downloaded": bool(staged_ok), "downloaded": bool(staged_ok),
"staged_version": staged_version if staged_ok else "", "staged_version": staged_version if staged_ok else "",
"busy": bool(self.busy), "busy": bool(self.busy),
"automatic_check_enabled": bool(self.config.auto_update), "automatic_check_enabled": bool(self.config.auto_update),
"automatic_install_enabled": False, "automatic_install_enabled": bool(self.config.auto_update and self.config.auto_install_updates),
"manual_update_only": bool(getattr(self.config, "manual_update_only", False)),
"supervisor": supervisor_state, "supervisor": supervisor_state,
} }