mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-07 14:06: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>
235 lines
14 KiB
Python
235 lines
14 KiB
Python
"""Conservative recreation editor: preserve saved state and add data routes."""
|
|
import copy
|
|
import re
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
from .i18n import translate
|
|
from .ui import UserCancelled
|
|
|
|
|
|
def positive_integer(ui, prompt, value, minimum=1):
|
|
result = int(ui.ask(prompt, str(value)))
|
|
if result < minimum:
|
|
raise ValueError(f"{prompt}: {translate('minimum')} {minimum}")
|
|
return result
|
|
|
|
|
|
def absolute_path(value):
|
|
if (not value.startswith('/') or value == '/' or str(PurePosixPath(value)) != value
|
|
or '..' in PurePosixPath(value).parts or any(c.isspace() or c == ',' for c in value)):
|
|
raise ValueError(translate('The path must be absolute and normalized'))
|
|
return value
|
|
|
|
|
|
def edit_network(deployment, ui):
|
|
from . import network as access
|
|
from .installer import ask_bridge
|
|
network = deployment['network']
|
|
bridge = ask_bridge(ui, translate('Access bridge'), network['bridge'])
|
|
ipv4, gateway = access.ask_ipv4(ui, bridge, network.get('ipv4'), network.get('gateway'))
|
|
network.update(bridge=bridge, ipv4=ipv4, gateway=gateway)
|
|
|
|
|
|
def edit_acceleration(candidate, ui):
|
|
from .installer import configure_acceleration
|
|
deployment = candidate['deployment']
|
|
template = candidate.get('template', {})
|
|
installer = template.get('proxmox', {}).get('installer_profile', {})
|
|
hardware = installer.get('hardware_acceleration')
|
|
if not hardware or not ui.confirm(translate('Change GPU acceleration?'), False):
|
|
return
|
|
profiles = hardware.get('profiles', [])
|
|
reference = template.get('container_contract', {}).get('image', {}).get('reference', '')
|
|
linuxserver = reference.split(':')[0].startswith(('lscr.io/linuxserver/', 'linuxserver/', 'docker.io/linuxserver/'))
|
|
if not linuxserver and any(e.get('name') == 'DOCKER_MODS' for p in profiles for e in p.get('environment', [])):
|
|
raise ValueError(translate('Docker Mods are only offered for compatible LinuxServer images'))
|
|
owned_ids = {d['id'] for p in profiles for d in p.get('device_requests', [])}
|
|
owned_configs = {c['id'] for p in profiles for c in p.get('post_start_configurations', [])}
|
|
old_profile = next((p for p in profiles if p['id'] == deployment.get('hardware_profile')), {})
|
|
environment = copy.deepcopy(deployment.get('environment', []))
|
|
for old in old_profile.get('environment', []):
|
|
if old['name'] != 'DOCKER_MODS':
|
|
environment = [e for e in environment if not (e['name'] == old['name'] and str(e['value']) == str(old['value']))]
|
|
owned_names = set(old_profile.get('environment_from_devices', {}))
|
|
owned_names.update(e['name'] for d in old_profile.get('device_requests', []) for e in d.get('environment', []))
|
|
environment = [e for e in environment if e['name'] not in owned_names]
|
|
mods = next((e for e in environment if e['name'] == 'DOCKER_MODS'), None)
|
|
custom_mods = []
|
|
if mods:
|
|
official_mods = {str(e['value']) for p in profiles for e in p.get('environment', []) if e['name'] == 'DOCKER_MODS'}
|
|
custom_mods = [m for m in str(mods['value']).split('|') if m and m not in official_mods]
|
|
environment = [e for e in environment if e['name'] != 'DOCKER_MODS']
|
|
profile = copy.deepcopy(installer)
|
|
profile['optional_devices'] = []
|
|
profile['device_requests'] = []
|
|
profile['hardware_acceleration']['default'] = deployment.get('hardware_profile') or hardware.get('default')
|
|
devices, selected, configurations, environment = configure_acceleration(
|
|
profile, environment, deployment.get('security', {}).get('unprivileged', True), ui)
|
|
new_mods = next((e for e in environment if e['name'] == 'DOCKER_MODS'), None)
|
|
if custom_mods:
|
|
if new_mods:
|
|
new_mods['value'] = '|'.join(dict.fromkeys(custom_mods + str(new_mods['value']).split('|')))
|
|
else:
|
|
environment.append(dict(mods, value='|'.join(custom_mods)))
|
|
deployment['devices'] = [d for d in deployment.get('devices', []) if d.get('id') not in owned_ids] + devices
|
|
from .gpu import apply_profile_image
|
|
apply_profile_image(template, selected)
|
|
deployment['hardware_profile'] = selected
|
|
deployment['environment'] = environment
|
|
deployment['post_start_configurations'] = [c for c in deployment.get('post_start_configurations', []) if c.get('id') not in owned_configs] + configurations
|
|
deployment['device_permissions'] = installer.get('device_permissions') if deployment['devices'] else None
|
|
|
|
|
|
def edit_environment(deployment, ui):
|
|
environment = deployment.setdefault('environment', [])
|
|
while ui.confirm(translate('Change or add an environment variable?'), False):
|
|
name = ui.ask(translate('Variable name'))
|
|
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name):
|
|
raise ValueError(translate('Invalid variable name'))
|
|
matches = [e for e in environment if e['name'] == name]
|
|
if len(matches) > 1:
|
|
raise ValueError(translate('Duplicate variable in the contract; review it before editing'))
|
|
old = matches[0] if matches else None
|
|
sensitive = bool(old and old.get('sensitive'))
|
|
if not sensitive:
|
|
sensitive = ui.confirm(translate('Is the value a password or secret?'), True)
|
|
value = ui.password(f"{translate('New value for')} {name}") if sensitive else ui.ask(
|
|
f"{translate('Value for')} {name}", old.get('value', '') if old else '', required=False)
|
|
if '\x00' in value:
|
|
raise ValueError(translate('The value contains an unsupported character'))
|
|
item = dict(old or {}, name=name, value=value, sensitive=sensitive)
|
|
if old:
|
|
environment[environment.index(old)] = item
|
|
else:
|
|
environment.append(item)
|
|
|
|
|
|
def edit_peripherals(deployment, ui, allow_coral=False):
|
|
devices = deployment.setdefault('devices', [])
|
|
label = 'Coral/USB' if allow_coral else 'USB'
|
|
example = '/dev/apex_0, ' if allow_coral else ''
|
|
while ui.confirm(f"{translate('Add or change a device')} ({label})?", False):
|
|
path = ui.ask(f"{translate('Host device node')} ({translate('e.g.')} {example}/dev/ttyACM0, /dev/bus/usb/003/004)")
|
|
if not re.fullmatch(r'/dev/(apex_[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path):
|
|
raise ValueError(translate('Select a specific Coral or USB node, not the whole /dev'))
|
|
if path.startswith('/dev/apex_') and not allow_coral:
|
|
raise ValueError(translate('Coral is only offered for Frigate and CodeProject.AI'))
|
|
if '/bus/usb/' in path:
|
|
ui.info(translate('The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.'))
|
|
old = next((d for d in devices if d.get('host_path') == path), None)
|
|
mode = ui.ask(translate('Node octal permissions (e.g. 0660)'), (old or {}).get('mode', '0660'))
|
|
if not re.fullmatch(r'0?[0-7]{3}', mode):
|
|
raise ValueError(translate('Invalid octal permissions'))
|
|
item = dict(old or {}, id=(old or {}).get('id', 'peripheral-' + path.removeprefix('/dev/').replace('/', '-')),
|
|
kind='character-device', host_path=path, container_path=path,
|
|
mode=mode, gid_strategy='host-device-gid', deny_write=False)
|
|
if old:
|
|
devices[devices.index(old)] = item
|
|
else:
|
|
devices.append(item)
|
|
|
|
|
|
def edit_recreation(record, ui):
|
|
candidate = copy.deepcopy(record)
|
|
refresh_template(candidate, ui)
|
|
deployment = candidate['deployment']
|
|
resources = deployment['resources']
|
|
resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores'])
|
|
resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128)
|
|
while ui.confirm(translate('Add a custom data path?'), False):
|
|
target = absolute_path(ui.ask(translate('Path inside the container'), '/data/custom'))
|
|
existing = [m['container_path'].rstrip('/') for m in deployment['mounts']]
|
|
if any(target == p or target.startswith(p + '/') or p.startswith(target + '/') for p in existing):
|
|
raise ValueError(translate('The path overlaps an existing mount'))
|
|
mode = ui.choose(translate('Persistence for the new path'),
|
|
[('managed-volume', translate('Container volume (included in backups)')),
|
|
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
|
|
'managed-volume')
|
|
if mode is None:
|
|
raise UserCancelled(translate('Custom path cancelled'))
|
|
mount = {'type': mode, 'container_path': target, 'read_only': False}
|
|
if mode == 'managed-volume':
|
|
mount.update(source=ui.ask(translate('Proxmox storage for the volume'), deployment['rootfs']['storage']),
|
|
size_gb=positive_integer(ui, translate('Volume size in GB'), 4), backup=True)
|
|
else:
|
|
mount.update(source=absolute_path(ui.ask(translate('Host directory'),
|
|
'/mnt/oci-shared/custom')), size_gb=None, backup=False,
|
|
create_if_missing=True)
|
|
deployment['mounts'].append(mount)
|
|
if ui.confirm(translate('Change the access network?'), False):
|
|
edit_network(deployment, ui)
|
|
edit_acceleration(candidate, ui)
|
|
reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '')
|
|
repository = reference.split('@')[0].rsplit(':', 1)[0]
|
|
edit_peripherals(deployment, ui, repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server'))
|
|
edit_environment(deployment, ui)
|
|
proposal = {'operation': 'recreate', 'candidate': candidate}
|
|
if record.get('observed', {}).get('config_sha256'):
|
|
proposal['base_config_sha256'] = record['observed']['config_sha256']
|
|
return proposal
|
|
|
|
|
|
def refresh_template(candidate, ui):
|
|
from .catalog import Catalog
|
|
old = candidate.get('template', {})
|
|
name = old.get('id', '').removeprefix('image-')
|
|
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', name):
|
|
return
|
|
root = Path(__file__).resolve().parents[2]
|
|
path = root / 'catalog' / 'apps' / (name + '.json')
|
|
if not path.is_file() or not old.get('container_contract', {}).get('image'):
|
|
return
|
|
latest = Catalog(root).load_template(name, generate_if_missing=False)
|
|
if latest == old:
|
|
return
|
|
if not ui.confirm(translate('Apply the options from the current catalog template? Your data and configuration are kept.'), True):
|
|
return
|
|
if latest['id'] != old['id'] or latest['container_contract']['image']['repository'] != old['container_contract']['image']['repository']:
|
|
raise ValueError(translate('The current template changes the image or identity; an explicit migration is required'))
|
|
deployment = candidate['deployment']
|
|
mounted = {m['container_path'] for m in deployment.get('mounts', [])}
|
|
for volume in latest['container_contract'].get('volumes', []):
|
|
if not volume.get('required', True) or volume['container_path'] in mounted:
|
|
continue
|
|
target = absolute_path(volume['container_path'])
|
|
ui.info(f"{translate('The current template requires a new persistent path:')} {target}. "
|
|
f"{translate('It is created empty; existing data is not migrated automatically.')}")
|
|
mode = ui.choose(f"{translate('Persistence for')} {target}",
|
|
[('managed-volume', translate('Container volume (included in backups)')),
|
|
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
|
|
volume.get('default', 'managed-volume'))
|
|
if mode not in ('managed-volume', 'host-bind'):
|
|
raise UserCancelled(translate('Required new path cancelled'))
|
|
mount = {'container_path': target, 'type': mode, 'read_only': volume.get('read_only', False)}
|
|
if mode == 'managed-volume':
|
|
mount.update(source=ui.ask(translate('Proxmox storage for the volume'), deployment['rootfs']['storage']),
|
|
size_gb=positive_integer(ui, translate('Volume size in GB'), volume.get('managed_volume', {}).get('default_size_gb', 4)), backup=True)
|
|
else:
|
|
mount.update(source=absolute_path(ui.ask(translate('Host directory'), '/mnt/oci-shared/' + name + '/' + volume.get('id', 'data'))),
|
|
backup=False, size_gb=None, create_if_missing=True)
|
|
deployment.setdefault('mounts', []).append(mount)
|
|
mounted.add(target)
|
|
for item in latest['container_contract'].get('environment', []):
|
|
if not item.get('required') or any(e['name'] == item['name'] for e in deployment.get('environment', [])):
|
|
continue
|
|
prompt = translate(item.get('prompt', item['name']))
|
|
value = ui.password(prompt) if item['sensitive'] else ui.ask(prompt, item.get('example') or '')
|
|
deployment.setdefault('environment', []).append({'name': item['name'], 'value': value, 'sensitive': item['sensitive']})
|
|
profile = latest.get('proxmox', {}).get('installer_profile', {})
|
|
tmpfs = deployment.get('tmpfs_mounts', [])
|
|
for item in profile.get('tmpfs_mounts', []):
|
|
if any(m['container_path'] == item['container_path'] for m in tmpfs):
|
|
continue
|
|
size = item['default_size_mb']
|
|
if item.get('prompt_size', True):
|
|
size = positive_integer(ui, f"{translate('Tmpfs size in MiB for')} {item['container_path']}", size, item.get('minimum_size_mb', 1))
|
|
tmpfs.append({'container_path': item['container_path'], 'size_mb': size,
|
|
'mount_options': copy.deepcopy(item.get('mount_options', ['rw', 'nosuid', 'nodev']))})
|
|
deployment['tmpfs_mounts'] = tmpfs
|
|
sysctls = deployment.get('security', {}).get('sysctls', [])
|
|
for item in profile.get('security', {}).get('sysctls', []):
|
|
if not any(s['name'] == item['name'] for s in sysctls):
|
|
sysctls.append(copy.deepcopy(item))
|
|
deployment.setdefault('security', {})['sysctls'] = sysctls
|
|
candidate['template'] = latest
|