Optimize server startup speed, client update caching, and HTTP 304 conditional downloads
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m28s

This commit is contained in:
2026-10-02 22:43:18 +02:00
parent fe9d43cab6
commit 9ebfadbe89
5 changed files with 213 additions and 45 deletions
+47 -6
View File
@@ -1497,11 +1497,17 @@ def check_client_update(
@app.get("/api/client/update/download") @app.get("/api/client/update/download")
def download_client_update_package(os: Optional[str] = "windows", os_name: Optional[str] = None): def download_client_update_package(
request: Request,
os: Optional[str] = "windows",
os_name: Optional[str] = None
):
""" """
Streams the latest client binary or installer package for the specified OS. Streams the latest client binary or installer package for the specified OS.
Supports HTTP ETag and conditional 304 Not Modified requests to minimize network bandwidth.
""" """
import os as _os import os as _os
import email.utils
target_os = os_name or os or "windows" target_os = os_name or os or "windows"
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
pkg_tuple = mgr.get_client_package_path(os_type=target_os) pkg_tuple = mgr.get_client_package_path(os_type=target_os)
@@ -1512,14 +1518,50 @@ def download_client_update_package(os: Optional[str] = "windows", os_name: Optio
if not _os.path.exists(file_path): if not _os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Client package file missing on server disk.") raise HTTPException(status_code=404, detail="Client package file missing on server disk.")
etag = f'"{sha256}"'
if request is not None:
if_none_match = request.headers.get("if-none-match")
if if_none_match and (if_none_match == etag or if_none_match.strip('"') == sha256):
return Response(
status_code=304,
headers={
"ETag": etag,
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Checksum-SHA256": sha256
}
)
try:
mtime = _os.path.getmtime(file_path)
last_modified = email.utils.formatdate(mtime, usegmt=True)
except Exception:
last_modified = None
if_modified_since = request.headers.get("if-modified-since")
if if_modified_since and last_modified and if_modified_since == last_modified:
return Response(
status_code=304,
headers={
"ETag": etag,
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Checksum-SHA256": sha256
}
)
headers = {
"ETag": etag,
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Checksum-SHA256": sha256,
"Content-Disposition": f'attachment; filename="{filename}"'
}
if last_modified:
headers["Last-Modified"] = last_modified
return FileResponse( return FileResponse(
path=file_path, path=file_path,
filename=filename, filename=filename,
media_type="application/octet-stream", media_type="application/octet-stream",
headers={ headers=headers
"X-Checksum-SHA256": sha256,
"Content-Disposition": f'attachment; filename="{filename}"'
}
) )
@@ -2360,7 +2402,6 @@ def main():
csr_path = os.path.join(cert_dir, "ca.csr") csr_path = os.path.join(cert_dir, "ca.csr")
if not os.path.exists(csr_path): if not os.path.exists(csr_path):
enrollment.generate_ca_csr(cert_dir=cert_dir) enrollment.generate_ca_csr(cert_dir=cert_dir)
export_license_request_lic(config)
srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed( srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed(
ca_cert, ca_key, ca_cert, ca_key,
+37 -11
View File
@@ -76,22 +76,48 @@ class ClientUpdater:
def download_package(self, download_path_rel: str, expected_sha256: str, target_temp: str, timeout: int = 30) -> bool: def download_package(self, download_path_rel: str, expected_sha256: str, target_temp: str, timeout: int = 30) -> bool:
""" """
Downloads the client binary package from the server and validates its SHA-256 checksum. Downloads the client binary package from the server and validates its SHA-256 checksum.
Uses local binary hash checking and ETag conditional headers to avoid redundant downloads.
""" """
# 1. If running binary already matches target SHA-256, skip downloading
target_exec = sys.executable
if expected_sha256 and os.path.exists(target_exec):
try:
local_sha = self.calculate_sha256(target_exec)
if local_sha and local_sha == expected_sha256.lower():
print(f"[*] Local binary already matches target checksum ({local_sha[:12]}...). Skipping redundant download.")
return True
except Exception:
pass
base_url = self.get_server_base_url() base_url = self.get_server_base_url()
download_url = f"{base_url.rstrip('/')}/{download_path_rel.lstrip('/')}" download_url = f"{base_url.rstrip('/')}/{download_path_rel.lstrip('/')}"
print(f"[*] Downloading client update package from {download_url}...") print(f"[*] Downloading client update package from {download_url}...")
req = urllib.request.Request( headers = {"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"}
download_url, if expected_sha256:
headers={"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"} headers["If-None-Match"] = f'"{expected_sha256}"'
)
with urllib.request.urlopen(req, timeout=timeout) as resp: req = urllib.request.Request(download_url, headers=headers)
if resp.status != 200: try:
print(f"[!] Server returned HTTP {resp.status} during update download.") with urllib.request.urlopen(req, timeout=timeout) as resp:
return False if resp.status == 304:
with open(target_temp, "wb") as f: print("[+] Server returned 304 Not Modified. Local binary package is current.")
while chunk := resp.read(65536): return True
f.write(chunk) if resp.status != 200:
print(f"[!] Server returned HTTP {resp.status} during update download.")
return False
with open(target_temp, "wb") as f:
while chunk := resp.read(65536):
f.write(chunk)
except urllib.error.HTTPError as e:
if e.code == 304:
print("[+] Server returned 304 Not Modified. Local binary package is current.")
return True
print(f"[!] HTTP error during update download: {e}")
return False
except Exception as e:
print(f"[!] Error downloading update package: {e}")
return False
# Verify SHA-256 # Verify SHA-256
actual_sha256 = self.calculate_sha256(target_temp) actual_sha256 = self.calculate_sha256(target_temp)
+15 -18
View File
@@ -193,19 +193,24 @@ class StorageEngine:
) )
""") """)
def _ensure_columns(tbl: str, cols: List[Tuple[str, str]]):
try:
existing = {row[1] for row in conn.execute(f"PRAGMA table_info({tbl})").fetchall()}
for col, col_def in cols:
if col not in existing:
conn.execute(f"ALTER TABLE {tbl} ADD COLUMN {col} {col_def}")
except Exception:
pass
# Migrations for license_config # Migrations for license_config
for col, col_def in [ _ensure_columns("license_config", [
("license_id", "TEXT"), ("license_id", "TEXT"),
("license_server_url", "TEXT"), ("license_server_url", "TEXT"),
("issued_at", "TEXT"), ("issued_at", "TEXT"),
("expires_at", "TEXT"), ("expires_at", "TEXT"),
("license_status", "TEXT DEFAULT 'unlicensed'"), ("license_status", "TEXT DEFAULT 'unlicensed'"),
("features", "TEXT DEFAULT '[]'") ("features", "TEXT DEFAULT '[]'")
]: ])
try:
conn.execute(f"ALTER TABLE license_config ADD COLUMN {col} {col_def}")
except sqlite3.OperationalError:
pass
# 4. Clients Table # 4. Clients Table
conn.execute(""" conn.execute("""
@@ -221,7 +226,7 @@ class StorageEngine:
""") """)
# Migrations for clients table # Migrations for clients table
for col, col_def in [ _ensure_columns("clients", [
("metrics_json", "TEXT DEFAULT '{}'"), ("metrics_json", "TEXT DEFAULT '{}'"),
("cpu_percent", "REAL DEFAULT 0.0"), ("cpu_percent", "REAL DEFAULT 0.0"),
("memory_percent", "REAL DEFAULT 0.0"), ("memory_percent", "REAL DEFAULT 0.0"),
@@ -229,11 +234,7 @@ class StorageEngine:
("client_version", "TEXT DEFAULT ''"), ("client_version", "TEXT DEFAULT ''"),
("update_status", "TEXT DEFAULT 'up_to_date'"), ("update_status", "TEXT DEFAULT 'up_to_date'"),
("last_update_check", "TEXT") ("last_update_check", "TEXT")
]: ])
try:
conn.execute(f"ALTER TABLE clients ADD COLUMN {col} {col_def}")
except sqlite3.OperationalError:
pass
# 5. Users Table # 5. Users Table
conn.execute(""" conn.execute("""
@@ -249,14 +250,10 @@ class StorageEngine:
""") """)
# Migrations for users table (2FA) # Migrations for users table (2FA)
for col, col_def in [ _ensure_columns("users", [
("totp_secret", "TEXT"), ("totp_secret", "TEXT"),
("totp_enabled", "INTEGER DEFAULT 0") ("totp_enabled", "INTEGER DEFAULT 0")
]: ])
try:
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {col_def}")
except sqlite3.OperationalError:
pass
# Ensure default admin user exists # Ensure default admin user exists
c = conn.cursor() c = conn.cursor()
+67 -8
View File
@@ -45,6 +45,9 @@ class ServerUpdateManager:
# Hosted client packages cache # Hosted client packages cache
self.hosted_client_version: str = LOGAR_VERSION self.hosted_client_version: str = LOGAR_VERSION
self._sha256_cache: Dict[str, Tuple[float, int, str]] = {}
self._package_cache: Dict[str, Dict[str, Any]] = {}
self._package_cache_mtimes: Dict[str, float] = {}
self._ensure_package_structure() self._ensure_package_structure()
def _ensure_package_structure(self): def _ensure_package_structure(self):
@@ -52,21 +55,48 @@ class ServerUpdateManager:
for os_name in ["windows", "linux"]: for os_name in ["windows", "linux"]:
os.makedirs(os.path.join(self.update_dir, os_name), exist_ok=True) os.makedirs(os.path.join(self.update_dir, os_name), exist_ok=True)
@staticmethod @classmethod
def calculate_sha256(filepath: str) -> str: def calculate_sha256(cls, filepath: str) -> str:
"""Computes the SHA-256 digest of a local file.""" """Computes the SHA-256 digest of a local file with stat-based memoization."""
if not os.path.exists(filepath): if not os.path.exists(filepath):
return "" return ""
try:
stat = os.stat(filepath)
mtime = stat.st_mtime
size = stat.st_size
if hasattr(cls, "_global_sha256_cache"):
cached = cls._global_sha256_cache.get(filepath)
if cached and cached[0] == mtime and cached[1] == size:
return cached[2]
except Exception:
mtime = 0.0
size = 0
h = hashlib.sha256() h = hashlib.sha256()
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
while chunk := f.read(65536): while chunk := f.read(65536):
h.update(chunk) h.update(chunk)
return h.hexdigest().lower() digest = h.hexdigest().lower()
if not hasattr(cls, "_global_sha256_cache"):
cls._global_sha256_cache = {}
cls._global_sha256_cache[filepath] = (mtime, size, digest)
return digest
def set_hosted_client_version(self, version_str: str): def set_hosted_client_version(self, version_str: str):
"""Sets the version tag for currently hosted client packages.""" """Sets the version tag for currently hosted client packages."""
if version_str: if version_str:
self.hosted_client_version = version_str.strip().lstrip("v") self.hosted_client_version = version_str.strip().lstrip("v")
self._package_cache.clear()
def invalidate_package_cache(self, os_type: Optional[str] = None):
"""Clears in-memory package cache."""
if os_type:
self._package_cache.pop(os_type.lower(), None)
self._package_cache_mtimes.pop(os_type.lower(), None)
else:
self._package_cache.clear()
self._package_cache_mtimes.clear()
def register_client_package( def register_client_package(
self, self,
@@ -115,16 +145,37 @@ class ServerUpdateManager:
with open(manifest_path, "w", encoding="utf-8") as mf: with open(manifest_path, "w", encoding="utf-8") as mf:
json.dump(manifest, mf, indent=2) json.dump(manifest, mf, indent=2)
# Update cache directly
cached_entry = dict(manifest)
cached_entry["file_path"] = dest_path
cached_entry["exists"] = True
self._package_cache[norm_os] = cached_entry
self._package_cache_mtimes[norm_os] = os.path.getmtime(dest_path)
return manifest return manifest
def get_hosted_packages(self) -> Dict[str, Dict[str, Any]]: def get_hosted_packages(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
""" """
Returns info on currently hosted client packages for Windows and Linux. Returns info on currently hosted client packages for Windows and Linux.
Scans update_dir and falls back to project dist/ directory if present. Uses in-memory cache validated against file modification times to minimize disk I/O.
""" """
result: Dict[str, Dict[str, Any]] = {} result: Dict[str, Dict[str, Any]] = {}
for os_name in ["windows", "linux"]: for os_name in ["windows", "linux"]:
manifest_path = os.path.join(self.update_dir, os_name, "manifest.json") manifest_path = os.path.join(self.update_dir, os_name, "manifest.json")
target_file = None
if not force_refresh and os_name in self._package_cache:
cached = self._package_cache[os_name]
tf = cached.get("file_path")
if tf and os.path.exists(tf):
try:
cur_mtime = os.path.getmtime(tf)
if cur_mtime == self._package_cache_mtimes.get(os_name):
result[os_name] = cached
continue
except Exception:
pass
if os.path.exists(manifest_path): if os.path.exists(manifest_path):
try: try:
with open(manifest_path, "r", encoding="utf-8") as mf: with open(manifest_path, "r", encoding="utf-8") as mf:
@@ -134,6 +185,8 @@ class ServerUpdateManager:
info["exists"] = True info["exists"] = True
info["file_path"] = target_file info["file_path"] = target_file
result[os_name] = info result[os_name] = info
self._package_cache[os_name] = info
self._package_cache_mtimes[os_name] = os.path.getmtime(target_file)
continue continue
except Exception: except Exception:
pass pass
@@ -164,7 +217,7 @@ class ServerUpdateManager:
if found_pkg and os.path.exists(found_pkg): if found_pkg and os.path.exists(found_pkg):
sha256 = self.calculate_sha256(found_pkg) sha256 = self.calculate_sha256(found_pkg)
result[os_name] = { pkg_data = {
"os": os_name, "os": os_name,
"filename": os.path.basename(found_pkg), "filename": os.path.basename(found_pkg),
"version": self.hosted_client_version or LOGAR_VERSION, "version": self.hosted_client_version or LOGAR_VERSION,
@@ -174,8 +227,11 @@ class ServerUpdateManager:
"exists": True, "exists": True,
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
} }
result[os_name] = pkg_data
self._package_cache[os_name] = pkg_data
self._package_cache_mtimes[os_name] = os.path.getmtime(found_pkg)
else: else:
result[os_name] = { pkg_data = {
"os": os_name, "os": os_name,
"filename": None, "filename": None,
"version": self.hosted_client_version or LOGAR_VERSION, "version": self.hosted_client_version or LOGAR_VERSION,
@@ -184,6 +240,9 @@ class ServerUpdateManager:
"file_path": None, "file_path": None,
"exists": False "exists": False
} }
result[os_name] = pkg_data
self._package_cache[os_name] = pkg_data
self._package_cache_mtimes[os_name] = 0.0
return result return result
+47 -2
View File
@@ -137,11 +137,34 @@ class TestUpdateSystem(unittest.TestCase):
self.assertEqual(chk_resp["latest_version"], "0.3.6.0") self.assertEqual(chk_resp["latest_version"], "0.3.6.0")
# 4. Download package endpoint # 4. Download package endpoint
dl_resp = Server.download_client_update_package(os="windows") from starlette.requests import Request as StarletteRequest
req_scope = {
"type": "http",
"method": "GET",
"path": "/api/client/update/download",
"headers": [],
"query_string": b"os=windows"
}
init_req = StarletteRequest(req_scope)
dl_resp = Server.download_client_update_package(request=init_req, os="windows")
self.assertEqual(dl_resp.status_code, 200) self.assertEqual(dl_resp.status_code, 200)
self.assertIn("X-Checksum-SHA256", dl_resp.headers) self.assertIn("X-Checksum-SHA256", dl_resp.headers)
self.assertIn("ETag", dl_resp.headers)
etag = dl_resp.headers["ETag"]
# 5. WebUI rendering verification # 5. Conditional GET with ETag (304 Not Modified)
cond_scope = {
"type": "http",
"method": "GET",
"path": "/api/client/update/download",
"headers": [(b"if-none-match", etag.encode("utf-8"))],
"query_string": b"os=windows"
}
cached_req = StarletteRequest(cond_scope)
cached_resp = Server.download_client_update_package(request=cached_req, os="windows")
self.assertEqual(cached_resp.status_code, 304)
# 6. WebUI rendering verification
# Simulate server update available # Simulate server update available
self.update_mgr.server_update_available = True self.update_mgr.server_update_available = True
self.update_mgr.latest_upstream_version = "0.3.6.0" self.update_mgr.latest_upstream_version = "0.3.6.0"
@@ -155,6 +178,28 @@ class TestUpdateSystem(unittest.TestCase):
self.assertIn("tabPane-updates", html) self.assertIn("tabPane-updates", html)
self.assertIn("Client Package Distribution Repository", html) self.assertIn("Client Package Distribution Repository", html)
def test_update_memoization_and_cache_invalidation(self):
"""Tests that package info is served from memory cache and invalidates on update."""
dummy_pkg = os.path.join(self.test_dir, "LOGAR_Client_Linux")
with open(dummy_pkg, "wb") as f:
f.write(b"LINUX_CLIENT_PAYLOAD_V1")
self.update_mgr.register_client_package("linux", dummy_pkg, version="0.3.6.0")
pkgs_1 = self.update_mgr.get_hosted_packages()
sha1 = pkgs_1["linux"]["sha256"]
# Repeated call should hit memory cache with identical hash
pkgs_2 = self.update_mgr.get_hosted_packages()
self.assertEqual(pkgs_2["linux"]["sha256"], sha1)
# Invalidate cache on new registration
with open(dummy_pkg, "wb") as f:
f.write(b"LINUX_CLIENT_PAYLOAD_V2_UPDATED")
self.update_mgr.register_client_package("linux", dummy_pkg, version="0.3.7.0")
pkgs_3 = self.update_mgr.get_hosted_packages()
self.assertEqual(pkgs_3["linux"]["version"], "0.3.7.0")
self.assertNotEqual(pkgs_3["linux"]["sha256"], sha1)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()