Files

566 lines
25 KiB
Python
Raw Permalink Normal View History

import sys
import subprocess
import tempfile
import unittest
from types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT / "AppImage" / "scripts"))
import lxc_apps # noqa: E402
import managed_installs # noqa: E402
class UpdateStrategyValidationTests(unittest.TestCase):
def test_custom_command_defaults_to_override(self):
ok, config = lxc_apps.validate_config({
"name": "Jellyfin",
"update_command": "systemctl restart jellyfin",
})
self.assertTrue(ok, config)
self.assertEqual(config["update_strategy"], "custom_override")
def test_legacy_helper_then_custom_is_normalised_to_override(self):
ok, config = lxc_apps.validate_config({
"name": "Jellyfin",
"update_command": "systemctl restart jellyfin",
"update_strategy": "helper_then_custom",
})
self.assertTrue(ok, config)
self.assertEqual(config["update_strategy"], "custom_override")
def test_unknown_strategy_is_ignored_and_normalised(self):
ok, config = lxc_apps.validate_config({
"name": "Jellyfin",
"update_command": "true",
"update_strategy": "run-everything",
})
self.assertTrue(ok, config)
self.assertEqual(config["update_strategy"], "custom_override")
class HelperEvidenceTests(unittest.TestCase):
def test_legacy_literal_wrapper_slug_is_supported(self):
wrapper = (
'bash -c "$(curl -fsSL '
'https://raw.githubusercontent.com/community-scripts/ProxmoxVE/'
'main/ct/jellyfin.sh)"'
)
self.assertEqual(
managed_installs._extract_helper_slug_from_update_wrapper(wrapper),
"jellyfin",
)
def test_current_generated_wrapper_slug_is_supported(self):
wrapper = """#!/usr/bin/env bash
# Community-Scripts update entrypoint (generated - do not edit by hand).
# Regenerated on install and on every successful update.
export SCRIPT_SLUG="nginxproxymanager"
export UPDATE_SCRIPT_NAME="nginxproxymanager"
export COMMUNITY_SCRIPTS_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
bash -c "$(curl -fsSL "${COMMUNITY_SCRIPTS_URL}/ct/${UPDATE_SCRIPT_NAME}.sh")"
"""
self.assertEqual(
managed_installs._extract_helper_slug_from_update_wrapper(wrapper),
"nginxproxymanager",
)
def test_update_script_name_is_a_compatible_fallback(self):
wrapper = "export UPDATE_SCRIPT_NAME='qbittorrent'"
self.assertEqual(
managed_installs._extract_helper_slug_from_update_wrapper(wrapper),
"qbittorrent",
)
def test_dynamic_or_command_chained_assignments_are_rejected(self):
for wrapper in (
'export SCRIPT_SLUG="$(touch /tmp/unsafe)"',
'export SCRIPT_SLUG="jellyfin"; touch /tmp/unsafe',
'export UPDATE_SCRIPT_NAME="${UNTRUSTED}"',
):
with self.subTest(wrapper=wrapper):
self.assertIsNone(
managed_installs._extract_helper_slug_from_update_wrapper(wrapper)
)
def test_wrapper_is_executable_evidence(self):
with patch.object(managed_installs, "_probe_helper_scripts_slug", return_value="jellyfin"):
slug, source = managed_installs._identify_helper_slug("101", "media")
self.assertEqual((slug, source), ("jellyfin", "update_wrapper"))
def test_tag_hostname_is_suggestion_only(self):
with patch.object(managed_installs, "_probe_helper_scripts_slug", return_value=None), \
patch.object(managed_installs, "_probe_lxc_tags", return_value={"community-scripts"}), \
patch.object(managed_installs, "_guess_helper_slug_from_hostname", return_value="jellyfin"):
slug, source = managed_installs._identify_helper_slug("101", "jellyfin")
self.assertEqual((slug, source), ("jellyfin", "tag_hostname"))
def _detect_one(self, source, slug="jellyfin"):
patches = (
patch.object(managed_installs, "_lxc_updates_detection_enabled", return_value=True),
patch.object(managed_installs, "_read_registry", return_value={"items": []}),
patch.object(managed_installs, "_list_pve_lxcs", return_value=[{
"vmid": "101", "status": "running", "name": "jellyfin",
}]),
patch.object(managed_installs, "_get_oci_managed_vmids", return_value={}),
patch.object(managed_installs, "_probe_lxc_is_oci", return_value=False),
patch.object(managed_installs, "_probe_lxc_os", return_value="debian"),
patch.object(managed_installs, "_identify_helper_slug", return_value=(slug, source)),
patch.object(managed_installs, "_fetch_helpers_cache", return_value={
slug: {"name": "Jellyfin", "updateable": True},
}),
)
for ctx in patches:
ctx.start()
try:
return managed_installs._detect_lxc_containers()[0]
finally:
for ctx in reversed(patches):
ctx.stop()
def test_hostname_guess_never_enables_updater(self):
item = self._detect_one("tag_hostname")
self.assertFalse(item["_has_app_updater"])
self.assertEqual(item["_helper_slug_source"], "tag_hostname")
def test_valid_wrapper_enables_updateable_app(self):
item = self._detect_one("update_wrapper")
self.assertTrue(item["_has_app_updater"])
def test_base_os_wrapper_never_enables_app_updater(self):
item = self._detect_one("update_wrapper", slug="debian")
self.assertFalse(item["_has_app_updater"])
class BulkUpdateConfigTests(unittest.TestCase):
def test_os_is_mandatory_and_a_second_target_is_required(self):
ok, error = lxc_apps.validate_bulk_update({"targets": ["app:abc"]})
self.assertFalse(ok)
self.assertIn("OS", error)
ok, error = lxc_apps.validate_bulk_update({"targets": ["os"]})
self.assertFalse(ok)
self.assertIn("application", error)
def test_targets_are_deduplicated_and_normalised(self):
ok, config = lxc_apps.validate_bulk_update({
"targets": ["docker-engine", "os", "app:abc", "docker-engine"],
})
self.assertTrue(ok, config)
self.assertEqual(config["targets"], ["os", "app:abc", "docker-engine"])
def test_only_opaque_docker_units_are_allowed(self):
ok, _ = lxc_apps.validate_bulk_update({
"targets": ["os", "docker-compose:media"],
})
self.assertFalse(ok)
ok, config = lxc_apps.validate_bulk_update({
"targets": ["os", "docker-unit:0123456789abcdefabcd"],
})
self.assertTrue(ok, config)
def test_bulk_config_round_trips_separately_from_schedule(self):
with tempfile.TemporaryDirectory() as temp_dir, \
patch.object(lxc_apps, "_APPS_DIR", temp_dir):
ok, _ = lxc_apps.update_schedule(101, {
"enabled": False,
"cron": "",
"target": "os",
"targets": ["os"],
})
self.assertTrue(ok)
ok, _ = lxc_apps.update_bulk_update(101, {
"targets": ["os", "docker-engine"],
})
self.assertTrue(ok)
self.assertEqual(lxc_apps.get_bulk_update(101)["targets"], ["os", "docker-engine"])
self.assertEqual(lxc_apps.get_schedule(101)["targets"], ["os"])
self.assertTrue(lxc_apps.delete_bulk_update(101))
self.assertIsNone(lxc_apps.get_bulk_update(101))
self.assertIsNotNone(lxc_apps.get_schedule(101))
class ScheduledReleaseTargetTests(unittest.TestCase):
def test_untracked_custom_app_is_not_release_gated(self):
gated, remaining = lxc_apps.partition_scheduled_release_targets(
["app:links"],
[{
"id": "links",
"name": "Links only",
"update_command": "systemctl restart links",
}],
)
self.assertEqual(gated, set())
self.assertEqual(remaining, ["app:links"])
def test_deferred_tracked_app_does_not_block_untracked_or_os(self):
gated, remaining = lxc_apps.partition_scheduled_release_targets(
["os", "app:tracked", "app:links"],
[
{"id": "tracked", "installed_via": "binary"},
{"id": "links", "update_command": "true"},
],
)
self.assertEqual(gated, {"tracked"})
self.assertEqual(remaining, ["os", "app:links"])
def test_legacy_apps_target_keeps_only_untracked_when_gate_defers(self):
gated, remaining = lxc_apps.partition_scheduled_release_targets(
["os", "apps"],
[
{"id": "tracked", "installed_via": "file"},
{"id": "links", "update_command": "true"},
{"id": "docker", "installed_via": "binary", "helper_slug": "docker"},
],
)
self.assertEqual(gated, {"tracked"})
self.assertEqual(remaining, ["os", "app:links"])
class AppCacheWriteThroughContractTests(unittest.TestCase):
def test_successful_mutations_publish_the_complete_sidecar(self):
cache_source = (REPO_ROOT / "AppImage" / "lib" / "lxc-apps-cache.ts").read_text()
panel_source = (REPO_ROOT / "AppImage" / "components" / "lxc-app-panel.tsx").read_text()
server_source = (REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text()
self.assertIn("export function setLxcAppsCached", cache_source)
self.assertGreaterEqual(panel_source.count("setLxcAppsCached(vmid, r, suggestions)"), 6)
self.assertNotIn("_vm_cache_put(_vm_apps_cache", server_source)
self.assertIn("lxc_apps.load_sidecar(vmid)", server_source)
def test_post_apply_revalidates_without_evicting_render_seed(self):
source = (REPO_ROOT / "AppImage" / "components" / "virtual-machines.tsx").read_text()
apply_block = source[source.index("const handleApplyComplete"):source.index("const getAggregateUpdateCheck")]
self.assertIn("void fetchLxcApps(applyVmid)", apply_block)
self.assertNotIn("invalidateLxcApps(applyVmid)", apply_block)
def test_apply_completion_is_owned_by_the_backend_and_idempotent(self):
server_source = (REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text()
terminal_source = (REPO_ROOT / "AppImage" / "scripts" / "flask_terminal_routes.py").read_text()
endpoint_block = server_source[
server_source.index("def api_lxc_updates_applied"):
server_source.index("@app.route('/api/health/thresholds'")
]
finalizer_block = server_source[
server_source.index("def _finalize_lxc_update"):
server_source.index("def _terminal_lxc_update_completed")
]
self.assertIn("set_script_completion_hook(_terminal_lxc_update_completed)", server_source)
self.assertIn("params.get('RUN_ID')", server_source)
self.assertIn("_run_script_completion_hook", terminal_source)
self.assertIn("_lxc_update_finalizations", finalizer_block)
self.assertIn("managed_installs.refresh_lxc(vmid)", finalizer_block)
self.assertNotIn("managed_installs.check_for_updates(force=True)", endpoint_block)
self.assertIn("entity_id=f'{vmid}:{safe_run_id}'", finalizer_block)
def test_scheduled_updates_use_the_shared_finalizer(self):
source = (REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text()
scheduler_block = source[
source.index("def _run_scheduled_update"):
source.index("def _scheduler_loop")
]
self.assertIn("_finalize_lxc_update(", scheduler_block)
self.assertIn("return finish('partial'", scheduler_block)
self.assertIn("before_snapshot=before", scheduler_block)
class DockerStackNotificationTests(unittest.TestCase):
def _docker_app(self):
return {
"id": "docker",
"name": "Docker",
"helper_slug": "docker",
"notifications_enabled": True,
"state": {
"installed_version": "27.4.0",
"latest_version": "29.7.2",
"update_available": True,
},
}
def _inventory(self, reference="portainer/portainer-ce:latest", digest="sha256:new"):
return {
"available": True,
"images": [{
"reference": reference,
"installed_version": "2.19.4",
"available_version": "2.39.6",
"remote_digest": digest,
"update_available": True,
}],
}
def test_engine_and_images_share_one_payload(self):
payload = lxc_apps._docker_stack_notification_payload(
101, self._docker_app(), self._inventory(), "docker",
)
self.assertEqual(payload["count"], 2)
self.assertIn("Docker Engine: 27.4.0 → 29.7.2", payload["details"])
self.assertIn("portainer/portainer-ce:latest: 2.19.4 → 2.39.6", payload["details"])
def test_signature_changes_when_pending_identity_changes_at_same_count(self):
first = lxc_apps._docker_stack_notification_payload(
101, {**self._docker_app(), "state": {}}, self._inventory(), "docker",
)
second = lxc_apps._docker_stack_notification_payload(
101,
{**self._docker_app(), "state": {}},
self._inventory("library/nginx:latest", "sha256:other"),
"docker",
)
self.assertEqual(first["count"], second["count"])
self.assertNotEqual(first["signature"], second["signature"])
def test_generic_app_event_does_not_duplicate_docker_stack_event(self):
emitter = MagicMock()
fake_module = SimpleNamespace(notification_manager=emitter)
with patch.dict(sys.modules, {"notification_manager": fake_module}):
lxc_apps._fire_update_notification(101, self._docker_app())
emitter.emit_event.assert_not_called()
class DockerInventoryCachePolicyTests(unittest.TestCase):
def setUp(self):
self.original_cache = lxc_apps._docker_inventory_cache
lxc_apps._docker_inventory_cache = {}
def tearDown(self):
lxc_apps._docker_inventory_cache = self.original_cache
def _inventory(self, available=True):
return {
"vmid": 101,
"available": available,
"images": [],
"checked_at_unix": 1_000_000,
}
def test_available_inventory_uses_24_hour_cache(self):
self.assertEqual(lxc_apps._DOCKER_INVENTORY_TTL_SEC, 24 * 3600)
lxc_apps._docker_inventory_cache["101"] = self._inventory()
fresh_scan = self._inventory()
fresh_scan["engine_version"] = "new-scan"
with patch.object(lxc_apps.time, "time", return_value=1_000_000 + 3600), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=fresh_scan) as scan:
cached = lxc_apps.get_docker_inventory(101)
self.assertNotIn("engine_version", cached)
scan.assert_not_called()
def test_available_inventory_refreshes_after_24_hours(self):
lxc_apps._docker_inventory_cache["101"] = self._inventory()
fresh_scan = self._inventory()
fresh_scan["engine_version"] = "new-scan"
with patch.object(lxc_apps.time, "time", return_value=1_000_000 + 24 * 3600 + 1), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=fresh_scan) as scan:
refreshed = lxc_apps.get_docker_inventory(101)
self.assertEqual(refreshed["engine_version"], "new-scan")
scan.assert_called_once_with(101)
def test_unavailable_inventory_retries_after_30_seconds(self):
lxc_apps._docker_inventory_cache["101"] = self._inventory(available=False)
fresh_scan = self._inventory(available=True)
with patch.object(lxc_apps.time, "time", return_value=1_000_029), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=fresh_scan) as scan:
lxc_apps.get_docker_inventory(101)
scan.assert_not_called()
with patch.object(lxc_apps.time, "time", return_value=1_000_031), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=fresh_scan) as scan:
lxc_apps.get_docker_inventory(101)
scan.assert_called_once_with(101)
def test_force_failure_preserves_docker_unit_identity_as_refreshing(self):
previous = self._inventory(available=True)
previous.update({
"images": [{"reference": "demo/app:latest", "update_available": True}],
"update_units": [{
"id": "docker-unit:0123456789abcdefabcd",
"display_name": "Demo",
"update_available": True,
}],
})
lxc_apps._docker_inventory_cache["101"] = previous
failed_scan = {
"vmid": 101,
"available": False,
"images": [],
"update_count": 0,
"error": "Docker is not ready",
}
with patch.object(lxc_apps.time, "time", return_value=1_000_100), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=failed_scan):
result = lxc_apps.get_docker_inventory(101, force=True)
self.assertFalse(result["available"])
self.assertTrue(result["refreshing"])
self.assertEqual(result["update_units"][0]["display_name"], "Demo")
self.assertEqual(result["update_units"][0]["id"], "docker-unit:0123456789abcdefabcd")
def test_force_failure_preserves_empty_lifecycle_pending_state(self):
pending = self._inventory(available=False)
pending.update({
"refreshing": True,
"images": [],
"update_units": [],
"error": None,
})
lxc_apps._docker_inventory_cache["101"] = pending
failed_scan = {
"vmid": 101,
"available": False,
"images": [],
"update_units": [],
"update_count": 0,
"error": "Docker is not ready",
}
with patch.object(lxc_apps.time, "time", return_value=1_000_100), \
patch.object(lxc_apps, "_docker_inventory_from_ct", return_value=failed_scan):
result = lxc_apps.get_docker_inventory(101, force=True)
self.assertFalse(result["available"])
self.assertTrue(result["refreshing"])
self.assertEqual(result["images"], [])
self.assertEqual(result["update_units"], [])
def test_lifecycle_transition_keeps_ids_but_clears_old_update_state(self):
previous = self._inventory(available=True)
previous.update({
"images": [{"reference": "demo/app:latest", "update_available": True}],
"update_units": [{
"id": "docker-unit:0123456789abcdefabcd",
"display_name": "Demo",
"update_available": True,
}],
})
lxc_apps._docker_inventory_cache["101"] = previous
pending = lxc_apps.mark_docker_inventory_refreshing(101)
self.assertFalse(pending["available"])
self.assertTrue(pending["refreshing"])
self.assertIsNone(pending["images"][0]["update_available"])
self.assertIsNone(pending["update_units"][0]["update_available"])
self.assertTrue(previous["images"][0]["update_available"])
def test_docker_ps_timeout_is_not_reported_as_an_empty_inventory(self):
with patch.object(lxc_apps, "_pct_exec", side_effect=[
(0, "27.4.0\n", ""),
(124, "", "timed out after 10s"),
]):
result = lxc_apps._docker_inventory_from_ct(101)
self.assertFalse(result["available"])
self.assertEqual(result["images"], [])
self.assertIn("not ready", result["error"])
def test_docker_image_ls_timeout_is_not_reported_as_empty(self):
with patch.object(lxc_apps, "_pct_exec", side_effect=[
(0, "27.4.0\n", ""),
(0, "", ""),
(124, "", "timed out after 15s"),
]):
result = lxc_apps._docker_inventory_from_ct(101)
self.assertFalse(result["available"])
self.assertEqual(result["images"], [])
self.assertIn("timed out", result["error"])
def test_pct_exec_timeout_kills_the_complete_local_process_group(self):
process = MagicMock()
process.pid = 4321
process.communicate.side_effect = [
subprocess.TimeoutExpired(cmd="pct", timeout=1),
("", ""),
]
with patch.object(lxc_apps.subprocess, "Popen", return_value=process), \
patch.object(lxc_apps.os, "killpg") as killpg:
rc, out, err = lxc_apps._pct_exec(101, ["docker", "version"], timeout=1)
self.assertEqual((rc, out), (124, ""))
self.assertIn("timed out", err)
killpg.assert_called_once_with(4321, lxc_apps.signal.SIGKILL)
def test_inventory_does_not_define_disk_persistence(self):
source = (REPO_ROOT / "AppImage" / "scripts" / "lxc_apps.py").read_text()
self.assertNotIn("docker_inventory.json", source)
self.assertNotIn("_save_docker_inventory_disk", source)
def test_daily_collector_and_ui_force_points_are_explicit(self):
notification_source = (REPO_ROOT / "AppImage" / "scripts" / "notification_events.py").read_text()
frontend_source = (REPO_ROOT / "AppImage" / "components" / "virtual-machines.tsx").read_text()
automatic_block = frontend_source[
frontend_source.index("// Docker drift is opt-in"):
frontend_source.index("const refreshDockerInventory")
]
manual_block = frontend_source[
frontend_source.index("const refreshDockerInventory"):
frontend_source.index("const closeCustomCmdEditor")
]
self.assertIn("refresh_docker_inventories(force=True)", notification_source)
self.assertIn("/docker/inventory`", automatic_block)
self.assertNotIn("?force=1", automatic_block)
self.assertIn("/docker/inventory?force=1", manual_block)
def test_startup_and_lifecycle_rebuild_memory_inventory_without_blank_gap(self):
server_source = (REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text()
lifecycle_block = server_source[
server_source.index("def _refresh_started_guest"):
server_source.index("def _schedule_started_guest_refresh")
]
startup_block = server_source[
server_source.index("def _deferred_startup_inits"):
server_source.index("threading.Thread(target=_deferred_startup_inits")
]
self.assertIn("refresh_docker_inventories(force=True)", startup_block)
self.assertIn("mark_docker_inventory_refreshing(vmid)", lifecycle_block)
self.assertIn("get_docker_inventory(vmid, force=True)", lifecycle_block)
self.assertIn("docker_refresh_pending", lifecycle_block)
self.assertIn("time.monotonic() + 7 * 60", lifecycle_block)
self.assertIn("Docker ready in", lifecycle_block)
self.assertLess(
lifecycle_block.index("get_docker_inventory(vmid, force=True)"),
lifecycle_block.index("get_suggestions(vmid, force=True)"),
)
self.assertLess(
lifecycle_block.index("_publish_guest_modal_cache_revision(vmid)"),
lifecycle_block.index("get_suggestions(vmid, force=True)"),
)
def test_manual_docker_refresh_publishes_endpoint_result_without_full_vm_wait(self):
frontend_source = (REPO_ROOT / "AppImage" / "components" / "virtual-machines.tsx").read_text()
manual_block = frontend_source[
frontend_source.index("const refreshDockerInventory"):
frontend_source.index("const closeCustomCmdEditor")
]
self.assertIn("const inventory = await fetchApi<LxcDockerInventory>", manual_block)
self.assertIn("docker_inventory: inventory", manual_block)
self.assertIn("{ revalidate: false }", manual_block)
self.assertNotIn("await mutate()", manual_block)
def test_bulk_ui_does_not_expose_internal_docker_ids_while_refreshing(self):
frontend_source = (REPO_ROOT / "AppImage" / "components" / "virtual-machines.tsx").read_text()
bulk_block = frontend_source[
frontend_source.index("const pendingDockerBulkTargets"):
frontend_source.index("{/* Options card")
]
self.assertIn("vmLxc.bulkUpdate.dockerInventoryPending", bulk_block)
self.assertIn("vmLxc.bulkUpdate.missingDockerTarget", bulk_block)
self.assertIn("pendingDockerBulkTargets.length > 0", bulk_block)
self.assertNotIn("{target}\n", bulk_block)
if __name__ == "__main__":
unittest.main()