fix(oci): qualify import adoption and recovery diagnostics

This commit is contained in:
martino
2026-09-26 18:04:35 +02:00
parent 9998a937d5
commit 6d53035936
9 changed files with 588 additions and 21 deletions
+2 -2
View File
@@ -44,12 +44,12 @@ def _mount(key, value, vmid):
'size_gb': None, 'backup': False, 'read_only': read_only,
'create_if_missing': False}
if options.get('backup') != '1' or ':' not in source:
raise ValueError(f'{key}: {translate("Only backed-up Proxmox volumes can be adopted")}')
raise ValueError(f'{key}: {translate("Proxmox volume adoption requires backup=1 and a volume ID")}')
storage, volume = source.split(':', 1)
if not re.fullmatch(r'[A-Za-z0-9_-]+', storage) or not volume:
raise ValueError(f'{key}: {translate("Invalid Proxmox volume ID")}')
if not re.search(rf'(?:^|/)(?:vm|subvol)-{vmid}-disk-[0-9]+(?:\.|$)', volume):
raise ValueError(f'{key}: {translate("The disk does not belong to this CT; automatic adoption is unsafe")}')
raise ValueError(f'{key}: {translate("The volume ID does not contain a disk name for this CT; automatic adoption is unsafe")}')
return {'type': 'managed-volume', 'container_path': target, 'source': storage,
'size_gb': _managed_size(options.get('size')), 'backup': True,
'read_only': read_only}
+7 -2
View File
@@ -200,9 +200,14 @@ def pending_journal():
def recovery_hint(after_recovery=False):
if after_recovery:
msg_warn(translate('The recovery did not complete. Review the log and choose "Recover" again for this container in the OCI management menu.'))
source = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.'
message = translate('The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.')
else:
msg_warn(translate('The operation stopped halfway. Choose "Recover" for this container in the OCI management menu to restore the previous installation.'))
source = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
message = translate('The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.')
# A malformed cache entry must not leave the user without the menu choice.
msg_warn((message if '{choice}' in message else source).replace(
'{choice}', translate('Recover the previous installation')))
def recover_untouched(root, journal):
+1 -1
View File
@@ -140,7 +140,7 @@ def remove(root, vmid):
for path, size in image_cache.prune(root, lock=False):
msg_ok(f"{translate('Unused image removed from the cache:')} {path.name}")
for path in kept:
msg_warn(f"{translate('Host directory kept, with its content:')} {path}")
msg_warn(f"{translate('Host directory listed in saved records (not targeted for removal):')} {path}")
def main():
+12 -12
View File
@@ -114,8 +114,8 @@ def template_from_compose(text: str, title: str | None = None) -> dict[str, Any]
services = _services(compose)
if len(services) > 1:
raise ConversionError(
f"{translate('The Compose file describes several images:')} {', '.join(services)}. "
f"{translate('Only one image at a time can be installed this way.')}")
f"{translate('The Compose file describes several services:')} {', '.join(services)}. "
f"{translate('Only one service at a time can be installed this way.')}")
name = next(iter(services))
service = services[name] or {}
if not service.get('image'):
@@ -158,8 +158,8 @@ def template_from_compose(text: str, title: str | None = None) -> dict[str, Any]
if Path(volume['container_path']).suffix]
if files:
raise ConversionError(
f"{translate('The image expects files that are given to it one by one:')} {', '.join(files)}. "
f"{translate('ProxMenux attaches directories, not single files, so this image cannot be installed yet.')}")
f"{translate('These container mount paths have an extension-like suffix:')} {', '.join(files)}. "
f"{translate('This import rejects these paths without checking whether they are files or directories.')}")
return template
@@ -316,7 +316,8 @@ def _read_pasted(ui, title: str | None = None) -> str:
print()
text = sys.stdin.read(MAX_COMPOSE_BYTES + 1)
if not text.strip():
raise UserCancelled(translate('No Compose file was given'))
raise UserCancelled(translate('No docker run command was given') if title is not None
else translate('No Compose file was given'))
return text
@@ -331,7 +332,7 @@ def read_definition(ui) -> tuple[str, str]:
('image', translate('Only the image reference, with no Compose file')),
], 'paste', title=translate('Image that is not in the catalog'), size=MENU_SIZE)
if source is None:
raise UserCancelled(translate('No image was given'))
raise UserCancelled(translate('Image selection was cancelled'))
if source == 'paste':
return _read_pasted(ui), translate('pasted Compose file')
if source == 'file':
@@ -415,7 +416,7 @@ def registry_report(reference: str) -> tuple[bool, str]:
result = subprocess.run(['skopeo', 'inspect', '--raw', f'docker://{reference}'],
capture_output=True, text=True, check=False, timeout=120)
if result.returncode != 0:
return False, f"{translate('The image was not found in its registry, or it is private:')} {reference}"
return False, f"{translate('Could not inspect the image in its registry:')} {reference}"
try:
document = json.loads(result.stdout)
except ValueError:
@@ -493,12 +494,12 @@ def describe(template: dict[str, Any]) -> str:
for item in devices]
warnings = []
if security.get('requires_privileged_lxc'):
warnings.append(translate('It needs a privileged container, which is not isolated from the host.'))
warnings.append(translate('This profile requires a privileged LXC, which reduces isolation from the host.'))
elif security.get('source_requests_privileged_lxc') or security.get('optional_privileged_lxc'):
warnings.append(translate('Its Compose file asks for privileged mode; the container is created '
'unprivileged and that mode is only offered as an option.'))
if security.get('requires_relaxed_confinement') or security.get('source_requests_relaxed_confinement'):
warnings.append(translate('It asks for capabilities or a relaxed confinement profile.'))
warnings.append(translate('A relaxed AppArmor or seccomp profile is requested; this may be optional.'))
if security.get('requires_host_pid_namespace'):
warnings.append(translate('It asks to see the processes of the host.'))
if warnings:
@@ -548,9 +549,8 @@ def explore(ui) -> None:
if notes:
summary += '\n\n' + '\n'.join(notes)
if not available:
advice = translate('Some projects publish a Dockerfile and not an image: it has to be built '
'and published to a registry before it can be installed this way. An image '
'of a private registry needs credentials, which are not supported yet.')
advice = translate('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.')
ui.message(f'{summary}\n\n{advice}', title)
return
if not template.get('compatibility', {}).get('automatic_install_candidate'):
+4 -4
View File
@@ -319,10 +319,10 @@ def _removal_summary(project, vmid):
if bridge:
text += ['', f"{translate('Private network of the application that is released:')} {bridge}"]
if kept:
text += ['', translate('Host directories that are kept, with their content:'),
text += ['', translate('Host paths found in container configs or saved records (not targeted for removal):'),
*[f' {path}' for path in kept]]
else:
text += ['', translate('No host directory is used by this application.')]
text += ['', translate('No host directories found in the available container configs or saved records.')]
return '\n'.join(text)
@@ -372,11 +372,11 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
return _remove(project, ui, primary_id)
if not ui.review(f"{translate('All stack members are updated together. Main CT:')} {primary_id}, "
f"{translate('members:')} {len(members)}. "
f"{translate('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 anything fails, all members are recovered.')}",
f"{translate('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.')}",
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
return False
else:
if not ui.review(translate('A coordinated operation is pending. The whole previous stack will be recovered, not only the selected member. If the operation already finished, the cleanup of its markers is completed.'), translate('Recover OCI stack'),
if not ui.review(translate('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.'), translate('Recover OCI stack'),
question=translate('Recover or complete the operation?'), default=True):
return False
import json
@@ -0,0 +1,43 @@
"""Inert, extracted producer checks for the two adoption rejection messages."""
import ast
from pathlib import Path
import re
import types
import unittest
SOURCE = Path(__file__).resolve().parents[1] / 'remote' / 'oci_instance_reconcile.py'
def mount_reader():
tree = ast.parse(SOURCE.read_text())
funcs: list[ast.stmt] = [node for node in tree.body if isinstance(node, ast.FunctionDef)
and node.name in ('_mount', '_managed_size')]
namespace = {'re': re, 'translate': lambda text: text,
'host_mounts': types.SimpleNamespace(valid_path=lambda path: path,
validate_source=lambda path: None)}
exec(compile(ast.Module(body=funcs, type_ignores=[]), str(SOURCE), 'exec'), namespace)
return namespace['_mount']
class AdoptionWording(unittest.TestCase):
def test_backup_flag_is_a_requirement_not_a_prior_backup(self):
mount = mount_reader()
for value in ('local-lvm:vm-200-disk-3,mp=/data,size=8G,backup=0',
'local-lvm:vm-200-disk-3,mp=/data,size=8G'):
with self.subTest(value=value), self.assertRaisesRegex(
ValueError, 'Proxmox volume adoption requires backup=1 and a volume ID'):
mount('mp2', value, 200)
self.assertTrue(mount('mp2', 'local-lvm:vm-200-disk-3,mp=/data,size=8G,backup=1', 200)['backup'])
def test_disk_name_check_does_not_claim_to_prove_ownership(self):
mount = mount_reader()
with self.assertRaisesRegex(ValueError,
'The volume ID does not contain a disk name for this CT; automatic adoption is unsafe'):
mount('mp2', 'local-lvm:vm-201-disk-3,mp=/data,size=8G,backup=1', 200)
# A matching substring passes the actual guard; no ownership query is made.
result = mount('mp2', 'local-lvm:other/vm-200-disk-3.raw,mp=/data,size=8G,backup=1', 200)
self.assertEqual(result['type'], 'managed-volume')
if __name__ == '__main__':
unittest.main()