mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
fix(oci): qualify import adoption and recovery diagnostics
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"""Offline checks of real OCI messages; never import administrative modules."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
REMOVE = ROOT / 'oci/remote/oci_remove.py'
|
||||
PREVIEW_HOST = 'Host paths found in container configs or saved records (not targeted for removal):'
|
||||
EMPTY_HOST = 'No host directories found in the available container configs or saved records.'
|
||||
POST_HOST = 'Host directory listed in saved records (not targeted for removal):'
|
||||
UPDATE = ('All images are downloaded and verified first, and native backups are taken with the stack stopped. '
|
||||
'Contracts are published after the whole set is checked. If a step fails, recovery is attempted '
|
||||
'where needed; recovery can also fail.')
|
||||
PENDING = ('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack '
|
||||
'where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.')
|
||||
KEYS = (PREVIEW_HOST, EMPTY_HOST, POST_HOST, UPDATE, PENDING)
|
||||
TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class InventoryMessages(unittest.TestCase):
|
||||
def preview(self, configs, records, cache=None, translate=lambda text: text):
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
def read(root, vmid):
|
||||
if vmid not in records:
|
||||
raise OSError('unreadable record')
|
||||
return records[vmid]
|
||||
instances.read = read
|
||||
remover = ModuleType('oci_remove')
|
||||
remover.members_of = lambda root, vmid: (101, {}, [102, 101])
|
||||
remover.guest_config = lambda vmid: configs.get(vmid)
|
||||
remover.host_directories = lambda root, members: cache or []
|
||||
remover.private_bridge = lambda primary: None
|
||||
state = ModuleType('oci_installation_state')
|
||||
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
|
||||
'translate': translate, 're': re}
|
||||
summary = extracted(MENU, '_removal_summary', scope)
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
return summary(Path('/inert'), 101)
|
||||
|
||||
def test_missing_metadata_is_not_reported_as_no_host_usage(self):
|
||||
text = self.preview({101: None, 102: None}, {})
|
||||
self.assertIn(EMPTY_HOST, text)
|
||||
self.assertNotIn('No host directory is used by this application.', text)
|
||||
|
||||
def test_discovered_paths_do_not_promise_exhaustive_preservation(self):
|
||||
text = self.preview({101: b'mp0: /bind/current,mp=/data\n', 102: None}, {}, ['/bind/saved'])
|
||||
self.assertIn(PREVIEW_HOST + '\n /bind/current\n /bind/saved', text)
|
||||
self.assertNotIn('Host directories that are kept, with their content:', text)
|
||||
|
||||
def test_removal_notice_qualifies_saved_record_source(self):
|
||||
events = []
|
||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||
identity=lambda raw: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
|
||||
'guest_config': lambda vmid: b'description: owned',
|
||||
'host_directories': lambda root, members: ['/bind/saved'],
|
||||
'private_bridge': lambda primary: None,
|
||||
'run': lambda *args: events.append(('run', args)),
|
||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'translate': lambda text: text,
|
||||
'msg_info': lambda text: events.append(('info', text)),
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
remove = extracted(REMOVE, 'remove', scope)
|
||||
remove(Path('/inert'), 101)
|
||||
self.assertIn(('warn', POST_HOST + ' /bind/saved'), events)
|
||||
self.assertIn(('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1')), events)
|
||||
scope['translate'] = lambda text: 'Tradotto: ' + text if text == POST_HOST else text
|
||||
events.clear()
|
||||
remove(Path('/inert'), 101)
|
||||
self.assertIn(('warn', 'Tradotto: ' + POST_HOST + ' /bind/saved'), events)
|
||||
|
||||
|
||||
class RecoveryMessages(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.journal = Path(self.tmp.name) / 'journal.json'
|
||||
self.members = [{'vmid': 101, 'deployment': {'mounts': []}}]
|
||||
self.primary = {'stack': {'members': self.members}}
|
||||
self.calls = []
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: self.primary
|
||||
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
|
||||
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path,
|
||||
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
'_remove': lambda *args: self.fail('remove must not run'),
|
||||
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
|
||||
self.ui = SimpleNamespace(
|
||||
choose=lambda *args: 'update',
|
||||
review=lambda message, *args, **kwargs: self.calls.append(('review', message)) or False,
|
||||
confirm=lambda *args: self.fail('host confirmation must not run'),
|
||||
message=lambda *args: self.fail('unexpected error'))
|
||||
|
||||
def manage(self):
|
||||
with patch.dict(sys.modules, self.modules):
|
||||
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
|
||||
return next(message for kind, message in self.calls if kind == 'review')
|
||||
|
||||
def test_update_preview_describes_recovery_attempt_not_guarantee(self):
|
||||
message = self.manage()
|
||||
self.assertIn(UPDATE, message)
|
||||
self.assertNotIn('all members are recovered', message)
|
||||
|
||||
def test_pending_terminal_and_recovery_failed_states_share_bounded_wording(self):
|
||||
for phase in ('committed', 'rolled-back', 'recovery-failed'):
|
||||
with self.subTest(phase=phase):
|
||||
self.calls.clear()
|
||||
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
|
||||
self.primary['pending_stack_transaction'] = str(self.journal)
|
||||
self.assertEqual(self.manage(), PENDING)
|
||||
self.assertFalse(any(isinstance(item[0], list) for item in self.calls))
|
||||
|
||||
def test_pending_confirmation_preserves_recover_command_in_each_state(self):
|
||||
self.ui.review = lambda message, *args, **kwargs: True
|
||||
for phase in ('committed', 'recovery-failed'):
|
||||
with self.subTest(phase=phase):
|
||||
self.calls.clear()
|
||||
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
|
||||
self.primary['pending_stack_transaction'] = str(self.journal)
|
||||
with patch.dict(sys.modules, self.modules):
|
||||
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
|
||||
self.assertEqual(self.calls, [
|
||||
(['python3', '/inert/remote/oci_stack_native.py', '101', '--recover',
|
||||
'--acknowledge-external-data'], 'Recover OCI stack')])
|
||||
|
||||
def test_actual_transaction_distinguishes_terminal_cleanup_from_failed_restore(self):
|
||||
events = []
|
||||
scope = {'TERMINAL': {'committed', 'rolled-back'},
|
||||
'translate': lambda message: message,
|
||||
'member_tx': SimpleNamespace(log=lambda message: None),
|
||||
'write': lambda path, state: Path(path).write_text(json.dumps(state)),
|
||||
'msg_info': lambda message: None, 'msg_ok': lambda message: None,
|
||||
'member': lambda adapter, vmid: f'CT {vmid}'}
|
||||
extracted(TRANSACTION, 'save', scope)
|
||||
recover = extracted(TRANSACTION, 'recover_state', scope)
|
||||
class Adapter:
|
||||
def finalize(self, state): events.append('finalize')
|
||||
def validate(self, plan): events.append('validate')
|
||||
def restore(self, vmid, backup, txid):
|
||||
events.append('restore')
|
||||
raise RuntimeError('restore failed')
|
||||
def stop(self, vmid): events.append('stop')
|
||||
plan = {'start_order': [101], 'stop_order': [101]}
|
||||
terminal = {'phase': 'committed', 'plan': plan}
|
||||
self.assertIs(recover(self.journal, terminal, Adapter()), terminal)
|
||||
self.assertEqual(events, ['finalize'])
|
||||
events.clear()
|
||||
failed = dict(terminal, phase='recovery-failed', id='tx', running={'101': True},
|
||||
stop_intent=True, replacement_intent=True, backups={'101': {'archive': 'a'}})
|
||||
with self.assertRaisesRegex(RuntimeError, 'restore failed'):
|
||||
recover(self.journal, failed, Adapter())
|
||||
self.assertEqual(events, ['validate', 'stop', 'restore'])
|
||||
self.assertEqual(json.loads(self.journal.read_text())['phase'], 'recovery-failed')
|
||||
|
||||
def test_new_keys_extract_and_shipped_fallback_and_synthetic_translation(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
self.assertEqual(len(KEYS), len(set(KEYS)))
|
||||
self.assertTrue(set(KEYS) <= set(found), set(KEYS) - set(found))
|
||||
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)
|
||||
paths = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
|
||||
setattr(module, '_cache', {})
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
|
||||
module._language, module._cache = 'it', {key: 'Tradotto: ' + key for key in KEYS}
|
||||
self.assertEqual(module.translate(EMPTY_HOST), 'Tradotto: ' + EMPTY_HOST)
|
||||
preview = InventoryMessages().preview({101: None, 102: None}, {}, translate=module.translate)
|
||||
self.assertIn('Tradotto: ' + EMPTY_HOST, preview)
|
||||
preview = InventoryMessages().preview({101: b'mp0: /bind,mp=/data'}, {},
|
||||
translate=module.translate)
|
||||
self.assertIn('Tradotto: ' + PREVIEW_HOST + '\n /bind', preview)
|
||||
# Exercise the actual management consumer, not just the provider.
|
||||
self.scope['translate'] = module.translate
|
||||
self.assertIn('Tradotto: ' + UPDATE, self.manage())
|
||||
self.journal.write_text(json.dumps({'phase': 'committed', 'plan': {'members': self.members}}))
|
||||
self.primary['pending_stack_transaction'] = str(self.journal)
|
||||
self.calls.clear()
|
||||
self.assertEqual(self.manage(), 'Tradotto: ' + PENDING)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Exercise the real OCI preview/import functions without importing host-management modules."""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import unittest
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
|
||||
ROWS = {
|
||||
'It needs a privileged container, which is not isolated from the host.':
|
||||
'This profile requires a privileged LXC, which reduces isolation from the host.',
|
||||
'It asks for capabilities or a relaxed confinement profile.':
|
||||
'A relaxed AppArmor or seccomp profile is requested; this may be optional.',
|
||||
'The image expects files that are given to it one by one:':
|
||||
'These container mount paths have an extension-like suffix:',
|
||||
'ProxMenux attaches directories, not single files, so this image cannot be installed yet.':
|
||||
'This import rejects these paths without checking whether they are files or directories.',
|
||||
}
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def extracted(name, **deps):
|
||||
node = next(n for n in ast.parse(CUSTOM.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
scope = {'Any': Any, 'Path': Path, 'yaml': yaml, 'ConversionError': ConversionError,
|
||||
'translate': lambda text: text}
|
||||
scope.update(deps)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(CUSTOM), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class SecurityMountWording(unittest.TestCase):
|
||||
def test_security_warnings_match_their_guards_without_claiming_capabilities(self):
|
||||
describe = extracted('describe', blocker_text=str)
|
||||
template = {'container_contract': {'image': {'reference': 'test:latest'},
|
||||
'volumes': [], 'ports': [], 'environment': []},
|
||||
'proxmox': {'security_profile': {}}}
|
||||
for profile, present in (
|
||||
({}, ()),
|
||||
({'optional_privileged_lxc': True}, ()),
|
||||
({'requires_privileged_lxc': True}, (ROWS['It needs a privileged container, which is not isolated from the host.'],)),
|
||||
({'requires_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
|
||||
({'source_requests_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
|
||||
({'requires_privileged_lxc': True, 'source_requests_relaxed_confinement': True},
|
||||
(ROWS['It needs a privileged container, which is not isolated from the host.'],
|
||||
ROWS['It asks for capabilities or a relaxed confinement profile.'])),
|
||||
):
|
||||
with self.subTest(profile=profile):
|
||||
template['proxmox']['security_profile'] = profile
|
||||
text = describe(template)
|
||||
for warning in (ROWS['It needs a privileged container, which is not isolated from the host.'],
|
||||
ROWS['It asks for capabilities or a relaxed confinement profile.']):
|
||||
if warning in present:
|
||||
self.assertIn(' ' + warning, text)
|
||||
else:
|
||||
self.assertNotIn(warning, text)
|
||||
template['proxmox']['security_profile'] = {}
|
||||
template['proxmox']['installer_profile'] = {'security': {'required_capabilities': ['NET_ADMIN']}}
|
||||
text = describe(template)
|
||||
self.assertNotIn(ROWS['It asks for capabilities or a relaxed confinement profile.'], text)
|
||||
|
||||
def test_suffix_hit_rejects_dotted_directory_without_claiming_file_type(self):
|
||||
fn = extracted('template_from_compose',
|
||||
_services=lambda d: d['services'], _check_mounts=lambda s: None,
|
||||
normalize_app_id=lambda s: s, _published_port=lambda s: None,
|
||||
_dump=lambda s: '', _keep_reference=lambda *a: None,
|
||||
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
|
||||
convert_casaos_compose=lambda *args: {'container_contract': {
|
||||
'image': {'reference': 'example:latest'},
|
||||
'volumes': [{'container_path': '/srv/data.v2', 'installation_choice': ['managed-volume', 'host-bind']}]},
|
||||
'proxmox': {}})
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
fn('services:\n app:\n image: example:latest\n')
|
||||
self.assertEqual(str(caught.exception),
|
||||
ROWS['The image expects files that are given to it one by one:'] + ' /srv/data.v2. ' +
|
||||
ROWS['ProxMenux attaches directories, not single files, so this image cannot be installed yet.'])
|
||||
|
||||
def test_suffix_free_path_still_accepted(self):
|
||||
fn = extracted('template_from_compose',
|
||||
_services=lambda d: d['services'], _check_mounts=lambda s: None,
|
||||
normalize_app_id=lambda s: s, _published_port=lambda s: None,
|
||||
_dump=lambda s: '', _keep_reference=lambda *a: None,
|
||||
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
|
||||
convert_casaos_compose=lambda *args: {'container_contract': {
|
||||
'image': {'reference': 'example:latest'},
|
||||
'volumes': [{'container_path': '/srv/config', 'installation_choice': ['managed-volume', 'host-bind']}]},
|
||||
'proxmox': {}})
|
||||
self.assertEqual(fn('services:\n app:\n image: example:latest\n')['container_contract']['volumes'][0]['container_path'], '/srv/config')
|
||||
|
||||
def test_all_four_keys_extract_and_fallback_in_seven_shipped_locales(self):
|
||||
found = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
self.assertEqual(len(ROWS), 4)
|
||||
self.assertTrue(set(ROWS.values()) <= set(found), set(ROWS.values()) - set(found))
|
||||
import importlib.util
|
||||
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)
|
||||
paths = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
for new in ROWS.values():
|
||||
self.assertEqual(module.translate(new), cache.get(new) or new, (path.name, new))
|
||||
setattr(module, '_cache', {})
|
||||
for new in ROWS.values():
|
||||
self.assertEqual(module.translate(new), new, (path.name, new, 'synthetic missing'))
|
||||
module._language, module._cache = 'it', {new: 'IT: ' + new for new in ROWS.values()}
|
||||
self.assertIn('IT: ' + ROWS['It needs a privileged container, which is not isolated from the host.'],
|
||||
extracted('describe', translate=module.translate, blocker_text=str)({
|
||||
'container_contract': {'image': {'reference': 'test:latest'}, 'volumes': [], 'ports': [], 'environment': []},
|
||||
'proxmox': {'security_profile': {'requires_privileged_lxc': True}}}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Offline OCI wording seams; compile actual functions, never import operational modules."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
|
||||
TRANSACTION = ROOT / 'oci/remote/oci_instance_transaction.py'
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
CHOICE = 'Recover the previous installation'
|
||||
BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
|
||||
AFTER = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.'
|
||||
NEW_KEYS = (
|
||||
'The Compose file describes several services:',
|
||||
'Only one service at a time can be installed this way.',
|
||||
'No docker run command was given',
|
||||
'Image selection was cancelled',
|
||||
'Could not inspect the image in its registry:',
|
||||
'Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.',
|
||||
BEFORE, AFTER,
|
||||
)
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def function(path, name, **dependencies):
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
# Only read_definition's infrastructure import is omitted; keep the function intact.
|
||||
if name == 'read_definition':
|
||||
node.body = [n for n in node.body if not isinstance(n, ast.ImportFrom)]
|
||||
scope = dict(translate=lambda text: text, ConversionError=ConversionError,
|
||||
UserCancelled=UserCancelled, Any=Any, yaml=__import__('yaml'), json=json,
|
||||
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024)
|
||||
scope.update(dependencies)
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||
str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class OciWordingTests(TestCase):
|
||||
def test_extractor_shipped_locales_and_synthetic_fallback(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
extracted = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
self.assertEqual(len(NEW_KEYS), len(set(NEW_KEYS)))
|
||||
self.assertTrue(set(NEW_KEYS) <= set(extracted), set(NEW_KEYS) - set(extracted))
|
||||
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)
|
||||
paths = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
for key in NEW_KEYS:
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
|
||||
setattr(module, '_cache', {})
|
||||
for key in NEW_KEYS:
|
||||
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
|
||||
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
|
||||
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
|
||||
|
||||
def test_multiple_services_named_not_images(self):
|
||||
template = function(CUSTOM, 'template_from_compose',
|
||||
_services=lambda doc: doc['services'], _check_mounts=lambda _: None)
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
template(json.dumps({'services': {'alpha': {'image': 'same:latest'},
|
||||
'beta': {'image': 'same:latest'}}}))
|
||||
self.assertEqual(str(caught.exception),
|
||||
'The Compose file describes several services: alpha, beta. '
|
||||
'Only one service at a time can be installed this way.')
|
||||
|
||||
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
|
||||
for title, expected in ((None, 'No Compose file was given'),
|
||||
('docker run command of the application', 'No docker run command was given')):
|
||||
def paste(content):
|
||||
return function(CUSTOM, '_read_pasted',
|
||||
console=SimpleNamespace(show_logo=lambda: None, msg_title=lambda *_: None,
|
||||
msg_info2=lambda *_: None),
|
||||
sys=SimpleNamespace(stdin=io.StringIO(content)))
|
||||
with self.assertRaises(UserCancelled) as caught:
|
||||
paste('')(None, title)
|
||||
self.assertEqual(str(caught.exception), expected)
|
||||
self.assertEqual(paste(' literal input\n')(None, title), ' literal input\n')
|
||||
|
||||
def test_cancelled_image_menu(self):
|
||||
with self.assertRaises(UserCancelled) as caught:
|
||||
function(CUSTOM, 'read_definition')(SimpleNamespace(choose=lambda *_, **__: None))
|
||||
self.assertEqual(str(caught.exception), 'Image selection was cancelled')
|
||||
|
||||
def test_registry_nonzero_and_success_command_unchanged(self):
|
||||
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
|
||||
run = Mock(return_value=result)
|
||||
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run))
|
||||
self.assertEqual(report('example:latest'),
|
||||
(False, 'Could not inspect the image in its registry: example:latest'))
|
||||
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
|
||||
capture_output=True, text=True, check=False, timeout=120)
|
||||
run.reset_mock()
|
||||
run.return_value = SimpleNamespace(returncode=0,
|
||||
stdout='{"manifests":[{"platform":{"architecture":"amd64"}}]}', stderr='')
|
||||
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
|
||||
self.assertEqual(run.call_count, 1)
|
||||
|
||||
def test_failed_registry_inspection_has_neutral_advice(self):
|
||||
ui = SimpleNamespace(message=Mock())
|
||||
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
|
||||
ignored_settings=lambda _: [], template_from_compose=lambda _: {
|
||||
'container_contract': {'image': {'reference': 'example:latest'}}},
|
||||
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'))
|
||||
fn(ui)
|
||||
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n'
|
||||
'Check the image reference and registry access. If the registry is unavailable, '
|
||||
'try again later. A private registry needs credentials, which are not supported yet.')
|
||||
|
||||
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
|
||||
# Pin the real translated management choice, not an English AST constant alone.
|
||||
source = ast.parse(MENU.read_text())
|
||||
self.assertTrue(any(isinstance(n, ast.Tuple) and len(n.elts) == 2
|
||||
and isinstance(n.elts[0], ast.Constant) and n.elts[0].value == 'recover'
|
||||
and isinstance(n.elts[1], ast.Call) and isinstance(n.elts[1].func, ast.Name)
|
||||
and n.elts[1].func.id == 'translate' and n.elts[1].args
|
||||
and isinstance(n.elts[1].args[0], ast.Constant)
|
||||
and n.elts[1].args[0].value == CHOICE for n in ast.walk(source)))
|
||||
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.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())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
warnings = []
|
||||
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
|
||||
msg_warn=warnings.append)
|
||||
hint()
|
||||
hint(after_recovery=True)
|
||||
label = module.translate(CHOICE)
|
||||
self.assertNotEqual(label, CHOICE, path.name)
|
||||
expected = [(cache.get(key) or key).replace('{choice}', label)
|
||||
if '{choice}' in (cache.get(key) or key) else key.replace('{choice}', label)
|
||||
for key in (BEFORE, AFTER)]
|
||||
self.assertEqual(warnings, expected, path.name)
|
||||
# An intentionally absent message key proves fallback independently of shipped catalogs.
|
||||
setattr(module, '_cache', {CHOICE: label})
|
||||
warnings.clear()
|
||||
hint()
|
||||
hint(after_recovery=True)
|
||||
self.assertEqual(warnings, [BEFORE.replace('{choice}', label),
|
||||
AFTER.replace('{choice}', label)], (path.name, 'synthetic missing'))
|
||||
|
||||
def test_recovery_hint_uses_translated_whole_messages_and_malformed_fallback(self):
|
||||
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
module._language = 'it'
|
||||
for translation in ('Before: «{choice}».', 'Before: label missing.'):
|
||||
module._cache = {CHOICE: 'Recupero', BEFORE: translation,
|
||||
AFTER: 'After: «{choice}».'}
|
||||
warnings = []
|
||||
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
|
||||
msg_warn=warnings.append)
|
||||
hint()
|
||||
hint(after_recovery=True)
|
||||
self.assertEqual(warnings, [
|
||||
('Before: «Recupero».' if '{choice}' in translation
|
||||
else BEFORE.replace('{choice}', 'Recupero')),
|
||||
'After: «Recupero».'])
|
||||
Reference in New Issue
Block a user