mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-15 03:06:45 +00:00
566 lines
25 KiB
Python
566 lines
25 KiB
Python
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()
|