mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
Merge pull request #397 from f3rs3n/fix/oci-selection-setup-diagnostics
Clarify OCI selection and stack setup diagnostics
This commit is contained in:
@@ -0,0 +1,146 @@
|
|||||||
|
"""Inert extracted OCI selection/setup consumers; no admin module imports or scripts sourced."""
|
||||||
|
import ast
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||||
|
OLD_EMPTY = 'No OCI instances are registered.'
|
||||||
|
EMPTY = 'No registered OCI containers are available for selection on this host.'
|
||||||
|
OLD_REPLAY = 'This stack requires replaying specific rootfs adaptations. Coordinated updates are not yet enabled for it.'
|
||||||
|
NO_MEMBERS = 'This stack has no saved members to update.'
|
||||||
|
REPLAY = 'This stack needs rootfs adaptations that coordinated updates cannot replay yet.'
|
||||||
|
HTTP = 'The HTTP check for the application failed:'
|
||||||
|
|
||||||
|
|
||||||
|
def extracted(name, **dependencies):
|
||||||
|
node = next(n for n in ast.parse(MENU.read_text()).body
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||||
|
# Drop only infrastructure imports, retaining all selection, guard, and return logic.
|
||||||
|
node.body = [n for n in node.body if not (isinstance(n, ast.Import) and
|
||||||
|
any(a.name == 'oci_instances' or a.name == 'oci_stack_replay' for a in n.names))]
|
||||||
|
for n in ast.walk(node):
|
||||||
|
if isinstance(n, (ast.If, ast.With)):
|
||||||
|
n.body = [x for x in n.body if not (isinstance(x, ast.Import) and
|
||||||
|
any(a.name in ('oci_instances', 'oci_stack_replay') for a in x.names))]
|
||||||
|
scope = dict(dependencies)
|
||||||
|
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||||
|
str(MENU), 'exec'), scope)
|
||||||
|
return scope[name]
|
||||||
|
|
||||||
|
|
||||||
|
class SelectionSetupWording(TestCase):
|
||||||
|
def test_selector_empty_explains_availability_not_registry_absence(self):
|
||||||
|
ui = SimpleNamespace(message=Mock(), choose=Mock())
|
||||||
|
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
|
||||||
|
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
|
||||||
|
translate=lambda s: s)
|
||||||
|
fn(Path('/fixture'), ui)
|
||||||
|
ui.message.assert_called_once_with(EMPTY, 'OCI management')
|
||||||
|
ui.choose.assert_not_called()
|
||||||
|
|
||||||
|
def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
|
||||||
|
class Entry:
|
||||||
|
name = '101'
|
||||||
|
class Lock:
|
||||||
|
def __enter__(self): return None
|
||||||
|
def __exit__(self, *_): return False
|
||||||
|
fake = SimpleNamespace(ROOT=SimpleNamespace(iterdir=lambda: [Entry()]),
|
||||||
|
locked=lambda _: Lock(), has_contract=lambda *args: True,
|
||||||
|
guest_exists=lambda _: False, read=Mock())
|
||||||
|
fn = extracted('saved_inventory', sys=SimpleNamespace(path=[]),
|
||||||
|
instances=fake, public_row=Mock())
|
||||||
|
self.assertEqual(fn(Path('/fixture')), [])
|
||||||
|
fake.read.assert_not_called()
|
||||||
|
|
||||||
|
def test_stack_without_members_is_not_diagnosed_as_replay_failure(self):
|
||||||
|
self._stack({}, NO_MEMBERS)
|
||||||
|
|
||||||
|
def test_stack_unsupported_replay_retains_distinct_verdict(self):
|
||||||
|
self._stack({'members': [{'native_stack_intent': {'adapt': True}}]}, REPLAY)
|
||||||
|
|
||||||
|
def _stack(self, stack, expected):
|
||||||
|
record = {'stack': stack}
|
||||||
|
fake = SimpleNamespace(ROOT=Path('/fixture'), read=lambda *args: record)
|
||||||
|
replay = SimpleNamespace(**{n + '_menu_ready': lambda _: False
|
||||||
|
for n in ('nextcloud', 'paperless', 'tandoor', 'immich')})
|
||||||
|
ui = SimpleNamespace(message=Mock(), choose=Mock(), review=Mock())
|
||||||
|
fn = extracted('_manage_stack', sys=SimpleNamespace(path=[]), instances=fake,
|
||||||
|
oci_stack_replay=replay, translate=lambda s: s)
|
||||||
|
self.assertFalse(fn(Path('/fixture'), ui, {'vmid': 101}))
|
||||||
|
ui.message.assert_called_once_with(expected, 'OCI stack management')
|
||||||
|
ui.choose.assert_not_called()
|
||||||
|
ui.review.assert_not_called()
|
||||||
|
|
||||||
|
def test_actual_http_failure_seams_preserve_app_and_log_redirect(self):
|
||||||
|
for app, port in (('paperless', ':8000'), ('tandoor', '')):
|
||||||
|
with self.subTest(app=app):
|
||||||
|
path = ROOT / f'oci/remote/install_{app}_stack.sh'
|
||||||
|
lines = path.read_text().splitlines()
|
||||||
|
start = next(i for i, line in enumerate(lines) if line.strip().startswith('curl -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
||||||
|
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
|
||||||
|
# Actual Bash failure expression, never source the parent installer.
|
||||||
|
harness = '''set -u
|
||||||
|
APPLICATION_LAN_IP=192.0.2.1
|
||||||
|
APPLICATION_ID=101
|
||||||
|
OCI_LOG=/dev/null
|
||||||
|
curl() { return 7; }
|
||||||
|
translate() { printf '%s' "$1"; }
|
||||||
|
print_first_boot_diagnostics() { :; }
|
||||||
|
die() { printf 'ERROR:%s\\n' "$1"; }
|
||||||
|
''' + command + '\n'
|
||||||
|
run = subprocess.run(['bash', '-c', harness], text=True,
|
||||||
|
capture_output=True, timeout=3)
|
||||||
|
self.assertEqual(run.returncode, 0, run.stderr)
|
||||||
|
self.assertEqual(run.stdout.strip(),
|
||||||
|
f'ERROR:{HTTP} {"Paperless-ngx" if app == "paperless" else "Tandoor"}')
|
||||||
|
self.assertIn('2>>"$OCI_LOG"', command)
|
||||||
|
|
||||||
|
def test_nextcloud_explicit_setup_guard_kept(self):
|
||||||
|
source = (ROOT / 'oci/remote/install_nextcloud_stack.sh').read_text()
|
||||||
|
self.assertIn("jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false'", source)
|
||||||
|
self.assertIn('die "$(translate "The application did not complete its initial setup:") Nextcloud"', source)
|
||||||
|
|
||||||
|
def test_shell_lookup_shipped_missing_and_synthetic_translation(self):
|
||||||
|
source = (ROOT / 'oci/remote/oci_ui.sh').read_text()
|
||||||
|
start = source.index('translate() {')
|
||||||
|
fn = source[start:source.index('\n}', start) + 2]
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
missing = Path(tmp) / 'missing.json'
|
||||||
|
missing.write_text('{}')
|
||||||
|
translated = Path(tmp) / 'translated.json'
|
||||||
|
translated.write_text(json.dumps({HTTP: 'VERIFICA HTTP FALLITA:'}))
|
||||||
|
for path in [*sorted((ROOT / 'lang').glob('*.json')), missing, translated]:
|
||||||
|
cache = json.loads(path.read_text())
|
||||||
|
bash = '_OCI_LANGUAGE=it\n_OCI_LANG_FILE="$1"\n' + fn + '\ntranslate "$2"\n'
|
||||||
|
result = subprocess.run(['bash', '-c', bash, 'fixture', str(path), HTTP],
|
||||||
|
text=True, capture_output=True, timeout=3)
|
||||||
|
self.assertEqual(result.returncode, 0, (path, result.stderr))
|
||||||
|
self.assertEqual(result.stdout, cache.get(HTTP) or HTTP, path)
|
||||||
|
|
||||||
|
def test_extraction_and_real_lookup_all_locales(self):
|
||||||
|
import runpy
|
||||||
|
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||||
|
extracted_keys = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||||
|
for key in (EMPTY, NO_MEMBERS, REPLAY):
|
||||||
|
self.assertIn(key, extracted_keys)
|
||||||
|
shell_keys = generator['extract_translate_texts'](ROOT / 'oci/remote')
|
||||||
|
self.assertIn(HTTP, shell_keys)
|
||||||
|
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
for path in sorted((ROOT / 'lang').glob('*.json')):
|
||||||
|
cache = json.loads(path.read_text())
|
||||||
|
module._language, module._cache = path.stem, cache
|
||||||
|
for key in (EMPTY, NO_MEMBERS, REPLAY, HTTP):
|
||||||
|
self.assertEqual(module.translate(key), cache.get(key) or key)
|
||||||
|
module._cache = {}
|
||||||
|
self.assertEqual(module.translate(EMPTY), EMPTY)
|
||||||
|
module._cache = {EMPTY: 'LOCALIZED SELECTOR'}
|
||||||
|
self.assertEqual(module.translate(EMPTY), 'LOCALIZED SELECTOR')
|
||||||
@@ -513,7 +513,7 @@ if (( START_AFTER == 1 )); then
|
|||||||
[[ -n $APPLICATION_LAN_IP ]] \
|
[[ -n $APPLICATION_LAN_IP ]] \
|
||||||
|| die "$(translate "The application did not get an address on the access network:") Paperless-ngx"
|
|| die "$(translate "The application did not get an address on the access network:") Paperless-ngx"
|
||||||
curl -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>>"$OCI_LOG" \
|
curl -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>>"$OCI_LOG" \
|
||||||
|| die "$(translate "The application did not complete its initial setup:") Paperless-ngx"
|
|| die "$(translate "The HTTP check for the application failed:") Paperless-ngx"
|
||||||
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}:8000/"
|
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}:8000/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ if (( START_AFTER == 1 )); then
|
|||||||
fi
|
fi
|
||||||
curl -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>>"$OCI_LOG" \
|
curl -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>>"$OCI_LOG" \
|
||||||
|| { print_first_boot_diagnostics "$APPLICATION_ID" tandoor; \
|
|| { print_first_boot_diagnostics "$APPLICATION_ID" tandoor; \
|
||||||
die "$(translate "The application did not complete its initial setup:") Tandoor"; }
|
die "$(translate "The HTTP check for the application failed:") Tandoor"; }
|
||||||
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}/"
|
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}/"
|
||||||
msg_info "$(translate "Creating the initial administrator...")"
|
msg_info "$(translate "Creating the initial administrator...")"
|
||||||
oci_quiet pct exec "$APPLICATION_ID" -- env DJANGO_SUPERUSER_PASSWORD="$ADMIN_PASSWORD" \
|
oci_quiet pct exec "$APPLICATION_ID" -- env DJANGO_SUPERUSER_PASSWORD="$ADMIN_PASSWORD" \
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ def _interactive_management(project, ui):
|
|||||||
return
|
return
|
||||||
rows = saved_inventory(project)
|
rows = saved_inventory(project)
|
||||||
if not rows:
|
if not rows:
|
||||||
ui.message(translate('No OCI instances are registered.'), translate('OCI management'))
|
ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management'))
|
||||||
return
|
return
|
||||||
# Same layout as the catalog lists; only an unusual state is shown.
|
# Same layout as the catalog lists; only an unusual state is shown.
|
||||||
tag_width = max(len(str(r['vmid'])) for r in rows)
|
tag_width = max(len(str(r['vmid'])) for r in rows)
|
||||||
@@ -352,12 +352,15 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
|
|||||||
import oci_stack_replay
|
import oci_stack_replay
|
||||||
needs_replay = any(m.get('native_stack_intent') or
|
needs_replay = any(m.get('native_stack_intent') or
|
||||||
m.get('deployment', {}).get('rootfs_adaptation_replay_required') for m in members)
|
m.get('deployment', {}).get('rootfs_adaptation_replay_required') for m in members)
|
||||||
if not members or (needs_replay and not (
|
if not members:
|
||||||
|
ui.message(translate('This stack has no saved members to update.'), translate('OCI stack management'))
|
||||||
|
return False
|
||||||
|
if needs_replay and not (
|
||||||
oci_stack_replay.nextcloud_menu_ready(primary) or
|
oci_stack_replay.nextcloud_menu_ready(primary) or
|
||||||
oci_stack_replay.paperless_menu_ready(primary) or
|
oci_stack_replay.paperless_menu_ready(primary) or
|
||||||
oci_stack_replay.tandoor_menu_ready(primary) or
|
oci_stack_replay.tandoor_menu_ready(primary) or
|
||||||
oci_stack_replay.immich_menu_ready(primary))):
|
oci_stack_replay.immich_menu_ready(primary)):
|
||||||
ui.message(translate('This stack requires replaying specific rootfs adaptations. Coordinated updates are not yet enabled for it.'), translate('OCI stack management'))
|
ui.message(translate('This stack needs rootfs adaptations that coordinated updates cannot replay yet.'), translate('OCI stack management'))
|
||||||
return False
|
return False
|
||||||
if action == 'recreate':
|
if action == 'recreate':
|
||||||
ui.message(translate('A multi-container application is not recreated: its containers are updated together.'), translate('OCI stack management'))
|
ui.message(translate('A multi-container application is not recreated: its containers are updated together.'), translate('OCI stack management'))
|
||||||
|
|||||||
Reference in New Issue
Block a user