mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
254 lines
14 KiB
Python
254 lines
14 KiB
Python
"""Explicitly adopt supported Proxmox mounts and devices into an OCI instance."""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import re
|
|
import uuid
|
|
|
|
import oci_instances as instances
|
|
import oci_gpu_devices as gpu_devices
|
|
import oci_host_mounts as host_mounts
|
|
import oci_instance_transaction as transaction
|
|
import oci_native_modify as native_modify
|
|
from oci_installation_state import parse_config, sha
|
|
from oci_ui import translate
|
|
|
|
|
|
def _managed_size(value):
|
|
match = re.fullmatch(r'([1-9][0-9]*)([GMT])', value or '')
|
|
if not match:
|
|
raise ValueError(translate('The added disk needs a whole-GiB size recorded by Proxmox'))
|
|
amount, unit = int(match[1]), match[2]
|
|
if unit == 'M':
|
|
if amount % 1024:
|
|
raise ValueError(translate('The added disk size is not a whole GiB'))
|
|
return amount // 1024
|
|
return amount * (1024 if unit == 'T' else 1)
|
|
|
|
|
|
def _mount(key, value, vmid):
|
|
source, *parts = value.split(',')
|
|
if not source or not parts or any('=' not in part for part in parts):
|
|
raise ValueError(f'{key}: {translate("Incomplete mount configuration")}')
|
|
options = dict(part.split('=', 1) for part in parts)
|
|
if len(options) != len(parts) or set(options) - {'mp', 'size', 'backup', 'ro'}:
|
|
raise ValueError(f'{key}: {translate("Unsupported mount options")}')
|
|
target = host_mounts.valid_path(options.get('mp'))
|
|
read_only = options.get('ro', '0') == '1'
|
|
if options.get('ro', '0') not in ('0', '1'):
|
|
raise ValueError(f'{key}: {translate("Invalid read-only option")}')
|
|
if source.startswith('/'):
|
|
if options.get('backup', '0') != '0' or 'size' in options:
|
|
raise ValueError(f'{key}: {translate("Host directories cannot be included in vzdump")}')
|
|
host_mounts.validate_source(source)
|
|
return {'type': 'host-bind', 'container_path': target, 'source': source,
|
|
'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("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 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}
|
|
|
|
|
|
def _device(key, value):
|
|
parts = value.split(',')
|
|
if any('=' not in part for part in parts):
|
|
raise ValueError(f'{key}: {translate("Incomplete device configuration")}')
|
|
fields = dict(part.split('=', 1) for part in parts)
|
|
if len(fields) != len(parts) or set(fields) - {'path', 'mode', 'gid', 'uid', 'deny-write'}:
|
|
raise ValueError(f'{key}: {translate("Unsupported device options")}')
|
|
path = fields.get('path')
|
|
if not (gpu_devices.gpu_path(path) or gpu_devices.peripheral_path(path)):
|
|
raise ValueError(f'{key}: {translate("Only Intel/AMD DRM, Coral, NPU and USB nodes can be adopted automatically")}')
|
|
snapshot = gpu_devices.snapshot(path)
|
|
mode = fields.get('mode', '0660')
|
|
if not re.fullmatch(r'0?[0-7]{3}', mode):
|
|
raise ValueError(f'{key}: {translate("Invalid device mode")}')
|
|
gid = int(fields.get('gid', '0'))
|
|
if gid not in (0, snapshot['gid']):
|
|
raise ValueError(f'{key}: {translate("Device GID does not match the host")}')
|
|
if fields.get('deny-write', '0') not in ('0', '1'):
|
|
raise ValueError(f'{key}: {translate("Invalid device permissions")}')
|
|
uid = int(fields.get('uid', '0'))
|
|
if uid < 0 or uid >= 4294967295:
|
|
raise ValueError(f'{key}: {translate("Invalid device UID")}')
|
|
device = {'id': 'adopted-' + path.removeprefix('/dev/').replace('/', '-'),
|
|
'kind': 'character-device', 'host_path': path, 'container_path': path,
|
|
'mode': mode, 'gid_strategy': 'host-device-gid' if gid == snapshot['gid'] else 'none',
|
|
'deny_write': fields.get('deny-write', '0') == '1'}
|
|
if uid:
|
|
device['uid'] = uid
|
|
if gpu_devices.gpu_path(path) and path != '/dev/kfd':
|
|
device['drm_vendor_ids'] = [snapshot['vendor']]
|
|
return device
|
|
|
|
|
|
ENVIRONMENT_KEYS = ('env', 'lxc.environment.runtime')
|
|
|
|
|
|
def _environment(record, config):
|
|
"""Variables changed in Proxmox since the last operation. Values are
|
|
applied but never listed: they may be passwords. A variable the image
|
|
defines and the user removed comes back with its value, as in Docker."""
|
|
before = native_modify.environment(record['observed']['config'].encode())
|
|
now = native_modify.environment(config)
|
|
removed = sorted(before.keys() - now.keys())
|
|
changed = sorted(name for name in now if before.get(name) != now[name])
|
|
return {name: now[name] for name in changed}, removed
|
|
|
|
|
|
def _adopt_environment(deployment, values, removed):
|
|
items = deployment.setdefault('environment', [])
|
|
details = []
|
|
for name, value in values.items():
|
|
matches = [item for item in items if item['name'] == name]
|
|
if len(matches) > 1:
|
|
raise ValueError(translate('Duplicate variable in the contract; review it before editing'))
|
|
if matches:
|
|
matches[0]['value'] = value
|
|
details.append(f"env {name}: {translate('value changed in Proxmox')}")
|
|
else:
|
|
items.append({'name': name, 'value': value, 'sensitive': True})
|
|
details.append(f"env {name}: {translate('added in Proxmox')}")
|
|
for name in removed:
|
|
items[:] = [item for item in items if item['name'] != name]
|
|
details.append(f"env {name}: {translate('removed in Proxmox')}")
|
|
return details
|
|
|
|
|
|
def _storage_change(key, value, deployment, vmid):
|
|
"""A disk resized or moved in Proxmox: the record follows it."""
|
|
if key == 'rootfs':
|
|
storage = value.split(':', 1)[0]
|
|
size = dict(part.split('=', 1) for part in value.split(',')[1:] if '=' in part).get('size')
|
|
deployment['rootfs'].update(storage=storage, size_gb=_managed_size(size))
|
|
return
|
|
current = _mount(key, value, vmid)
|
|
for mount in deployment.get('mounts', []):
|
|
if mount.get('container_path') == current['container_path']:
|
|
mount.update({field: current[field] for field in ('source', 'size_gb', 'read_only')})
|
|
|
|
|
|
def propose(record, config, member=False):
|
|
if (record.get('status') != 'installed' or (not member and (
|
|
record.get('stack') or record.get('stack_member') or record.get('native_stack_intent')
|
|
or record.get('deployment', {}).get('stack_managed')))):
|
|
raise ValueError(translate('Only an installed standalone OCI instance can adopt external changes'))
|
|
if instances.identity(config) != record['installation_id']:
|
|
raise ValueError(translate('The container identity changed; nothing was adopted'))
|
|
if transaction.nested_mounts.stale(config):
|
|
transaction.nested_mounts.follow(record['vmid'])
|
|
config = instances.command('pct', 'config', str(record['vmid']))
|
|
before, current = transaction.settings(record['observed']['config'].encode()), transaction.settings(config)
|
|
changed = sorted(key for key in before.keys() | current.keys() if before.get(key) != current.get(key))
|
|
# Notes do not describe a mount, device or runtime setting. The OCI marker
|
|
# was checked above, so a presentation-only change needs no adoption.
|
|
if 'description' in changed:
|
|
changed.remove('description')
|
|
environment = None
|
|
if any(key in ENVIRONMENT_KEYS for key in changed):
|
|
changed = [key for key in changed if key not in ENVIRONMENT_KEYS]
|
|
environment = _environment(record, config)
|
|
custom = transaction.custom_lxc(record, config)
|
|
custom_keys = {line.split(': ', 1)[0] for line in (*custom, *record['deployment'].get('custom_lxc', []))}
|
|
custom_changed = sorted(custom) != sorted(record['deployment'].get('custom_lxc', []))
|
|
changed = [key for key in changed if key not in custom_keys]
|
|
new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)]
|
|
storage = [key for key in changed if key in current and key in before and re.fullmatch(r'rootfs|mp[0-9]+', key)]
|
|
detached = [key for key in changed if key not in current and (
|
|
re.fullmatch(r'dev[0-9]+', key) or (re.fullmatch(r'mp[0-9]+', key) and before[key].startswith('/')))]
|
|
unsupported = [key for key in changed if key not in new_keys and key not in storage and key not in detached
|
|
and key != 'net0'
|
|
and key not in transaction.ADOPTABLE and key not in transaction.KEPT_AS_IS]
|
|
if unsupported:
|
|
raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} {', '.join(unsupported)}")
|
|
if not member and not (new_keys or storage or detached or custom_changed or environment is not None
|
|
or {'net0', 'hostname', 'entrypoint'} & set(changed)):
|
|
return None
|
|
candidate = copy.deepcopy(record)
|
|
deployment = candidate['deployment']
|
|
if custom:
|
|
deployment['custom_lxc'] = custom
|
|
else:
|
|
deployment.pop('custom_lxc', None)
|
|
details = _adopt_environment(deployment, *environment) if environment else []
|
|
for key in storage:
|
|
_storage_change(key, current[key], deployment, record['vmid'])
|
|
# A host directory or a device taken away in Proxmox holds no data of the container.
|
|
for key in detached:
|
|
fields = dict(part.split('=', 1) for part in before[key].split(',') if '=' in part)
|
|
if key.startswith('mp'):
|
|
deployment['mounts'] = [mount for mount in deployment.get('mounts', [])
|
|
if mount.get('container_path') != fields.get('mp')]
|
|
else:
|
|
path = fields.get('path') or before[key].split(',', 1)[0]
|
|
deployment['devices'] = [device for device in deployment.get('devices', [])
|
|
if device.get('host_path') != path]
|
|
for key in new_keys:
|
|
if key.startswith('mp'):
|
|
mount = _mount(key, current[key], record['vmid'])
|
|
deployment.setdefault('mounts', []).append(mount)
|
|
details.append(f"{key}: {mount['container_path']} <- {current[key].split(',', 1)[0]} "
|
|
f"({mount['type']}, backup={int(mount['backup'])})")
|
|
else:
|
|
device = _device(key, current[key])
|
|
deployment.setdefault('devices', []).append(device)
|
|
kind = 'GPU' if gpu_devices.gpu_path(device['host_path']) else 'USB/Coral/NPU'
|
|
details.append(f"{key}: {device['host_path']} ({kind})")
|
|
skipped = [*new_keys, *storage, *detached, *custom_keys, *(ENVIRONMENT_KEYS if environment else ())]
|
|
filtered = b'\n'.join(line for line in config.splitlines()
|
|
if not any(line.startswith(key.encode() + b': ') for key in skipped))
|
|
if environment or storage or detached or custom_keys:
|
|
filtered += b''.join(b'\n' + line for line in record['observed']['config'].encode().splitlines()
|
|
if any(line.startswith(key.encode() + b': ')
|
|
for key in (*storage, *detached, *custom_keys,
|
|
*(ENVIRONMENT_KEYS if environment else ()))))
|
|
filtered += b'\n'
|
|
for key, value in transaction.external_changes(record, filtered).items():
|
|
section, name = transaction.ADOPTABLE[key]
|
|
target = deployment.setdefault(section, {}) if section else deployment
|
|
target[name] = value
|
|
candidate['observed']['config'] = config.decode()
|
|
candidate['observed']['config_sha256'] = sha(config)
|
|
candidate['observed']['gpu_devices'] = gpu_devices.capture(config)
|
|
candidate['observed']['host_bind_sources'] = host_mounts.capture_sources(config)
|
|
transaction.preflight(candidate, copy.deepcopy(candidate), config, coordinated=member)
|
|
# A disk or a device is reviewed; the rest is what the user already decided.
|
|
return {'candidate': candidate, 'details': details, 'review': bool(new_keys),
|
|
'previous_record_hash': sha(instances.location(instances.ROOT, record['vmid']).read_bytes()),
|
|
'config_sha256': sha(config)}
|
|
|
|
|
|
def commit(root, vmid, proposal):
|
|
with instances.locked(root):
|
|
return commit_locked(root, vmid, proposal)
|
|
|
|
|
|
def commit_locked(root, vmid, proposal, member=False):
|
|
record = instances.read(root, vmid)
|
|
path = instances.location(root, vmid)
|
|
if sha(path.read_bytes()) != proposal['previous_record_hash']:
|
|
raise ValueError(translate('The OCI contract changed while reviewing; nothing was adopted'))
|
|
config = instances.command('pct', 'config', str(vmid))
|
|
if sha(config) != proposal['config_sha256']:
|
|
raise ValueError(translate('The LXC changed while reviewing; nothing was adopted'))
|
|
checked = propose(record, config, member)
|
|
if checked is None or checked['details'] != proposal['details']:
|
|
raise ValueError(translate('The proposed changes no longer match; nothing was adopted'))
|
|
candidate = checked['candidate']
|
|
observed = instances.observe(vmid, record['installation_id'], record['observed']['archive_path'],
|
|
record['observed']['resolved_registry_digest'], record['observed']['image'])
|
|
if observed['config_sha256'] != proposal['config_sha256']:
|
|
raise ValueError(translate('The LXC changed during verification; nothing was adopted'))
|
|
candidate['observed'] = observed
|
|
history = path.parent / 'history'
|
|
instances.write(history / f'before-reconciliation-{uuid.uuid4().hex}.json', record)
|
|
instances.write(path, candidate)
|
|
return candidate
|