mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 14:36:38 +00:00
265 lines
12 KiB
Python
265 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Add or remove the extra paths and devices of one member of an installed
|
|
multi-container application, without rebuilding any of its containers."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import oci_gpu_devices as gpu_devices
|
|
import oci_host_mounts as host_mounts
|
|
import oci_instance_reconcile as reconcile
|
|
import oci_instances as instances
|
|
import oci_stack_replay as replay
|
|
from oci_ui import msg_error, msg_info, msg_ok, translate
|
|
|
|
CONVERTERS = {'install_nextcloud_stack.sh': replay.nextcloud_record,
|
|
'install_paperless_stack.sh': replay.paperless_record,
|
|
'install_tandoor_stack.sh': replay.tandoor_record,
|
|
'install_immich_stack.sh': replay.immich_record}
|
|
|
|
|
|
def run(*command):
|
|
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
if result.returncode != 0:
|
|
detail = (result.stderr or result.stdout).strip().splitlines()[-1:] or ['']
|
|
raise RuntimeError(f"{' '.join(command[:3])}: {detail[0]}")
|
|
return result.stdout
|
|
|
|
|
|
def entries(vmid, prefix):
|
|
"""The `prefix`N lines of the container configuration, in order."""
|
|
result = {}
|
|
for line in run('pct', 'config', str(vmid)).splitlines():
|
|
key, separator, value = line.partition(': ')
|
|
if separator and re.fullmatch(prefix + '[0-9]+', key):
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def options(value):
|
|
return dict(part.split('=', 1) for part in value.split(',')[1:] if '=' in part)
|
|
|
|
|
|
def free_key(vmid, prefix):
|
|
used = entries(vmid, prefix)
|
|
return next(f'{prefix}{index}' for index in range(256) if f'{prefix}{index}' not in used)
|
|
|
|
|
|
def device_path(value):
|
|
fields = dict(part.split('=', 1) for part in value.split(',') if '=' in part)
|
|
return fields.get('path') or value.split(',', 1)[0]
|
|
|
|
|
|
def validate(vmid, changes):
|
|
"""Everything that can be refused is refused before the container stops."""
|
|
mounts = {options(value).get('mp'): (key, value) for key, value in entries(vmid, 'mp').items()}
|
|
devices = {device_path(value): key for key, value in entries(vmid, 'dev').items()}
|
|
for path in changes['remove_mounts']:
|
|
if path not in mounts:
|
|
raise ValueError(f"{translate('The path to remove is not mounted:')} {path}")
|
|
for path in changes['remove_devices']:
|
|
if path not in devices:
|
|
raise ValueError(f"{translate('The device to remove is not attached:')} {path}")
|
|
kept = [path for path in mounts if path not in changes['remove_mounts']]
|
|
for mount in changes['add_mounts']:
|
|
target = host_mounts.valid_path(mount['container_path'])
|
|
if any(target == other or target.startswith(other.rstrip('/') + '/')
|
|
or other.startswith(target.rstrip('/') + '/') for other in kept):
|
|
raise ValueError(f"{translate('The custom path overlaps another mount')}: {target}")
|
|
kept.append(target)
|
|
if mount['type'] == 'managed-volume':
|
|
if not isinstance(mount.get('size_gb'), int) or mount['size_gb'] < 1 \
|
|
or not re.fullmatch(r'[A-Za-z0-9_-]+', mount.get('source') or ''):
|
|
raise ValueError(f"{translate('Invalid volume size:')} {target}")
|
|
elif mount['type'] == 'host-bind':
|
|
host_mounts.validate_source(mount['source'], allow_missing=True)
|
|
else:
|
|
raise ValueError(f"{translate('Unsupported mount type:')} {mount['type']}")
|
|
for device in changes['add_devices']:
|
|
path = device.get('host_path')
|
|
if device.get('kind') != 'character-device' or not (
|
|
gpu_devices.gpu_path(path) or gpu_devices.peripheral_path(path)):
|
|
raise ValueError(f"{translate('Device outside the supported profiles; NVIDIA and device trees require another profile')}: {path}")
|
|
if path in devices and path not in changes['remove_devices']:
|
|
raise ValueError(f"{translate('This device is already attached')}: {path}")
|
|
gpu_devices.snapshot(path)
|
|
|
|
|
|
def apply(vmid, changes):
|
|
for mount in changes['add_mounts']:
|
|
target = mount['container_path']
|
|
if mount['type'] == 'managed-volume':
|
|
value = f"{mount['source']}:{mount['size_gb']},mp={target},backup=1"
|
|
else:
|
|
source = Path(mount['source'])
|
|
if not source.exists():
|
|
source.mkdir(parents=True, mode=0o775)
|
|
os.chown(source, 100000, 100000)
|
|
value = f"{source},mp={target},backup=0"
|
|
if mount.get('read_only'):
|
|
value += ',ro=1'
|
|
run('pct', 'set', str(vmid), '--' + free_key(vmid, 'mp'), value)
|
|
msg_ok(f"{translate('Path added:')} {target}")
|
|
for path in changes['remove_devices']:
|
|
key = next(key for key, value in entries(vmid, 'dev').items() if device_path(value) == path)
|
|
run('pct', 'set', str(vmid), '--delete', key)
|
|
msg_ok(f"{translate('Device removed:')} {path}")
|
|
for device in changes['add_devices']:
|
|
path = device['host_path']
|
|
value = (f"path={path},mode={device.get('mode', '0660')},"
|
|
f"deny-write={'1' if device.get('deny_write') else '0'},gid={os.stat(path).st_gid}")
|
|
run('pct', 'set', str(vmid), '--' + free_key(vmid, 'dev'), value)
|
|
msg_ok(f"{translate('Device added:')} {path}")
|
|
for path in changes['remove_mounts']:
|
|
key, value = next((key, value) for key, value in entries(vmid, 'mp').items()
|
|
if options(value).get('mp') == path)
|
|
source = value.split(',', 1)[0]
|
|
run('pct', 'set', str(vmid), '--delete', key)
|
|
if not source.startswith('/'):
|
|
# A detached disk would be destroyed with the container on its next
|
|
# update, so the volume of a removed path is deleted here, as confirmed.
|
|
unused = next((key for key, value in entries(vmid, 'unused').items() if value == source), None)
|
|
if unused:
|
|
run('pct', 'set', str(vmid), '--delete', unused)
|
|
msg_ok(f"{translate('Path removed:')} {path}")
|
|
|
|
|
|
def recorded_mounts(vmid):
|
|
"""The mounts of a member as its installation recorded them."""
|
|
result = []
|
|
for value in entries(vmid, 'mp').values():
|
|
source = value.split(',', 1)[0]
|
|
mount = options(value)
|
|
result.append({'container_path': mount['mp'], 'source': source,
|
|
'type': 'host-bind' if source.startswith('/') else 'managed-volume',
|
|
'backup': mount.get('backup') == '1', 'read_only': mount.get('ro') == '1',
|
|
'existing_volume': True})
|
|
return result
|
|
|
|
|
|
def register(root, vmid):
|
|
"""Record the member as it is now, the way a stack update leaves it, and
|
|
refresh the copy its main container keeps."""
|
|
record = instances.read(root, vmid)
|
|
previous = record['observed']
|
|
record['observed'] = instances.observe(vmid, record['installation_id'], previous['archive_path'],
|
|
previous['resolved_registry_digest'], previous['image'])
|
|
plan = record['deployment']
|
|
adapter = (plan.get('replay_profile') or {}).get('adapter')
|
|
if adapter in CONVERTERS:
|
|
if 'native_config' in plan:
|
|
plan['native_config'] = record['observed']['config']
|
|
if 'member_replay_projection' in plan:
|
|
plan['member_replay_projection'] = replay.normalize(record)
|
|
plan['mounts'] = recorded_mounts(vmid)
|
|
else:
|
|
converted = CONVERTERS[adapter](record)
|
|
plan['mounts'] = converted['deployment']['mounts']
|
|
plan['devices'] = converted['deployment'].get('devices', [])
|
|
# The paths an update must find are the ones mounted now.
|
|
record['template']['container_contract']['volumes'] = \
|
|
converted['template']['container_contract']['volumes']
|
|
# The stack must stay updatable with what was just changed.
|
|
CONVERTERS[adapter](record)
|
|
else:
|
|
known = {mount['container_path']: mount for mount in plan.get('mounts', [])}
|
|
plan['mounts'] = [known.get(options(value).get('mp')) or reconcile._mount(key, value, vmid)
|
|
for key, value in entries(vmid, 'mp').items()]
|
|
attached = {device_path(value): (key, value) for key, value in entries(vmid, 'dev').items()}
|
|
kept = [device for device in plan.get('devices', [])
|
|
if device.get('kind') != 'character-device' or device.get('host_path') in attached]
|
|
listed = {device.get('host_path') for device in kept}
|
|
plan['devices'] = kept + [reconcile._device(key, value) for path, (key, value) in attached.items()
|
|
if path not in listed and (gpu_devices.gpu_path(path)
|
|
or gpu_devices.peripheral_path(path))]
|
|
instances.write(instances.location(root, vmid), record)
|
|
primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
|
|
primary = instances.read(root, primary_id)
|
|
snapshot = instances.read(root, vmid)
|
|
snapshot.pop('stack', None)
|
|
members = primary.get('stack', {}).get('members', [])
|
|
for index, member in enumerate(members):
|
|
if member.get('vmid') == vmid:
|
|
members[index] = snapshot
|
|
instances.write(instances.location(root, primary_id), primary)
|
|
|
|
|
|
def is_running(vmid):
|
|
return 'running' in run('pct', 'status', str(vmid))
|
|
|
|
|
|
def modify(root, vmid, changes):
|
|
record = instances.read(root, vmid)
|
|
if record.get('status') != 'installed' or record.get('pending_transaction') \
|
|
or record.get('pending_stack_transaction'):
|
|
raise ValueError(translate('The container has an operation pending; finish or recover it first'))
|
|
if not (record.get('stack_member') or record.get('stack')):
|
|
raise ValueError(translate('This container is not a member of a multi-container application'))
|
|
if instances.identity(instances.command('pct', 'config', str(vmid))) != record['installation_id']:
|
|
raise ValueError(translate('The container identity does not match'))
|
|
validate(vmid, changes)
|
|
backup = instances.location(root, vmid).parent / f"config-before-recreate-{time.strftime('%Y%m%d-%H%M%S')}.conf"
|
|
backup.write_text(run('pct', 'config', str(vmid)))
|
|
backup.chmod(0o600)
|
|
import oci_operation_notice
|
|
import oci_update_current
|
|
primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
|
|
name = oci_update_current.application_name(instances.read(root, primary_id), primary_id)
|
|
with oci_operation_notice.operation([vmid], 'modify', name, primary_id):
|
|
_modify(root, vmid, changes)
|
|
|
|
|
|
def _modify(root, vmid, changes):
|
|
running = is_running(vmid)
|
|
if running:
|
|
msg_info(translate('Stopping the container...'))
|
|
try:
|
|
run('pct', 'shutdown', str(vmid), '--timeout', '60')
|
|
except RuntimeError:
|
|
run('pct', 'stop', str(vmid))
|
|
msg_ok(translate('Container stopped'))
|
|
try:
|
|
apply(vmid, changes)
|
|
msg_info(translate('Saving the new configuration of the application...'))
|
|
register(root, vmid)
|
|
msg_ok(translate('Configuration saved'))
|
|
finally:
|
|
if running:
|
|
msg_info(translate('Starting the container...'))
|
|
run('pct', 'start', str(vmid))
|
|
msg_ok(translate('Container started'))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('vmid', type=int)
|
|
parser.add_argument('--changes', type=Path, required=True)
|
|
parser.add_argument('--root', type=Path, default=instances.ROOT)
|
|
args = parser.parse_args()
|
|
if os.geteuid() != 0:
|
|
parser.error(translate('Root privileges are required'))
|
|
changes = {'remove_mounts': [], 'add_mounts': [], 'remove_devices': [], 'add_devices': [],
|
|
**json.loads(args.changes.read_text())}
|
|
try:
|
|
with instances.locked(args.root):
|
|
modify(args.root, args.vmid, changes)
|
|
except BlockingIOError:
|
|
msg_error(translate('Another OCI operation is using the instance registry. Wait for it to finish.'))
|
|
return 1
|
|
except (OSError, ValueError, KeyError, RuntimeError, StopIteration, subprocess.TimeoutExpired) as error:
|
|
msg_error(f"{translate('The application could not be modified:')} {error}")
|
|
return 1
|
|
msg_ok(translate('The application has been modified with the new options.'))
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|