mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-07 05:56:40 +00:00
Adds the OCI manager: an engine that turns a Docker Compose file into an LXC definition, a catalog of 365 applications drawn from LinuxServer.io and other container image sources, and a per-instance registry recording what each container was built from. Reachable from the main menu. Catalog text is translated like every other string in the project: the taglines go through translate() and land in lang/*.json, so the entries read in all eight languages instead of only English. Translation cache builder: - a failed translation leaves the key absent rather than writing English, which previously made the string count as translated forever - a result identical to a 3+ word source is rejected, catching a provider that silently returns the text it was given - strings that are nothing but glossary terms keep their source spelling instead of being discarded as failures - no backoff between attempts when the provider is deterministic - application names are protected so "HAOS One" survives translation - argos joins the provider list, and the workflow reads the OCI sources Audit & Report: - findings that moved in the wrong direction between runs are reported alongside the ones that improved - an accepted risk can carry a review date and is flagged when it falls due - backup checks explain in plain language what they looked at and what to do next Monitor: - disks can be excluded from periodic reads, and an idle disk says so instead of showing a stale temperature - per-disk identity survives a controller or enclosure change - scheduled Borg backups resolve their SSH key from the repository entry - PVE upgrades log the package list and the resulting dpkg changes The web build no longer copies scripts/ into public/: the documentation links to GitHub, so nothing read that folder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
354 lines
18 KiB
Python
354 lines
18 KiB
Python
"""Local PVE instance selection and explicit recovery UI."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
from . import images
|
|
from .i18n import N_, source_text, translate
|
|
|
|
STATUS_LABELS = {'installed': N_('installed'), 'failed': N_('failed'), 'updating': N_('updating'),
|
|
'recovering': N_('recovering'), 'installing': N_('installing'),
|
|
'assembling': N_('assembling')}
|
|
|
|
|
|
def public_row(record, decision):
|
|
return {'vmid': record['vmid'], 'hostname': record.get('deployment', {}).get('hostname',
|
|
f'OCI {record["vmid"]}'),
|
|
'title': source_text(record.get('template', {}).get('catalog_ui', {}).get('title')),
|
|
'image': str(record.get('template', {}).get('container_contract', {}).get('image', {})
|
|
.get('reference', '')).split('@', 1)[0].rsplit('/', 1)[-1],
|
|
'status': record['status'], 'reason': decision.get('reason'),
|
|
'pending': bool(record.get('pending_transaction')),
|
|
'stack_pending': bool(record.get('pending_stack_transaction')),
|
|
'stack': bool(record.get('stack') or record.get('stack_member')
|
|
or record.get('native_stack_intent'))}
|
|
|
|
|
|
def inventory(project, progress=None):
|
|
sys.path.insert(0, str(project / 'remote'))
|
|
import oci_instances as instances
|
|
with instances.locked(instances.ROOT):
|
|
resources = json.loads(instances.command('pvesh', 'get', '/cluster/resources',
|
|
'--type', 'vm', '--output-format', 'json'))
|
|
registered = set()
|
|
for directory in instances.ROOT.iterdir():
|
|
if directory.name.isdecimal() and instances.has_contract(instances.ROOT, int(directory.name)):
|
|
vmid = int(directory.name)
|
|
registered.add(vmid)
|
|
record = instances.read(instances.ROOT, vmid)
|
|
registered.update(member['vmid'] for member in record.get('stack', {}).get('members', []))
|
|
configs = {}
|
|
selected = [row for row in resources if row.get('type') == 'lxc'
|
|
and int(row['vmid']) in registered]
|
|
for index, row in enumerate(selected, 1):
|
|
if progress:
|
|
progress(f"{translate('Checking OCI')} {index}/{len(selected)}: CT {row['vmid']}...")
|
|
if row.get('type') == 'lxc':
|
|
configs[int(row['vmid'])] = instances.command('pvesh', 'get',
|
|
f'/nodes/{row["node"]}/lxc/{row["vmid"]}/config', '--output-format', 'json')
|
|
decisions = instances.reconcile(instances.ROOT, resources, configs)
|
|
return [public_row(instances.read(instances.ROOT, d['vmid']), d)
|
|
for d in decisions if d['action'] == 'keep']
|
|
|
|
|
|
def saved_inventory(project):
|
|
"""Open the selector without invoking Proxmox or changing saved contracts."""
|
|
sys.path.insert(0, str(project / 'remote'))
|
|
import oci_instances as instances
|
|
with instances.locked(instances.ROOT):
|
|
rows = []
|
|
for directory in sorted(instances.ROOT.iterdir(), key=lambda path: path.name.zfill(10)):
|
|
if not (directory.name.isdecimal() and instances.has_contract(instances.ROOT, int(directory.name))
|
|
and instances.guest_exists(int(directory.name))):
|
|
continue
|
|
record = instances.read(instances.ROOT, int(directory.name))
|
|
row = public_row(record, {'reason': 'not-yet-checked'})
|
|
row['title'] = row['title'] or _stack_title(instances, record)
|
|
if row['hostname'] == f"OCI {record['vmid']}":
|
|
row['hostname'] = _config_hostname(record['vmid']) or row['hostname']
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def _stack_title(instances, record):
|
|
"""Members of a dedicated stack keep a minimal template: name them after the stack."""
|
|
primary_id = record.get('stack_member', {}).get('primary_vmid', record['vmid'])
|
|
try:
|
|
primary = record if primary_id == record['vmid'] else instances.read(instances.ROOT, primary_id)
|
|
except (OSError, ValueError, KeyError):
|
|
return ''
|
|
for source in (primary.get('stack', {}), primary.get('native_stack_intent', {})):
|
|
title = source_text(source.get('template', {}).get('catalog_ui', {}).get('title'))
|
|
if title:
|
|
role = record.get('deployment', {}).get('role') or record.get('stack_member', {}).get('name')
|
|
return f"{title} · {role}" if role and primary_id != record['vmid'] else title
|
|
return ''
|
|
|
|
|
|
def _config_hostname(vmid):
|
|
for path in Path('/etc/pve/nodes').glob(f'*/lxc/{vmid}.conf'):
|
|
try:
|
|
for line in path.read_text().splitlines():
|
|
if line.startswith('hostname:'):
|
|
return line.split(':', 1)[1].strip()
|
|
if line.startswith('['):
|
|
break
|
|
except OSError:
|
|
continue
|
|
return ''
|
|
|
|
|
|
def check_selected(project, row):
|
|
"""Validate only the chosen container; lifecycle backends validate its stack."""
|
|
sys.path.insert(0, str(project / 'remote'))
|
|
import oci_instances as instances
|
|
with instances.locked(instances.ROOT):
|
|
record = instances.read(instances.ROOT, row['vmid'])
|
|
config = instances.command('pct', 'config', str(row['vmid']))
|
|
marker = instances.identity(config)
|
|
reason = 'matched' if marker == record['installation_id'] else 'identity-unconfirmed'
|
|
return public_row(record, {'reason': reason})
|
|
|
|
|
|
def _run_lifecycle(command, title):
|
|
"""The lifecycle programs print their own steps: they run on a clean screen
|
|
and their result stays readable until the user returns to the menu."""
|
|
from . import console
|
|
console.show_logo()
|
|
console.msg_title(title)
|
|
environment = dict(os.environ, OCI_SPINNER='1' if sys.stdout.isatty() else '0')
|
|
completed = subprocess.run(command, env=environment, check=False)
|
|
console.wait_for_enter(translate('Press Enter to return to the menu...'))
|
|
return completed.returncode == 0
|
|
|
|
|
|
def interactive_management(project, ui):
|
|
try:
|
|
_interactive_management(project, ui)
|
|
except BlockingIOError:
|
|
ui.message(translate('Another OCI operation is using the instance registry. Wait for it to finish and open this menu again; no container is modified.'),
|
|
translate('OCI management'))
|
|
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError):
|
|
ui.message(translate('OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.'), translate('OCI management'))
|
|
|
|
|
|
def _interactive_management(project, ui):
|
|
if os.geteuid() != 0 or not shutil.which('pct'):
|
|
ui.message(translate('This interface runs on the Proxmox node as root. Open proxmenux-oci.sh on the Proxmox host.'), translate('OCI management'))
|
|
return
|
|
rows = saved_inventory(project)
|
|
if not rows:
|
|
ui.message(translate('No OCI instances are registered.'), translate('OCI management'))
|
|
return
|
|
# Same layout as the catalog lists; only an unusual state is shown.
|
|
tag_width = max(len(str(r['vmid'])) for r in rows)
|
|
header = f" {'CT':<{tag_width + 2}}{translate('Application')[:32]:<32} {translate('Image')}"
|
|
options = []
|
|
for r in rows:
|
|
line = f"{(r['title'] or r['hostname'])[:32]:<32} "
|
|
if r['status'] == 'installed':
|
|
line += r['image'][:30]
|
|
else:
|
|
line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn"
|
|
options.append((str(r['vmid']), f"{line:<72}"))
|
|
selection = ui.choose(header, options, size=(22, 75, 15), colors=True,
|
|
title=translate('Manage installed OCI applications'))
|
|
if selection is None:
|
|
return
|
|
row = next(r for r in rows if str(r['vmid']) == selection)
|
|
row = check_selected(project, row)
|
|
if row['reason'] != 'matched':
|
|
ui.message(translate('The selected CT does not match its OCI record. Its configuration will not be modified or deleted.'), translate('OCI management'))
|
|
return
|
|
if row['stack']:
|
|
_manage_stack(project, ui, row)
|
|
return
|
|
if not row['pending']:
|
|
if row['status'] != 'installed' or row['reason'] != 'matched':
|
|
ui.message(translate('The instance identity or status must be reviewed before updating.'), translate('OCI management'))
|
|
return
|
|
action = ui.choose(translate('Manage OCI'), [('update', translate('Update the image with the saved configuration')),
|
|
('recreate', translate('Recreate: edit resources, network, paths and GPU')),
|
|
('remove', translate('Remove: delete the application and its containers'))], 'update')
|
|
if action is None:
|
|
return
|
|
sys.path.insert(0, str(project / 'remote'))
|
|
import oci_instances as instances
|
|
record = instances.read(instances.ROOT, row['vmid'])
|
|
if action == 'remove':
|
|
_remove(project, ui, row['vmid'])
|
|
return
|
|
proposal = None
|
|
if action == 'recreate':
|
|
from .recreation import edit_recreation
|
|
from .cli import _deployment_summary_text
|
|
proposal = edit_recreation(record, ui)
|
|
if not ui.review(_deployment_summary_text(proposal['candidate']['template'],
|
|
proposal['candidate']['deployment']), translate('Recreate OCI'),
|
|
question=translate('Recreate with these options?'), default=True):
|
|
return
|
|
elif not ui.review(translate('The current image of the saved channel will be checked and downloaded. Resources, paths and GPU are kept. The CT is stopped during the replacement and a native backup is created first.'),
|
|
translate('Update OCI'), question=translate('Update now?'), default=True):
|
|
return
|
|
command = [sys.executable, str(project / 'remote/oci_update_current.py'), str(row['vmid'])]
|
|
desired = proposal['candidate'] if proposal else record
|
|
if any(m['type'] == 'host-bind' for m in desired['deployment'].get('mounts', [])):
|
|
if not ui.confirm(translate('Shared host data is not reverted by the backup. Continue?'), False):
|
|
return
|
|
command.append('--acknowledge-external-data')
|
|
title = translate('Recreate OCI') if proposal else translate('Update OCI')
|
|
if proposal is None:
|
|
completed = _run_lifecycle(command, title)
|
|
else:
|
|
# Saved environment/secrets must never be passed on the command line.
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.json') as file:
|
|
json.dump(proposal, file)
|
|
file.flush()
|
|
completed = _run_lifecycle(command + ['--proposal', file.name], title)
|
|
if completed:
|
|
images.offer_removal(ui, [row['vmid']])
|
|
return
|
|
action = ui.choose(translate('Interrupted operation'), [('status', translate('View status')),
|
|
('recover', translate('Recover the previous installation'))], 'status')
|
|
if action is None:
|
|
return
|
|
if action == 'recover' and not ui.review(
|
|
translate('The previous native backup will be restored. Shared host directories are not reverted. Displaced disks are kept.'),
|
|
translate('Recover OCI'), question=translate('Recover now?'), default=True):
|
|
return
|
|
_run_lifecycle([sys.executable, str(project / 'remote/oci_instance_transaction.py'),
|
|
action, str(row['vmid'])],
|
|
translate('Recover OCI') if action == 'recover' else translate('OCI management'))
|
|
|
|
|
|
def _removal_summary(project, vmid):
|
|
"""What the removal deletes and what it keeps, read from the containers
|
|
themselves. A container of a multi-container application is never removed
|
|
on its own."""
|
|
sys.path.insert(0, str(project / 'remote'))
|
|
import oci_instances as instances
|
|
import oci_remove
|
|
from oci_installation_state import parse_config
|
|
primary_id, primary, members = oci_remove.members_of(instances.ROOT, vmid)
|
|
titles = [(primary.get('template') or {}).get('catalog_ui', {}).get('title'),
|
|
((primary.get('stack') or {}).get('template') or {}).get('catalog_ui', {}).get('title')]
|
|
application = next((source_text(title) for title in titles if source_text(title)), f'CT {primary_id}')
|
|
lines, volumes, kept = [], [], []
|
|
for member in members:
|
|
try:
|
|
record = instances.read(instances.ROOT, member)
|
|
except (OSError, ValueError, KeyError):
|
|
record = {}
|
|
config = oci_remove.guest_config(member)
|
|
cfg = parse_config(config) if config else {}
|
|
name = (cfg.get('hostname') or (record.get('stack_member') or {}).get('name')
|
|
or record.get('deployment', {}).get('hostname') or '')
|
|
lines.append(f' CT {member} {name}')
|
|
size = re.search(r'size=(\S+)', cfg.get('rootfs', ''))
|
|
volumes.append(f" CT {member}: rootfs {size.group(1) if size else '-'}")
|
|
for key, value in cfg.items():
|
|
if not re.fullmatch(r'mp[0-9]+', key):
|
|
continue
|
|
source, *rest = value.split(',')
|
|
options = dict(item.split('=', 1) for item in rest if '=' in item)
|
|
target = options.get('mp', '')
|
|
if source.startswith('/'):
|
|
kept.append(source)
|
|
else:
|
|
volumes.append(f" CT {member}: {target} ({options.get('size', '-')})")
|
|
for path in oci_remove.host_directories(instances.ROOT, members):
|
|
if path not in kept:
|
|
kept.append(path)
|
|
bridge = oci_remove.private_bridge(primary)
|
|
text = []
|
|
if len(members) > 1 and vmid == primary_id:
|
|
text += [f"{application} {translate('runs in')} {len(members)} {translate('containers')}. "
|
|
f"{translate('All of them are removed.')}", '']
|
|
elif len(members) > 1:
|
|
alone = translate('It cannot be removed on its own, because the application would stop '
|
|
'working: continuing removes the whole application.')
|
|
text += [f"CT {vmid} {translate('is one of the')} {len(members)} "
|
|
f"{translate('containers of')} {application}. {alone}", '']
|
|
text += [translate('Containers that are removed:'), *lines, '',
|
|
translate('Data that is deleted with them:'), *volumes]
|
|
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:'),
|
|
*[f' {path}' for path in kept]]
|
|
else:
|
|
text += ['', translate('No host directory is used by this application.')]
|
|
return '\n'.join(text)
|
|
|
|
|
|
def _remove(project, ui, vmid):
|
|
try:
|
|
summary = _removal_summary(project, vmid)
|
|
except (OSError, ValueError, KeyError) as error:
|
|
ui.message(f"{translate('The removal could not be prepared:')} {error}", translate('Remove OCI'))
|
|
return
|
|
if not ui.review(summary, translate('Remove OCI'),
|
|
question=translate('Remove it? The data of its containers cannot be recovered afterwards.'),
|
|
default=False):
|
|
return
|
|
_run_lifecycle([sys.executable, str(project / 'remote/oci_remove.py'), str(vmid)],
|
|
translate('Remove OCI'))
|
|
|
|
|
|
def _manage_stack(project, ui, row):
|
|
sys.path.insert(0, str(project / 'remote'))
|
|
import oci_instances as instances
|
|
record = instances.read(instances.ROOT, row['vmid'])
|
|
primary_id = record.get('stack_member', {}).get('primary_vmid', row['vmid'])
|
|
primary = instances.read(instances.ROOT, primary_id)
|
|
pending = primary.get('pending_stack_transaction')
|
|
if not pending:
|
|
members = primary.get('stack', {}).get('members', [])
|
|
import oci_stack_replay
|
|
needs_replay = any(m.get('native_stack_intent') or
|
|
m.get('deployment', {}).get('rootfs_adaptation_replay_required') for m in members)
|
|
if not members or (needs_replay and not (
|
|
oci_stack_replay.nextcloud_menu_ready(primary) or
|
|
oci_stack_replay.paperless_menu_ready(primary) or
|
|
oci_stack_replay.tandoor_menu_ready(primary) or
|
|
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'))
|
|
return
|
|
action = ui.choose(translate('Manage OCI stack'),
|
|
[('update', translate('Update every container of the application')),
|
|
('remove', translate('Remove: delete the application and its containers'))], 'update')
|
|
if action is None:
|
|
return
|
|
if action == 'remove':
|
|
_remove(project, ui, primary_id)
|
|
return
|
|
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.')}",
|
|
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
|
|
return
|
|
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'),
|
|
question=translate('Recover or complete the operation?'), default=True):
|
|
return
|
|
import json
|
|
members = json.loads(Path(pending).read_text())['plan']['members']
|
|
command = [sys.executable, str(project / 'remote/oci_stack_native.py'), str(primary_id)]
|
|
if pending:
|
|
command.append('--recover')
|
|
if any(mount['type'] == 'host-bind' for member in members
|
|
for mount in member.get('deployment', {}).get('mounts', [])):
|
|
if not ui.confirm(translate('Shared host data is not reverted by the backups. Continue?'), False):
|
|
return
|
|
command.append('--acknowledge-external-data')
|
|
completed = _run_lifecycle(command, translate('Recover OCI stack') if pending else translate('Update OCI stack'))
|
|
if completed and not pending:
|
|
images.offer_removal(ui, [int(member['vmid']) for member in members])
|