mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-27 09:06:47 +00:00
Improve LXC updater selection, error handling and dashboard consistency
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
RUNNER="$REPO_ROOT/scripts/lxc/apply_updates.sh"
|
||||
TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/proxmenux-update-test.XXXXXX")
|
||||
trap 'rm -rf "$TEST_ROOT"' EXIT
|
||||
MOCK_BIN="$TEST_ROOT/bin"
|
||||
mkdir -p "$MOCK_BIN" "$TEST_ROOT/locks"
|
||||
|
||||
cat >"$MOCK_BIN/pct" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "pct $*" >>"$MOCK_LOG"
|
||||
case "$1" in
|
||||
list)
|
||||
printf 'VMID Status Name\n101 %s test\n' "${MOCK_INITIAL_STATE:-running}"
|
||||
;;
|
||||
status)
|
||||
printf 'status: %s\n' "${MOCK_INITIAL_STATE:-running}"
|
||||
;;
|
||||
start|shutdown|reboot)
|
||||
;;
|
||||
exec)
|
||||
shift 3
|
||||
joined="$*"
|
||||
case "$joined" in
|
||||
*'grep -E "^ID="'*) echo 'ID=debian' ;;
|
||||
'test -f /usr/bin/update') [[ "${MOCK_HAS_WRAPPER:-1}" == "1" ]] ;;
|
||||
'cat /usr/bin/update')
|
||||
case "${MOCK_WRAPPER_FORMAT:-legacy}" in
|
||||
modern)
|
||||
printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'# Regenerated on install and on every successful update.' \
|
||||
"export SCRIPT_SLUG=\"${MOCK_HELPER_SLUG:-jellyfin}\"" \
|
||||
"export UPDATE_SCRIPT_NAME=\"${MOCK_HELPER_SLUG:-jellyfin}\"" \
|
||||
'bash -c "$(curl -fsSL "${COMMUNITY_SCRIPTS_URL}/ct/${UPDATE_SCRIPT_NAME}.sh")"'
|
||||
;;
|
||||
update-name)
|
||||
printf "export UPDATE_SCRIPT_NAME='%s'\n" "${MOCK_HELPER_SLUG:-jellyfin}"
|
||||
;;
|
||||
unsafe)
|
||||
printf '%s\n' 'export SCRIPT_SLUG="$(touch /tmp/proxmenux-unsafe)"'
|
||||
;;
|
||||
*)
|
||||
echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${MOCK_HELPER_SLUG:-jellyfin}.sh)\""
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*'command -v wget'*) ;;
|
||||
bash\ -c*) echo HELPER_EXEC >>"$MOCK_LOG" ;;
|
||||
sh\ -c*)
|
||||
echo CUSTOM_EXEC >>"$MOCK_LOG"
|
||||
[[ "${MOCK_CUSTOM_FAIL:-0}" != "1" ]]
|
||||
;;
|
||||
env\ DEBIAN_FRONTEND=noninteractive*) echo OS_EXEC >>"$MOCK_LOG" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
cat >"$MOCK_BIN/flock" <<'EOF'
|
||||
#!/bin/bash
|
||||
[[ "${MOCK_FLOCK_FAIL:-0}" != "1" ]]
|
||||
EOF
|
||||
|
||||
cat >"$MOCK_BIN/python3" <<'EOF'
|
||||
#!/bin/bash
|
||||
cat >/dev/null
|
||||
if [[ "${2:-}" == 'protect-update-command' ]]; then
|
||||
printf '%s' "$UPDATE_COMMAND"
|
||||
exit 0
|
||||
fi
|
||||
[[ "${MOCK_HELPER_SELECTED:-1}" == "1" ]]
|
||||
EOF
|
||||
|
||||
cat >"$MOCK_BIN/sleep" <<'EOF'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat >"$MOCK_BIN/vzdump" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo "vzdump $*" >>"$MOCK_LOG"
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN"/*
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
count() { grep -c "$1" "$MOCK_LOG" 2>/dev/null || true; }
|
||||
|
||||
run_case() {
|
||||
local name=$1
|
||||
shift
|
||||
export MOCK_LOG="$TEST_ROOT/$name.log"
|
||||
: >"$MOCK_LOG"
|
||||
set +e
|
||||
env PATH="$MOCK_BIN:$PATH" PROXMENUX_LOCK_DIR="$TEST_ROOT/locks" \
|
||||
VMID=101 TARGET=app BACKUP=0 RESTART=0 "$@" bash "$RUNNER" \
|
||||
>"$TEST_ROOT/$name.out" 2>&1
|
||||
CASE_RC=$?
|
||||
set -e
|
||||
}
|
||||
|
||||
run_case helper_only RUN_HELPER=1 UPDATE_COMMAND=
|
||||
[[ $CASE_RC -eq 0 ]] || fail "helper_only returned $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 1 ]] || fail "helper_only did not run helper exactly once"
|
||||
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "helper_only unexpectedly ran custom"
|
||||
|
||||
run_case helper_not_selected RUN_HELPER=1 UPDATE_COMMAND= MOCK_HELPER_SELECTED=0
|
||||
[[ $CASE_RC -eq 5 ]] || fail "helper_not_selected expected 5, got $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "unselected helper was executed"
|
||||
|
||||
run_case modern_helper RUN_HELPER=1 UPDATE_COMMAND= MOCK_WRAPPER_FORMAT=modern MOCK_HELPER_SLUG=nginxproxymanager
|
||||
[[ $CASE_RC -eq 0 ]] || fail "modern_helper returned $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 1 ]] || fail "modern_helper did not run helper exactly once"
|
||||
grep -qF 'slug: nginxproxymanager' "$TEST_ROOT/modern_helper.out" \
|
||||
|| fail "modern_helper did not resolve SCRIPT_SLUG"
|
||||
|
||||
run_case update_name_helper RUN_HELPER=1 UPDATE_COMMAND= MOCK_WRAPPER_FORMAT=update-name MOCK_HELPER_SLUG=qbittorrent
|
||||
[[ $CASE_RC -eq 0 ]] || fail "update_name_helper returned $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 1 ]] || fail "update_name_helper did not run helper exactly once"
|
||||
grep -qF 'slug: qbittorrent' "$TEST_ROOT/update_name_helper.out" \
|
||||
|| fail "update_name_helper did not resolve UPDATE_SCRIPT_NAME"
|
||||
|
||||
run_case unsafe_wrapper RUN_HELPER=1 UPDATE_COMMAND= MOCK_WRAPPER_FORMAT=unsafe
|
||||
[[ $CASE_RC -eq 5 ]] || fail "unsafe_wrapper expected 5, got $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "unsafe_wrapper ran helper"
|
||||
[[ ! -e /tmp/proxmenux-unsafe ]] || fail "unsafe_wrapper evaluated CT content"
|
||||
|
||||
run_case custom_override RUN_HELPER=0 UPDATE_COMMAND='update-custom'
|
||||
[[ $CASE_RC -eq 0 ]] || fail "custom_override returned $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "custom_override unexpectedly ran helper"
|
||||
[[ $(count CUSTOM_EXEC) -eq 1 ]] || fail "custom_override did not run custom exactly once"
|
||||
|
||||
run_case custom_replaces_helper RUN_HELPER=1 UPDATE_COMMAND='replace-helper'
|
||||
[[ $CASE_RC -eq 0 ]] || fail "custom_replaces_helper returned $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "custom_replaces_helper unexpectedly ran helper"
|
||||
[[ $(count CUSTOM_EXEC) -eq 1 ]] || fail "custom_replaces_helper did not run custom exactly once"
|
||||
grep -qF 'skipping Proxmox VE Helper-Scripts updater' "$TEST_ROOT/custom_replaces_helper.out" \
|
||||
|| fail "custom_replaces_helper did not report the replacement rule"
|
||||
|
||||
run_case explicit_multi_app RUN_HELPER=1 ALLOW_HELPER_WITH_CUSTOM=1 UPDATE_COMMAND='update-another-app'
|
||||
[[ $CASE_RC -eq 0 ]] || fail "explicit_multi_app returned $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 1 ]] || fail "explicit_multi_app did not run helper exactly once"
|
||||
[[ $(count CUSTOM_EXEC) -eq 1 ]] || fail "explicit_multi_app did not run custom exactly once"
|
||||
|
||||
run_case missing_wrapper RUN_HELPER=1 UPDATE_COMMAND= MOCK_HAS_WRAPPER=0
|
||||
[[ $CASE_RC -eq 5 ]] || fail "missing_wrapper expected 5, got $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "missing_wrapper ran helper"
|
||||
|
||||
run_case base_os_wrapper RUN_HELPER=1 UPDATE_COMMAND= MOCK_HELPER_SLUG=debian
|
||||
[[ $CASE_RC -eq 5 ]] || fail "base_os_wrapper expected 5, got $CASE_RC"
|
||||
[[ $(count HELPER_EXEC) -eq 0 ]] || fail "base_os_wrapper ran helper"
|
||||
|
||||
run_case stopped_restore RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_INITIAL_STATE=stopped
|
||||
[[ $CASE_RC -eq 0 ]] || fail "stopped_restore returned $CASE_RC"
|
||||
[[ $(count 'pct start 101') -eq 1 ]] || fail "stopped CT was not started exactly once"
|
||||
[[ $(count 'pct shutdown 101 --timeout 60') -eq 1 ]] || fail "stopped CT state was not restored"
|
||||
|
||||
run_case locked RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_FLOCK_FAIL=1
|
||||
[[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
|
||||
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater"
|
||||
|
||||
echo "apply_updates.sh: all deterministic tests passed"
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Run real shells and the real LXC runner against local download/guest fixtures."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
|
||||
import lxc_apps
|
||||
|
||||
URL = 'https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/odoo.sh2'
|
||||
LEGACY = f'''PHS_SILENT=1 bash -c "$(wget -qLO - '{URL}')"'''
|
||||
|
||||
|
||||
class DownloadGuardTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory(prefix='proxmenux-download-test-')
|
||||
self.addCleanup(folder.cleanup)
|
||||
self.folder = Path(folder.name)
|
||||
self.bin = self.folder / 'bin'
|
||||
self.bin.mkdir()
|
||||
self.env = {**os.environ, 'PATH': f'{self.bin}:{os.environ["PATH"]}',
|
||||
'PYTHONPATH': str(ROOT / 'AppImage/scripts'),
|
||||
'FETCH_STATUS': '8', 'FETCH_BODY': '', 'VMID': '101',
|
||||
'TARGET': 'app', 'BACKUP': '0', 'RESTART': '0',
|
||||
'RUN_HELPER': '0', 'UPDATE_COMMAND': '',
|
||||
'PROXMENUX_LOCK_DIR': str(self.folder)}
|
||||
for tool in ('wget', 'curl'):
|
||||
self.write(tool, '#!/bin/sh\nprintf "%s" "$FETCH_BODY"\nexit "$FETCH_STATUS"\n')
|
||||
self.write('flock', '#!/bin/sh\nexit 0\n')
|
||||
self.write('pct', '''#!/bin/bash
|
||||
case "$1" in
|
||||
list) printf 'VMID Status Name\\n101 running fixture\\n' ;;
|
||||
status) echo 'status: running' ;;
|
||||
exec)
|
||||
shift 3
|
||||
case "$*" in
|
||||
*'/etc/os-release'*) echo 'ID=debian' ;;
|
||||
'test -f /usr/bin/update') exit 0 ;;
|
||||
'cat /usr/bin/update') echo 'SCRIPT_SLUG="odoo"' ;;
|
||||
*) exec "$@" ;;
|
||||
esac ;;
|
||||
*) exit 90 ;;
|
||||
esac
|
||||
''')
|
||||
# Only consent is stubbed. Preparation imports the real implementation.
|
||||
self.write('python3', '#!/bin/bash\n'
|
||||
'if [[ "$2" != "protect-update-command" ]]; then exit 0; fi\n'
|
||||
# Preload the source under test, not a monitor already
|
||||
# installed on the build host at the production path.
|
||||
f'exec {shlex.quote(sys.executable)} -c '
|
||||
"'import sys, lxc_apps; exec(sys.stdin.read())'\n")
|
||||
|
||||
def write(self, name, content):
|
||||
path = self.bin / name
|
||||
path.write_text(content)
|
||||
path.chmod(0o755)
|
||||
|
||||
def shell(self, command, status=8, body=''):
|
||||
return subprocess.run(['/bin/sh', '-c', command], text=True, capture_output=True,
|
||||
env={**self.env, 'FETCH_STATUS': str(status), 'FETCH_BODY': body}, timeout=10)
|
||||
|
||||
def test_reproduces_original_false_success(self):
|
||||
self.assertEqual(self.shell(LEGACY).returncode, 0)
|
||||
fixed = self.shell(lxc_apps.protect_download_update_command(LEGACY))
|
||||
self.assertNotEqual(fixed.returncode, 0)
|
||||
self.assertIn('download failed', fixed.stderr)
|
||||
|
||||
def test_supported_literal_launchers_preserve_url_shell_and_environment(self):
|
||||
for tool in ('wget -qLO -', 'wget -qO-', 'wget -qO -', 'curl -fsSL', 'curl -fSL'):
|
||||
for shell in ('bash', 'sh', '/bin/bash', '/bin/sh'):
|
||||
for prefix in ('', 'PHS_SILENT=0 ', 'PHS_SILENT=1 '):
|
||||
original = f'''{prefix}{shell} -c "$({tool} '{URL}')"'''
|
||||
with self.subTest(command=original):
|
||||
prepared = lxc_apps.protect_download_update_command(original)
|
||||
self.assertNotEqual(prepared, original)
|
||||
self.assertEqual(lxc_apps.protect_download_update_command(prepared), prepared)
|
||||
self.assertIn(URL, prepared)
|
||||
result = self.shell(prepared, 0, 'echo "RAN:${PHS_SILENT:-unset}"')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), 'RAN:' + (prefix.strip()[-1] if prefix else 'unset'))
|
||||
|
||||
def test_does_not_reinterpret_unrelated_or_dynamic_commands(self):
|
||||
for command in ('/opt/odoo/update.sh', 'false; true', 'echo "$(date)"',
|
||||
'bash -c "$(wget -qLO - \'$UPDATE_URL\')"',
|
||||
'bash -c "$(curl -fsSL https://example.com/update.sh; echo injected)"',
|
||||
'bash -c "$(curl -fsSL https://example.com/update.sh?x=1&y=2)"',
|
||||
'PHS_SILENT=1 bash -c "$(wget -qLO - https://example.com/update.sh)"; true',
|
||||
'curl -fsSL https://example.com/update.sh | bash'):
|
||||
self.assertEqual(lxc_apps.protect_download_update_command(command), command)
|
||||
|
||||
def test_failed_partial_and_empty_downloads_never_execute(self):
|
||||
prepared = lxc_apps.protect_download_update_command(LEGACY)
|
||||
for status, body in ((8, ''), (8, 'echo BAD_EXECUTION'), (0, '')):
|
||||
result = self.shell(prepared, status, body)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn('BAD_EXECUTION', result.stdout)
|
||||
result = self.shell(prepared, 0, 'echo REAL_UPDATER; exit 23')
|
||||
self.assertEqual(result.returncode, 23)
|
||||
self.assertIn('REAL_UPDATER', result.stdout)
|
||||
|
||||
def test_grouping_stops_later_apps_and_preserves_failures(self):
|
||||
command = lxc_apps.protect_download_update_command(LEGACY) + ' && echo NEXT_APP'
|
||||
self.assertNotIn('NEXT_APP', self.shell(command).stdout)
|
||||
self.assertNotIn('NEXT_APP', self.shell(command, 0, 'exit 23').stdout)
|
||||
self.assertIn('NEXT_APP', self.shell(command, 0, 'exit 0').stdout)
|
||||
|
||||
def test_real_runner_reports_failure_for_both_methods(self):
|
||||
for method in ('helper', 'custom'):
|
||||
for status, body, expected in ((8, '', 4), (8, 'echo BAD_EXECUTION', 4),
|
||||
(0, '', 4), (0, 'exit 23', 4), (0, 'exit 0', 0)):
|
||||
with self.subTest(method=method, status=status, body=body):
|
||||
result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')],
|
||||
capture_output=True, text=True, timeout=15, env={**self.env,
|
||||
'RUN_HELPER': '1' if method == 'helper' else '0',
|
||||
'UPDATE_COMMAND': LEGACY if method == 'custom' else '',
|
||||
'FETCH_STATUS': str(status), 'FETCH_BODY': body})
|
||||
self.assertEqual(result.returncode, expected, result.stdout + result.stderr)
|
||||
self.assertEqual('=== Update complete' in result.stdout, expected == 0)
|
||||
self.assertEqual('=== Update FAILED' in result.stdout, expected != 0)
|
||||
self.assertNotIn('BAD_EXECUTION', result.stdout)
|
||||
self.assert_terminal_notification(result.returncode)
|
||||
|
||||
def assert_terminal_notification(self, exit_code):
|
||||
# Actual completion hook + finalizer; only IO/metadata are stubbed.
|
||||
names = {'_terminal_lxc_update_completed', '_finalize_lxc_update'}
|
||||
nodes = [n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name in names]
|
||||
notification = Mock()
|
||||
ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification,
|
||||
_LXC_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'),
|
||||
_normalise_lxc_update_run_id=lambda value, **_: value,
|
||||
_normalise_lxc_update_targets=lambda values, _: values,
|
||||
_normalise_lxc_update_labels=lambda values: values,
|
||||
_json_list=lambda _: [], _lxc_update_finalizations={},
|
||||
_lxc_update_finalization_lock=threading.Lock(), _LXC_UPDATE_FINALIZATION_TTL=3600,
|
||||
_fast_guest_status=lambda *_: 'stopped',
|
||||
_lxc_update_snapshot=lambda *_: {'ct_name': 'fixture'},
|
||||
_lxc_update_target_labels=lambda *_: ['Odoo'],
|
||||
_lxc_update_details=lambda **kwargs: kwargs['status'],
|
||||
get_proxmox_node_name=lambda: 'fixture-node')
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'completion', 'exec'), ns)
|
||||
params = {'RUN_ID': 'fixture-run', 'VMID': '101', 'TARGET': 'app'}
|
||||
for _ in range(2):
|
||||
ns['_terminal_lxc_update_completed'](script_path=ns['_LXC_APPLY_UPDATES_SCRIPT'],
|
||||
params=params, exit_code=exit_code, duration_seconds=1)
|
||||
notification.emit_event.assert_called_once()
|
||||
event = notification.emit_event.call_args.kwargs
|
||||
self.assertEqual(event['data']['result'], 'succeeded' if exit_code == 0 else 'failed')
|
||||
self.assertEqual(event['severity'], 'INFO' if exit_code == 0 else 'WARNING')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,565 @@
|
||||
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()
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Updater consent regressions: temporary sidecars, no guests or network."""
|
||||
import ast
|
||||
import copy
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
|
||||
import lxc_apps as apps
|
||||
|
||||
|
||||
def routes():
|
||||
names = {'_scheduled_helper_enabled', '_resolve_bulk_update_plan',
|
||||
'_compose_scheduled_update_command', '_run_scheduled_update',
|
||||
'_normalise_schedule_targets'}
|
||||
nodes = [node for node in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
|
||||
if isinstance(node, ast.FunctionDef) and node.name in names]
|
||||
ns = dict(json=json, os=os, re=re, time=time, uuid=uuid, datetime=datetime,
|
||||
subprocess=types.SimpleNamespace(run=Mock(return_value=types.SimpleNamespace(returncode=0)),
|
||||
TimeoutExpired=subprocess.TimeoutExpired),
|
||||
_DOCKER_ENGINE_INTEGRATED_COMMAND='integrated-docker',
|
||||
_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'),
|
||||
_create_lxc_update_log=lambda *_: ('test.log', None),
|
||||
_fast_guest_status=lambda *_: 'running',
|
||||
_lxc_update_snapshot=lambda *_: {'ct_name': 'test'},
|
||||
_lxc_update_target_labels=lambda *_: [],
|
||||
_append_lxc_update_log=lambda *_: None,
|
||||
_prune_lxc_update_logs=lambda *_: None,
|
||||
_finalize_lxc_update=Mock(return_value={}),
|
||||
_inspect_lxc_reboot_requirement=lambda *_, **__: (False, [], None))
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'update-routes', 'exec'), ns)
|
||||
return ns
|
||||
|
||||
|
||||
class UpdateChoiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.addCleanup(patch.stopall)
|
||||
patch.object(apps, '_APPS_DIR', directory.name).start()
|
||||
patch.object(apps, 'check_app', return_value=None).start()
|
||||
self.item = {'type': 'lxc', '_vmid': 101, '_has_app_updater': True,
|
||||
'_helper_slug_source': 'update_wrapper', '_helper_slug': 'qbittorrent'}
|
||||
patch.dict(sys.modules, {'lxc_apps': apps, 'managed_installs': types.SimpleNamespace(
|
||||
get_active_items=lambda: [self.item])}).start()
|
||||
self.api = routes()
|
||||
|
||||
def save(self, records, **extra):
|
||||
data = {'vmid': 101, 'apps': copy.deepcopy(records), **extra}
|
||||
self.assertTrue(apps._write_sidecar(101, data))
|
||||
return apps._read_sidecar(101)
|
||||
|
||||
def helper(self, method='helper', **extra):
|
||||
return {'id': 'qbit', 'name': 'qBittorrent', 'helper_slug': 'qbittorrent',
|
||||
'update_method': method, **extra}
|
||||
|
||||
def test_registration_does_not_enable_helper_even_in_legacy_wildcard_schedule(self):
|
||||
self.save([], schedule={'enabled': True, 'target': 'both'})
|
||||
ok, sidecar = apps.add_app(101, {'name': 'qBittorrent', 'helper_slug': 'qbittorrent'})
|
||||
self.assertTrue(ok, sidecar)
|
||||
self.assertEqual(sidecar['apps'][0]['update_method'], 'none')
|
||||
self.assertFalse(self.api['_scheduled_helper_enabled'](101, 'app', ['apps']))
|
||||
|
||||
def test_legacy_commands_and_explicit_selections_survive_migration(self):
|
||||
helper = self.helper()
|
||||
helper.pop('update_method')
|
||||
result = self.save([helper, {'id': 'manual', 'update_command': 'my-updater'}],
|
||||
bulk_update={'targets': ['os', 'app:qbit']})
|
||||
self.assertEqual([a['update_method'] for a in result['apps']], ['helper', 'custom'])
|
||||
result = self.save([helper], schedule={'enabled': True, 'target': 'both'})
|
||||
self.assertEqual(result['apps'][0]['update_method'], 'helper')
|
||||
result = self.save([helper], schedule={'enabled': False, 'target': 'both'})
|
||||
self.assertEqual(result['apps'][0]['update_method'], 'none')
|
||||
self.assertEqual(self.save([helper])['apps'][0]['update_method'], 'none')
|
||||
|
||||
def test_choice_survives_editing_ports_and_disable_never_falls_back(self):
|
||||
self.save([self.helper()], schedule={'enabled': True, 'targets': ['apps']})
|
||||
record = apps._read_sidecar(101)['apps'][0]
|
||||
ok, result = apps.update_app(101, 'qbit', {**record, 'ports': [{'port': 8090}]})
|
||||
self.assertTrue(ok, result)
|
||||
self.assertEqual(result['apps'][0]['update_method'], 'helper')
|
||||
ok, result = apps.update_app(101, 'qbit', {**record, 'update_method': 'custom', 'update_command': 'my-updater'})
|
||||
self.assertTrue(ok, result)
|
||||
self.assertFalse(self.api['_scheduled_helper_enabled'](101, 'app', ['apps']))
|
||||
ok, result = apps.update_app(101, 'qbit', {**record, 'update_method': 'none', 'update_command': ''})
|
||||
self.assertTrue(ok, result)
|
||||
self.assertFalse(apps.helper_update_selected(101, 'qbittorrent'))
|
||||
self.assertTrue(result['schedule']['enabled'])
|
||||
|
||||
def test_conflicting_or_empty_methods_are_rejected(self):
|
||||
for payload in ({'update_method': 'custom'}, {'update_method': 'helper'},
|
||||
{'update_method': 'helper', 'helper_slug': 'qbittorrent', 'update_command': 'true'},
|
||||
{'update_method': 'none', 'update_command': 'true'}, {'update_method': 'automatic'}):
|
||||
self.assertFalse(apps.validate_config({'name': 'test', **payload})[0], payload)
|
||||
|
||||
def test_multi_app_bulk_keeps_methods_separate(self):
|
||||
self.save([self.helper(), {'id': 'other', 'name': 'Other', 'update_method': 'custom',
|
||||
'update_command': '/opt/other/update.sh'},
|
||||
{'id': 'unconfigured', 'helper_slug': 'jellyfin', 'update_method': 'none'}])
|
||||
plan = self.api['_resolve_bulk_update_plan'](101, ['os', 'app:qbit', 'app:other'])
|
||||
self.assertTrue(plan['ok'], plan)
|
||||
self.assertTrue(plan['run_helper'])
|
||||
self.assertTrue(plan['allow_helper_with_custom'])
|
||||
self.assertEqual(plan['update_command'], '/opt/other/update.sh')
|
||||
plan = self.api['_resolve_bulk_update_plan'](101, ['os', 'app:unconfigured'])
|
||||
self.assertFalse(plan['ok'])
|
||||
self.assertFalse(apps.helper_update_selected(101, 'qbittorrent', ['app:other']))
|
||||
|
||||
def test_helper_for_one_app_never_authorizes_a_different_helper(self):
|
||||
self.save([self.helper(), {'id': 'wrong', 'helper_slug': 'jellyfin', 'update_method': 'helper'}])
|
||||
plan = self.api['_resolve_bulk_update_plan'](101, ['os', 'app:qbit', 'app:wrong'])
|
||||
self.assertFalse(plan['ok'])
|
||||
self.assertEqual(plan['unavailable'][0]['target'], 'app:wrong')
|
||||
|
||||
def test_legacy_download_guard_covers_bulk_and_schedule_without_rewriting_settings(self):
|
||||
command = 'PHS_SILENT=1 bash -c "$(wget -qLO - \'https://example.com/odoo.sh2\')"'
|
||||
self.save([{'id': 'odoo', 'name': 'Odoo', 'update_method': 'custom', 'update_command': command},
|
||||
{'id': 'other', 'name': 'Other', 'update_method': 'custom', 'update_command': 'echo OTHER'}])
|
||||
saved_before = Path(apps._sidecar_path(101)).read_bytes()
|
||||
plan = self.api['_resolve_bulk_update_plan'](101, ['os', 'app:odoo', 'app:other'])
|
||||
self.assertTrue(plan['ok'])
|
||||
self.assertIn('updater download failed', plan['update_command'])
|
||||
self.assertTrue(plan['update_command'].endswith(') && echo OTHER'))
|
||||
self.api['subprocess'].run.return_value.returncode = 4
|
||||
result = self.api['_run_scheduled_update'](101, {'targets': ['app:odoo', 'app:other']})
|
||||
self.assertEqual(result['status'], 'failure')
|
||||
actual = self.api['subprocess'].run.call_args.kwargs['env']['UPDATE_COMMAND']
|
||||
self.assertEqual(actual, plan['update_command'])
|
||||
self.assertEqual(self.api['_finalize_lxc_update'].call_args.kwargs['status'], 'failure')
|
||||
self.assertEqual(Path(apps._sidecar_path(101)).read_bytes(), saved_before)
|
||||
self.assertEqual(apps._read_sidecar(101)['apps'][0]['update_command'], command)
|
||||
|
||||
def test_detection_and_duplicate_conflicts_do_not_authorize_execution(self):
|
||||
self.save([self.helper('none')])
|
||||
self.assertFalse(apps.helper_update_selected(101, 'qbittorrent'))
|
||||
self.save([self.helper(), {**self.helper('custom'), 'id': 'duplicate', 'update_command': 'my-update'}])
|
||||
self.assertFalse(apps.helper_update_selected(101, 'qbittorrent'))
|
||||
self.save([self.helper()])
|
||||
self.item['_helper_slug_source'] = 'tag_hostname'
|
||||
self.assertFalse(self.api['_scheduled_helper_enabled'](101, 'app', ['apps']))
|
||||
|
||||
def test_disabled_app_schedule_reports_skipped_without_running(self):
|
||||
self.save([self.helper('none')])
|
||||
result = self.api['_run_scheduled_update'](101, {'targets': ['app:qbit']})
|
||||
self.assertEqual(result['status'], 'skipped')
|
||||
self.assertEqual(result['executed_targets'], [])
|
||||
self.api['subprocess'].run.assert_not_called()
|
||||
|
||||
def test_disabled_app_does_not_block_os_or_custom_app_and_reports_partial(self):
|
||||
self.save([self.helper('none'), {'id': 'other', 'update_command': 'my-updater'}])
|
||||
result = self.api['_run_scheduled_update'](101, {'targets': ['os', 'app:qbit', 'app:other']})
|
||||
self.assertEqual(result['status'], 'partial')
|
||||
self.assertEqual(result['executed_targets'], ['os', 'app:other'])
|
||||
env = self.api['subprocess'].run.call_args.kwargs['env']
|
||||
self.assertEqual(env['RUN_HELPER'], '0')
|
||||
self.assertEqual(env['UPDATE_COMMAND'], 'my-updater')
|
||||
|
||||
def test_wildcard_only_runs_selected_methods(self):
|
||||
self.save([self.helper(), {'id': 'other', 'update_command': 'my-updater'},
|
||||
{'id': 'links', 'name': 'Links only', 'update_method': 'none'}])
|
||||
result = self.api['_run_scheduled_update'](101, {'targets': ['apps']})
|
||||
self.assertEqual(result['status'], 'success')
|
||||
self.assertEqual(result['executed_targets'], ['app:qbit', 'app:other'])
|
||||
env = self.api['subprocess'].run.call_args.kwargs['env']
|
||||
self.assertEqual(env['RUN_HELPER'], '1')
|
||||
self.assertEqual(env['UPDATE_COMMAND'], 'my-updater')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,280 @@
|
||||
// Exercise the actual selector with a tiny JSX/hook harness; no browser or API.
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const {spawnSync} = require('node:child_process')
|
||||
const os = require('node:os')
|
||||
const root = path.resolve(__dirname, '../../AppImage')
|
||||
const ts = require(path.join(root, 'node_modules/typescript'))
|
||||
const source = fs.readFileSync(path.join(root, 'components/app-updater-editor.tsx'), 'utf8')
|
||||
const result = ts.transpileModule(source, {compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
||||
}, reportDiagnostics: true})
|
||||
assert.equal(result.diagnostics.length, 0)
|
||||
const flatten = node => !node || typeof node !== 'object' ? [] : [node, ...(
|
||||
[node.props?.children].flat(Infinity).flatMap(flatten)
|
||||
)]
|
||||
|
||||
// Check the real shared Button and cn/tailwind-merge, not only editor props.
|
||||
function loadSource(relative) {
|
||||
const compiled = ts.transpileModule(fs.readFileSync(path.join(root, relative), 'utf8'), {
|
||||
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX},
|
||||
})
|
||||
const context = {exports: {}, require: name => name === '@/lib/utils'
|
||||
? loadSource('lib/utils.ts') : require(path.join(root, 'node_modules', name))}
|
||||
vm.runInNewContext(compiled.outputText, context)
|
||||
return context.exports
|
||||
}
|
||||
const actualButton = loadSource('components/ui/button.tsx').Button
|
||||
const navbarSource = fs.readFileSync(path.join(root, 'components/proxmox-dashboard.tsx'), 'utf8')
|
||||
const appPanelSource = fs.readFileSync(path.join(root, 'components/lxc-app-panel.tsx'), 'utf8')
|
||||
function assertSaveContrast(save) {
|
||||
const dom = actualButton.render(save.props, null)
|
||||
assert.equal(dom.props.disabled, save.props.disabled)
|
||||
assert.match(dom.props.className, /\btext-white\b/)
|
||||
assert.match(dom.props.className, /disabled:opacity-50/)
|
||||
assert(!dom.props.className.includes('disabled:opacity-100'), 'retain the shared disabled appearance')
|
||||
assert(!dom.props.className.includes('disabled:bg-blue-800'), 'do not override the disabled background')
|
||||
const background = dom.props.className.split(' ').find(token => /^bg-blue-\d+$/.test(token))
|
||||
assert.equal(background, 'bg-blue-500')
|
||||
assert(navbarSource.includes(`data-[state=active]:${background}`), 'match the active navigation tab blue')
|
||||
}
|
||||
let englishEditor
|
||||
|
||||
for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
|
||||
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
|
||||
let help = null
|
||||
const t = key => {
|
||||
const value = key.split('.').reduce((object, part) => object?.[part], messages)
|
||||
assert.equal(typeof value, 'string', `${locale}: missing ${key}`)
|
||||
return value
|
||||
}
|
||||
const context = {exports: {}, require: name => {
|
||||
if (name === 'react') return {useId: () => 'test-command', useState: () => [help, value => {help = value}]}
|
||||
if (name === 'react/jsx-runtime') return {jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})}
|
||||
if (name === '@/lib/i18n/provider') return {useT: () => t}
|
||||
return new Proxy({}, {get: (_, key) => String(key)})
|
||||
}}
|
||||
vm.runInNewContext(result.outputText, context)
|
||||
if (locale === 'en') englishEditor = context.exports.AppUpdaterEditor
|
||||
let saves = 0, method = 'none', command = ''
|
||||
const props = {method, command, helperAvailable: true, helperSlug: 'qbittorrent', configured: false,
|
||||
saving: false, changed: true, onMethodChange: value => {method = value}, onCommandChange: value => {command = value},
|
||||
onSave: () => {saves++}, onCancel: () => {}, onRemove: () => {}}
|
||||
const render = overrides => flatten(context.exports.AppUpdaterEditor({...props, method, command, ...overrides}))
|
||||
let tree = render()
|
||||
if (locale === 'es') assert.equal(t('vmLxc.updates.updaterChoiceHint'), 'Elige y guarda un método de actualización.')
|
||||
assert.equal(tree.filter(n => n.props['aria-pressed'] === true).length, 0)
|
||||
assert.equal(tree.filter(n => n.type === 'Textarea').length, 0)
|
||||
let save = tree.find(n => n.type === 'Button' && n.props.onClick === props.onSave)
|
||||
assert.equal(save.props.disabled, true)
|
||||
assert.match(save.props.className, /bg-blue-500/)
|
||||
assert.match(save.props.className, /hover:bg-blue-600/)
|
||||
assert.match(save.props.className, /text-white/)
|
||||
assertSaveContrast(save)
|
||||
const helperButton = tree.find(n => n.type === 'Button' && n.props.children === t('vmLxc.updates.helperMethod'))
|
||||
helperButton.props.onClick()
|
||||
assert.equal(method, 'helper')
|
||||
assert.equal(saves, 0, 'selecting must not execute or save')
|
||||
tree = render()
|
||||
const helperField = tree.find(n => n.type === 'Textarea')
|
||||
const canonicalHelper = helperField.props.value
|
||||
assert.equal(canonicalHelper, 'PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/qbittorrent.sh)"')
|
||||
assert(!canonicalHelper.includes('_proxmenux_updater'), 'internal guards must not appear in the editor')
|
||||
assert(!helperField.props.readOnly, 'the helper launcher must be editable')
|
||||
helperField.props.onChange({target: {value: canonicalHelper}})
|
||||
assert.equal(method, 'helper', 'an unchanged launcher remains the official method')
|
||||
const editedHelper = canonicalHelper.replace('PHS_SILENT=1', 'PHS_SILENT=0')
|
||||
helperField.props.onChange({target: {value: editedHelper}})
|
||||
assert.equal(method, 'custom', 'editing must route execution through the saved custom command')
|
||||
assert.equal(command, editedHelper)
|
||||
assert.equal(render().find(n => n.type === 'Textarea').props.value, editedHelper)
|
||||
helperButton.props.onClick()
|
||||
assert.equal(render().find(n => n.type === 'Textarea').props.value, canonicalHelper)
|
||||
assert.equal(command, editedHelper, 'switching back to helper preserves the unsaved custom draft')
|
||||
command = ''
|
||||
tree = render()
|
||||
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, false)
|
||||
assertSaveContrast(tree.find(n => n.props.onClick === props.onSave))
|
||||
for (const overrides of [{saving: true}, {changed: false}]) {
|
||||
const disabledSave = render(overrides).find(n => n.props.onClick === props.onSave)
|
||||
assert.equal(disabledSave.props.disabled, true)
|
||||
assertSaveContrast(disabledSave)
|
||||
}
|
||||
const helperInfo = tree.find(n => n.props['aria-label'] === t('vmLxc.updates.helperMethodHelp'))
|
||||
assert.match(helperInfo.props.className, /text-blue-500/)
|
||||
helperInfo.props.onClick()
|
||||
tree = render()
|
||||
assert.equal(tree.find(n => n.type === 'Dialog').props.open, true)
|
||||
assert(tree.some(n => n.type === 'a' && n.props.href.endsWith('/ct/qbittorrent.sh')))
|
||||
assert(tree.some(n => n.type === 'a' && n.props.href === 'https://community-scripts.org/docs/tools/pve/update-apps'))
|
||||
const helperLinks = tree.filter(n => n.type === 'a')
|
||||
assert.equal(helperLinks.length, 2)
|
||||
for (const link of helperLinks) {
|
||||
assert.match(link.props.className, /text-blue-400 hover:text-blue-300/)
|
||||
assert(appPanelSource.includes('text-blue-400 hover:text-blue-300'), 'use the same colors as the App web links')
|
||||
}
|
||||
assert(tree.some(n => n.type === 'code' && n.props.children === canonicalHelper))
|
||||
assert(!render({helperSlug: "bad'; touch /tmp/injected"}).some(n => n.type === 'code'))
|
||||
assert(!render({helperSlug: undefined}).some(n => n.type === 'code'))
|
||||
assert.equal(tree.filter(n => n.type === 'Textarea').length, 1)
|
||||
assert.equal(render({helperSlug: undefined}).find(n => n.props.onClick === props.onSave).props.disabled, true)
|
||||
const customInfo = tree.find(n => n.props['aria-label'] === t('vmLxc.updates.customMethodHelp'))
|
||||
assert.match(customInfo.props.className, /text-blue-500/)
|
||||
customInfo.props.onClick()
|
||||
tree = render()
|
||||
const headingIndex = tree.findIndex(n => n.type === 'p' && n.props.children === t('vmLxc.updates.customExamplesHeading'))
|
||||
const descriptionIndex = tree.findIndex(n => n.type === 'DialogDescription')
|
||||
const firstExampleIndex = tree.findIndex(n => n.type === 'code')
|
||||
assert(headingIndex > descriptionIndex && headingIndex < firstExampleIndex, 'examples heading follows the introduction')
|
||||
if (locale === 'es') assert.equal(t('vmLxc.updates.customExamplesHeading'), 'Ejemplos para adaptar:')
|
||||
const examples = tree.filter(n => n.type === 'code').map(n => n.props.children)
|
||||
assert.equal(examples.length, 4)
|
||||
assert(examples.includes('/opt/my-app/update.sh'))
|
||||
assert(examples[1].includes("curl -fsSL 'https://example.com/my-app/update.sh'"))
|
||||
assert(examples[1].includes('-o "$script" &&\nbash "$script"'), 'never execute a failed download')
|
||||
assert(examples[1].includes('trap'), 'clean up the temporary download')
|
||||
for (const example of examples) {
|
||||
const syntax = spawnSync('sh', ['-n'], {input: example, encoding: 'utf8'})
|
||||
assert.equal(syntax.status, 0, syntax.stderr)
|
||||
}
|
||||
if (locale === 'en') {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'proxmenux-helper-launcher-'))
|
||||
try {
|
||||
// The visible command stays short; the actual backend adds protection.
|
||||
fs.symlinkSync('/bin/bash', path.join(fixture, 'bash'))
|
||||
const fetch = '#!/bin/sh\nprintf "%s" "$FETCH_BODY"\nexit "$FETCH_STATUS"\n'
|
||||
fs.writeFileSync(path.join(fixture, 'curl'), fetch, {mode: 0o755})
|
||||
for (const tool of ['wget', 'curl']) {
|
||||
const wget = path.join(fixture, 'wget')
|
||||
if (tool === 'wget') fs.writeFileSync(wget, fetch, {mode: 0o755})
|
||||
else fs.unlinkSync(wget)
|
||||
const command = canonicalHelper.replaceAll('qbittorrent.sh', 'qbittorrent.sh2')
|
||||
.replace('curl -fsSL', tool === 'wget' ? 'wget -qLO -' : 'curl -fsSL')
|
||||
const prepared = spawnSync('python3', ['-c',
|
||||
'import sys; from lxc_apps import protect_download_update_command; sys.stdout.write(protect_download_update_command(sys.stdin.read()))'], {
|
||||
input: command, encoding: 'utf8', env: {...process.env, PYTHONPATH: path.join(root, 'scripts')},
|
||||
})
|
||||
assert.equal(prepared.status, 0, prepared.stderr)
|
||||
assert(prepared.stdout.includes('updater download failed'), 'both curl and historical wget launchers remain protected')
|
||||
for (const [status, body, expected] of [[8, '', 1], [8, 'echo SHOULD_NOT_RUN', 1],
|
||||
[0, '', 1], [0, 'echo SCRIPT_RAN; exit 0', 0], [0, 'echo SCRIPT_RAN; exit 23', 23]]) {
|
||||
const run = spawnSync('/bin/sh', ['-c', prepared.stdout], {
|
||||
encoding: 'utf8', env: {...process.env, PATH: fixture, FETCH_STATUS: String(status), FETCH_BODY: body},
|
||||
})
|
||||
assert.equal(run.status, expected, run.stderr)
|
||||
assert(!run.stdout.includes('SHOULD_NOT_RUN'), 'a partial failed download must not execute')
|
||||
assert.equal(run.stdout.includes('SCRIPT_RAN'), status === 0 && body !== '')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(fixture, {recursive: true, force: true})
|
||||
}
|
||||
}
|
||||
if (locale === 'en') {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'proxmenux-online-example-'))
|
||||
try {
|
||||
const bin = path.join(fixture, 'bin')
|
||||
fs.mkdirSync(bin)
|
||||
fs.writeFileSync(path.join(bin, 'curl'), '#!/bin/sh\nexit "$EXAMPLE_FETCH_STATUS"\n', {mode: 0o755})
|
||||
fs.writeFileSync(path.join(bin, 'bash'), '#!/bin/sh\n: > "$EXAMPLE_EXECUTION_MARKER"\n', {mode: 0o755})
|
||||
for (const status of [0, 22]) {
|
||||
const marker = path.join(fixture, `ran-${status}`)
|
||||
const run = spawnSync('/bin/sh', ['-c', examples[1]], {encoding: 'utf8', env: {
|
||||
...process.env, PATH: `${bin}:${process.env.PATH}`, TMPDIR: fixture,
|
||||
EXAMPLE_FETCH_STATUS: String(status), EXAMPLE_EXECUTION_MARKER: marker,
|
||||
}})
|
||||
assert.equal(run.status, status, run.stderr)
|
||||
assert.equal(fs.existsSync(marker), status === 0, 'do not run a failed or incomplete download')
|
||||
}
|
||||
assert.deepEqual(fs.readdirSync(fixture).sort(), ['bin', 'ran-0'], 'temporary scripts must be removed')
|
||||
} finally {
|
||||
fs.rmSync(fixture, {recursive: true, force: true})
|
||||
}
|
||||
}
|
||||
assert(examples.some(code => code.includes('apt-get install -y --only-upgrade my-package')))
|
||||
assert(examples.some(code => code.includes('install -b -m 0755 /tmp/my-app.new')))
|
||||
assert.equal(command, '', 'help examples must not change the saved command')
|
||||
assert.equal(saves, 0, 'opening help must not execute or save')
|
||||
assert.match(tree.find(n => n.type === 'DialogContent').props.className, /overflow-y-auto/)
|
||||
method = 'custom'
|
||||
tree = render()
|
||||
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, true)
|
||||
tree.find(n => n.type === 'Textarea').props.onChange({target: {value: '/opt/my-app/update.sh'}})
|
||||
tree = render()
|
||||
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, false)
|
||||
tree = render({helperAvailable: false})
|
||||
assert(!tree.some(n => n.type === 'Button' && n.props.children === t('vmLxc.updates.helperMethod')))
|
||||
method = 'helper'
|
||||
tree = render({helperAvailable: false})
|
||||
assert.equal(tree.find(n => n.props.onClick === props.onSave).props.disabled, true)
|
||||
console.log(`PASS ${locale}: explicit selection, navbar blue, dimmed disabled Save, examples heading, local/online scripts, no execution, validation`)
|
||||
}
|
||||
|
||||
// Integrate the editor with the actual parent open/save/cancel functions.
|
||||
async function testParentRoundTrip() {
|
||||
const parentSource = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
|
||||
const ast = ts.createSourceFile('virtual-machines.tsx', parentSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const names = ['openCustomCmdEditor', 'saveCustomCommand', 'closeCustomCmdEditor']
|
||||
const declarations = {}
|
||||
function visit(node) {
|
||||
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && names.includes(node.name.text)) {
|
||||
declarations[node.name.text] = `const ${node.getText(ast)};`
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(ast)
|
||||
for (const name of names) assert(declarations[name], `missing actual parent function ${name}`)
|
||||
const compiled = ts.transpileModule(names.map(n => declarations[n]).join('\n') + '\n' +
|
||||
names.map(n => `exports.${n} = ${n};`).join('\n'), {compilerOptions: {module: ts.ModuleKind.CommonJS}})
|
||||
const saved = new Map([
|
||||
['a', {id: 'a', name: 'Odoo', helper_slug: 'odoo', update_method: 'helper', update_command: ''}],
|
||||
['b', {id: 'b', name: 'Other app', update_method: 'custom', update_command: '/opt/other/update.sh'}],
|
||||
])
|
||||
let writes = 0
|
||||
const parent = {exports: {}, customCmdDraft: '', updaterMethodDraft: 'none',
|
||||
setCustomCmdEditingApp: value => {parent.editing = value},
|
||||
setCustomCmdDraft: value => {parent.customCmdDraft = value},
|
||||
setUpdaterMethodDraft: value => {parent.updaterMethodDraft = value},
|
||||
setCustomCmdSaving: value => {parent.saving = value},
|
||||
patchAppWatch: async (vmid, app, patch) => {
|
||||
assert.equal(vmid, 101)
|
||||
saved.set(app.id, {...saved.get(app.id), ...patch})
|
||||
writes++
|
||||
}, t: key => key, alert: error => {throw Error(error)},
|
||||
}
|
||||
vm.runInNewContext(compiled.outputText, parent)
|
||||
const render = () => flatten(englishEditor({method: parent.updaterMethodDraft, command: parent.customCmdDraft,
|
||||
helperAvailable: true, helperSlug: saved.get(parent.editing)?.helper_slug, configured: true,
|
||||
saving: false, changed: true, onMethodChange: parent.setUpdaterMethodDraft,
|
||||
onCommandChange: parent.setCustomCmdDraft, onCancel: parent.exports.closeCustomCmdEditor,
|
||||
onSave: () => {}, onRemove: () => {}}))
|
||||
parent.exports.openCustomCmdEditor(saved.get('a'))
|
||||
let field = render().find(n => n.type === 'Textarea')
|
||||
assert(field.props.value.includes('/ct/odoo.sh'))
|
||||
const edited = field.props.value.replace('PHS_SILENT=1', 'PHS_SILENT=0')
|
||||
field.props.onChange({target: {value: edited}})
|
||||
assert.equal(writes, 0, 'editing must not persist before Save')
|
||||
parent.exports.closeCustomCmdEditor()
|
||||
assert.equal(saved.get('a').update_method, 'helper', 'Cancel leaves the original choice unchanged')
|
||||
parent.exports.openCustomCmdEditor(saved.get('a'))
|
||||
field = render().find(n => n.type === 'Textarea')
|
||||
assert(field.props.value.includes('PHS_SILENT=1'))
|
||||
field.props.onChange({target: {value: edited}})
|
||||
await parent.exports.saveCustomCommand(101, saved.get('a'))
|
||||
assert.equal(saved.get('a').update_method, 'custom')
|
||||
assert.equal(saved.get('a').update_command, edited, 'Save must not discard the edited helper launcher')
|
||||
parent.exports.openCustomCmdEditor(saved.get('a'))
|
||||
assert.equal(render().find(n => n.type === 'Textarea').props.value, edited, 'reopen the exact saved custom command')
|
||||
parent.exports.openCustomCmdEditor(saved.get('b'))
|
||||
assert.equal(render().find(n => n.type === 'Textarea').props.value, '/opt/other/update.sh', 'keep commands separate per app')
|
||||
parent.exports.openCustomCmdEditor(saved.get('a'))
|
||||
parent.setUpdaterMethodDraft('helper')
|
||||
await parent.exports.saveCustomCommand(101, saved.get('a'))
|
||||
assert.equal(saved.get('a').update_method, 'helper')
|
||||
assert.equal(saved.get('a').update_command, '', 'explicitly selecting helper restores the official path')
|
||||
assert.equal(saved.get('b').update_command, '/opt/other/update.sh')
|
||||
console.log('PASS actual parent: edit, cancel, save, reopen, per-app isolation, restore helper')
|
||||
}
|
||||
testParentRoundTrip().catch(error => {console.error(error); process.exitCode = 1})
|
||||
Reference in New Issue
Block a user