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
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.0"
ERP_LOCAL_AGENT_VERSION = "1.22.2"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_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"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"
"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"
@@ -29,7 +29,8 @@ class AgentConfig:
tunnel_reconnect_seconds: int = 10
auto_update: bool = True
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_host: str = "127.0.0.1"
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.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(
erp_base_url=erp_base_url,
node_code=node_code,
@@ -122,9 +130,10 @@ def load_config(env_file: str | None = None) -> AgentConfig:
max_retries=_get_int("MAX_RETRIES", 3),
tunnel_enabled=_get_bool("TUNNEL_ENABLED", True),
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)),
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_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))),
@@ -3,8 +3,8 @@ from __future__ import annotations
import hashlib
import os
from pathlib import Path
import re
import shutil
import subprocess
import threading
import time
import zipfile
@@ -12,11 +12,28 @@ import zipfile
from . import __version__
class AgentUpdater:
"""Manual-install updater.
def _version_tuple(value: str) -> tuple[int, ...]:
"""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):
@@ -48,7 +65,7 @@ class AgentUpdater:
latest = str(manifest.get("latest_version") or "").strip()
expected = str(manifest.get("sha256") or "").lower().strip()
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:
self.db.set_meta("latest_agent_version", latest or __version__)
self.db.set_meta("update_available", "1" if available else "0")
@@ -65,8 +82,27 @@ class AgentUpdater:
return False
try:
state = self.check_for_update(force=force)
return bool(state.get("update_available"))
except Exception:
available = bool(state.get("update_available"))
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
def download_latest(self) -> dict:
@@ -79,7 +115,7 @@ class AgentUpdater:
self.check_for_update(force=True)
latest = str(self.latest_manifest.get("latest_version") or "").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()
package = self.client.download_update_package()
actual = hashlib.sha256(package).hexdigest().lower()
@@ -97,6 +133,8 @@ class AgentUpdater:
archive.extractall(staged)
if not (staged / "erp_local_agent" / "__init__.py").exists():
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))
if self.db is not None:
self.db.set_meta("staged_update_version", latest)
@@ -110,11 +148,7 @@ class AgentUpdater:
self.busy = False
def install_downloaded(self) -> dict:
"""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.
"""
"""Ask the persistent desktop supervisor to install the staged worker."""
with self.lock:
latest = str(
(self.db.get_meta("staged_update_version") if self.db else "")
@@ -123,6 +157,8 @@ class AgentUpdater:
).strip()
if not latest:
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"
staged = self.install_dir / "updates" / f"staged_{safe}"
@@ -144,6 +180,7 @@ class AgentUpdater:
"staged_dir": str(staged.resolve()),
"requested_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"requested_by_pid": os.getpid(),
"automatic": bool(self.config.auto_install_updates),
}
import json
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
@@ -164,7 +201,11 @@ class AgentUpdater:
}
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_ok = False
if staged_version:
@@ -182,11 +223,12 @@ class AgentUpdater:
return {
"current_version": __version__,
"latest_version": latest,
"update_available": bool(latest and latest != __version__),
"update_available": bool(latest and _is_newer(latest, __version__)),
"downloaded": bool(staged_ok),
"staged_version": staged_version if staged_ok else "",
"busy": bool(self.busy),
"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,
}