feat(hub): add fleet distribution policy controls and unblock update checks
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m1s

This commit is contained in:
2026-10-04 01:22:42 +02:00
parent 41563a8fb8
commit b069e1c1c0
4 changed files with 22 additions and 47 deletions
+5 -29
View File
@@ -1497,12 +1497,6 @@ def sync_upstream_packages_api(auth_user: str = Depends(require_admin_auth)):
""" """
Downloads latest client binary packages from upstream release assets to local Hub storage. Downloads latest client binary packages from upstream release assets to local Hub storage.
""" """
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not is_feature_licensed("auto_update", db_path):
raise HTTPException(
status_code=403,
detail="Fleet package synchronization requires an active license with the 'auto_update' capability."
)
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
results = mgr.sync_upstream_packages(force=True) results = mgr.sync_upstream_packages(force=True)
return { return {
@@ -1519,16 +1513,9 @@ def update_server_update_config(req: ServerUpdateConfigRequest, auth_user: str =
""" """
Updates fleet auto-update settings (on/off switch and automatic vs manual acquisition mode). Updates fleet auto-update settings (on/off switch and automatic vs manual acquisition mode).
""" """
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
config = SERVER_STATE.get("config", {}) config = SERVER_STATE.get("config", {})
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
if req.auto_update_enabled and not is_feature_licensed("auto_update", db_path):
raise HTTPException(
status_code=403,
detail="Fleet auto-update requires an active license with the 'auto_update' capability."
)
mode = (req.update_mode or "auto").lower() mode = (req.update_mode or "auto").lower()
if mode not in ("auto", "manual"): if mode not in ("auto", "manual"):
raise HTTPException(status_code=400, detail="Invalid update_mode. Must be 'auto' (fetch from upstream) or 'manual' (provided only).") raise HTTPException(status_code=400, detail="Invalid update_mode. Must be 'auto' (fetch from upstream) or 'manual' (provided only).")
@@ -1550,7 +1537,7 @@ def update_server_update_config(req: ServerUpdateConfigRequest, auth_user: str =
except Exception as e: except Exception as e:
print(f"[!] Warning: Could not save config file: {e}") print(f"[!] Warning: Could not save config file: {e}")
# If enabled and switched to auto mode, optionally trigger sync in background # If enabled and in auto mode, trigger upstream check
if mgr.auto_update_enabled and mgr.update_mode == "auto": if mgr.auto_update_enabled and mgr.update_mode == "auto":
try: try:
mgr.check_upstream_updates(force=False, auto_download_clients=True) mgr.check_upstream_updates(force=False, auto_download_clients=True)
@@ -1594,14 +1581,6 @@ def check_client_update(
except Exception: except Exception:
pass pass
if not is_feature_licensed("auto_update", db_path):
return {
"update_available": False,
"current_version": current_version or "0.0.0",
"target_version": current_version or "0.0.0",
"reason": "Fleet auto-update is not authorized by the server license."
}
return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch) return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch)
@@ -2363,13 +2342,10 @@ async def update_checker_loop(interval_hours: int = 1):
await asyncio.sleep(initial_delay) await asyncio.sleep(initial_delay)
while True: while True:
try: try:
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
if is_feature_licensed("auto_update", db_path): loop = asyncio.get_running_loop()
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER should_dl = bool(mgr.auto_update_enabled and mgr.update_mode == "auto")
if mgr.auto_update_enabled: await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=should_dl))
loop = asyncio.get_running_loop()
should_dl = (mgr.update_mode == "auto")
await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=should_dl))
except Exception as e: except Exception as e:
print(f"[!] Warning in update checker background loop: {e}") print(f"[!] Warning in update checker background loop: {e}")
await asyncio.sleep(interval_hours * 3600) await asyncio.sleep(interval_hours * 3600)
+9 -11
View File
@@ -651,8 +651,6 @@ class WebUIRenderer:
<!-- Tab 6: Updates & Fleet Distribution --> <!-- Tab 6: Updates & Fleet Distribution -->
<div class="tab-pane" id="tabPane-updates"> <div class="tab-pane" id="tabPane-updates">
{auto_update_lock_banner}
<!-- Server Version & Upstream Release Status --> <!-- Server Version & Upstream Release Status -->
<div class="card" style="margin-bottom: 24px;"> <div class="card" style="margin-bottom: 24px;">
<div class="card-header"> <div class="card-header">
@@ -683,14 +681,14 @@ class WebUIRenderer:
</div> </div>
<!-- Fleet Auto-Update & Acquisition Policy Form --> <!-- Fleet Auto-Update & Acquisition Policy Form -->
<div class="card" style="margin-bottom: 24px; {upload_container_style}"> <div class="card" style="margin-bottom: 24px;">
<div class="card-header"> <div class="card-header">
<div> <div>
<div class="card-title" style="font-size: 15px;">⚙️ Fleet Distribution & Auto-Update Policy</div> <div class="card-title" style="font-size: 15px;">⚙️ Fleet Distribution & Auto-Update Policy</div>
<div class="subtitle" style="margin-top: 4px;">Configure hot-upgrade execution and choose how client binary images are acquired &amp; hosted</div> <div class="subtitle" style="margin-top: 4px;">Configure hot-upgrade execution and choose how client binary images are acquired &amp; hosted</div>
</div> </div>
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
{('<span class="badge badge-active" style="font-size: 11px;">🔄 FLEET AUTO-UPDATE LICENSED</span>' if is_auto_update_unlocked else '<span class="badge badge-danger" style="font-size: 11px;">🔒 UNLICENSED</span>')} {('<span class="badge badge-active" style="font-size: 11px;">🔄 FLEET AUTO-UPDATE LICENSED</span>' if is_auto_update_unlocked else '<span class="badge" style="background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); font-size: 11px;">COMMUNITY / UNRESTRICTED</span>')}
</div> </div>
</div> </div>
@@ -702,11 +700,11 @@ class WebUIRenderer:
</div> </div>
<div style="display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 6px;"> <div style="display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 10px 16px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;"> <label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 10px 16px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
<input type="radio" name="autoUpdateEnabledRadio" id="autoUpdateOn" value="1" {'checked' if (auto_update_enabled and is_auto_update_unlocked) else ''} {upload_disabled_attr}> <input type="radio" name="autoUpdateEnabledRadio" id="autoUpdateOn" value="1" {'checked' if auto_update_enabled else ''}>
<span>🟢 <strong>Enabled (ON)</strong> <span style="color: var(--text-muted); font-size: 12px;">— Edge forwarders query this Hub and auto hot-upgrade</span></span> <span>🟢 <strong>Enabled (ON)</strong> <span style="color: var(--text-muted); font-size: 12px;">— Edge forwarders query this Hub and auto hot-upgrade</span></span>
</label> </label>
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 10px 16px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;"> <label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 10px 16px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
<input type="radio" name="autoUpdateEnabledRadio" id="autoUpdateOff" value="0" {'checked' if (not auto_update_enabled or not is_auto_update_unlocked) else ''} {upload_disabled_attr}> <input type="radio" name="autoUpdateEnabledRadio" id="autoUpdateOff" value="0" {'checked' if not auto_update_enabled else ''}>
<span>🔴 <strong>Disabled (OFF)</strong> <span style="color: var(--text-muted); font-size: 12px;">— Suspend fleet hot-upgrades (clients stay on current version)</span></span> <span>🔴 <strong>Disabled (OFF)</strong> <span style="color: var(--text-muted); font-size: 12px;">— Suspend fleet hot-upgrades (clients stay on current version)</span></span>
</label> </label>
</div> </div>
@@ -719,14 +717,14 @@ class WebUIRenderer:
</div> </div>
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px;"> <div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px;">
<label style="display: inline-flex; align-items: center; gap: 12px; cursor: pointer; padding: 12px 16px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;"> <label style="display: inline-flex; align-items: center; gap: 12px; cursor: pointer; padding: 12px 16px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
<input type="radio" name="updateModeRadio" id="modeAuto" value="auto" {'checked' if (update_mode == 'auto' and is_auto_update_unlocked) else ''} {upload_disabled_attr}> <input type="radio" name="updateModeRadio" id="modeAuto" value="auto" {'checked' if update_mode == 'auto' else ''}>
<div> <div>
<div style="font-weight: 600; font-size: 13px; color: #34d399;">🌐 Automatic (Hub Downloads &amp; Hosts Upstream Images)</div> <div style="font-weight: 600; font-size: 13px; color: #34d399;">🌐 Automatic (Hub Downloads &amp; Hosts Upstream Images)</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">The LOGAR Hub connects to the upstream release repository, automatically downloads Windows &amp; Linux client binaries (.exe, .bin), caches and hosts them locally for the fleet.</div> <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">The LOGAR Hub connects to the upstream release repository, automatically downloads Windows &amp; Linux client binaries (.exe, .bin), caches and hosts them locally for the fleet.</div>
</div> </div>
</label> </label>
<label style="display: inline-flex; align-items: center; gap: 12px; cursor: pointer; padding: 12px 16px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;"> <label style="display: inline-flex; align-items: center; gap: 12px; cursor: pointer; padding: 12px 16px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
<input type="radio" name="updateModeRadio" id="modeManual" value="manual" {'checked' if (update_mode == 'manual' or not is_auto_update_unlocked) else ''} {upload_disabled_attr}> <input type="radio" name="updateModeRadio" id="modeManual" value="manual" {'checked' if update_mode == 'manual' else ''}>
<div> <div>
<div style="font-weight: 600; font-size: 13px; color: #f59e0b;">📁 Manually Provided (Upload Only)</div> <div style="font-weight: 600; font-size: 13px; color: #f59e0b;">📁 Manually Provided (Upload Only)</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">The LOGAR Hub will not download packages from the internet. Binary packages must be manually uploaded by an administrator below.</div> <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">The LOGAR Hub will not download packages from the internet. Binary packages must be manually uploaded by an administrator below.</div>
@@ -735,13 +733,13 @@ class WebUIRenderer:
</div> </div>
<div class="form-group" style="margin-bottom: 0;"> <div class="form-group" style="margin-bottom: 0;">
<label class="form-label" style="font-size: 12px;">Upstream Release API Endpoint</label> <label class="form-label" style="font-size: 12px;">Upstream Release API Endpoint</label>
<input type="text" id="upstreamReleaseUrl" class="form-control" value="{html.escape(str(upstream_url))}" placeholder="https://gitea.eibl.tech/api/v1/repos/me0nline/LOGAR/releases/latest" {upload_disabled_attr}> <input type="text" id="upstreamReleaseUrl" class="form-control" value="{html.escape(str(upstream_url))}" placeholder="https://gitea.eibl.tech/api/v1/repos/me0nline/LOGAR/releases/latest">
</div> </div>
</div> </div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;"> <div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button type="submit" class="btn btn-primary" {upload_disabled_attr}>💾 Save Update Settings</button> <button type="submit" class="btn btn-primary">💾 Save Update Settings</button>
<button type="button" onclick="syncUpstreamPackagesNow()" class="btn btn-accent" {upload_disabled_attr}>🔄 Sync &amp; Download Upstream Images Now</button> <button type="button" onclick="syncUpstreamPackagesNow()" class="btn btn-accent">🔄 Sync &amp; Download Upstream Images Now</button>
</div> </div>
</form> </form>
</div> </div>
+4 -3
View File
@@ -323,11 +323,12 @@ class TestServerLicensingAndWebUI(unittest.TestCase):
conn.commit() conn.commit()
conn.close() conn.close()
# WebUI should render locked banner and disabled upload controls # WebUI should render update controls and community status
resp = Server.serve_webui() resp = Server.serve_webui()
html = resp.body.decode("utf-8") html = resp.body.decode("utf-8")
self.assertIn("Fleet Auto-Update is Inactive &amp; Locked", html) self.assertIn("Fleet Distribution & Auto-Update Policy", html)
self.assertIn("disabled", html) self.assertIn("COMMUNITY / UNRESTRICTED", html)
self.assertIn("autoUpdateEnabledRadio", html)
def test_json_lic_package_import(self): def test_json_lic_package_import(self):
"""Verifies importing an activated .lic JSON package via /api/license/import.""" """Verifies importing an activated .lic JSON package via /api/license/import."""
+4 -4
View File
@@ -130,14 +130,14 @@ class TestUpdateSystem(unittest.TestCase):
f.write(b"WIN_CLIENT_V0360") f.write(b"WIN_CLIENT_V0360")
self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0") self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0")
# 3. Client update check endpoint (Unlicensed should block auto_update) # 3. Client update check endpoint (Allowed regardless of license)
chk_blocked = Server.check_client_update( chk_unlicensed = Server.check_client_update(
client_id="win-client-01", client_id="win-client-01",
os="windows", os="windows",
current_version="0.3.5.1" current_version="0.3.5.1"
) )
self.assertFalse(chk_blocked["update_available"]) self.assertTrue(chk_unlicensed["update_available"])
self.assertIn("not authorized", chk_blocked.get("reason", "")) self.assertEqual(chk_unlicensed["latest_version"], "0.3.6.0")
# 4. Enable auto_update feature flag in license config # 4. Enable auto_update feature flag in license config
import sqlite3 import sqlite3