mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-09 06:56:37 +00:00
feat(oci): Recreate for multi-container apps and CPU/memory in every advanced install
This commit is contained in:
@@ -120,6 +120,11 @@ MEDIA_STORAGE=$(jq -r '.media.storage // empty' "$DEPLOYMENT_FILE")
|
||||
MEDIA_SIZE=$(jq -r '.media.size_gb // empty' "$DEPLOYMENT_FILE")
|
||||
MEDIA_ROOT=$(jq -r '.media.host_path // empty' "$DEPLOYMENT_FILE")
|
||||
TIMEZONE=$(jqr '.timezone')
|
||||
APPLICATION_CORES=$(jqr '.resources.cores // 4')
|
||||
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 3072')
|
||||
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
|
||||
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|
||||
|| die "$(translate "Invalid resources")"
|
||||
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
|
||||
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
|
||||
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
|
||||
@@ -449,13 +454,15 @@ fi
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_create_container "$SERVER_ID" "$SERVER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:16" \
|
||||
--mp0 "$SERVER_MEDIA_MOUNT" --hostname "${STACK_NAME}-server" \
|
||||
--cores 4 --memory 3072 --swap 1024 \
|
||||
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
|
||||
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
|
||||
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${SERVER_ADDRESS},type=veth" \
|
||||
"${SERVER_DEVICE_ARGS[@]}" --unprivileged 1 --features nesting=1 --cmode console \
|
||||
--onboot "$ONBOOT" --startup order=40,up=10,down=30 --tags "$TAGS" \
|
||||
--description 'Immich server native OCI'
|
||||
created_ids+=("$SERVER_ID")
|
||||
oci_apply_extra_mounts "$SERVER_ID"
|
||||
oci_apply_extra_devices "$SERVER_ID"
|
||||
|
||||
oci_quiet pct mount "$SERVER_ID"
|
||||
SERVER_ROOT="/var/lib/lxc/${SERVER_ID}/rootfs"
|
||||
|
||||
@@ -116,6 +116,11 @@ APACHE_BODY_LIMIT=$(jqr '.application.apache_body_limit')
|
||||
TIMEZONE=$(jqr '.timezone')
|
||||
MAINTENANCE_WINDOW=$(jqr '.maintenance_window_start_utc')
|
||||
PHONE_REGION=$(jqr '.default_phone_region')
|
||||
APPLICATION_CORES=$(jqr '.resources.cores // 2')
|
||||
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 2048')
|
||||
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
|
||||
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|
||||
|| die "$(translate "Invalid resources")"
|
||||
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
|
||||
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
|
||||
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
|
||||
@@ -395,13 +400,15 @@ msg_ok "$(translate "Container created:") CT $CACHE_ID (Redis)"
|
||||
msg_info "$(translate "Creating the container...")"
|
||||
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
--mp0 "$APPLICATION_MOUNT" --hostname "$STACK_NAME" \
|
||||
--cores 2 --memory 2048 --swap 1024 \
|
||||
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
|
||||
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
|
||||
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
|
||||
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
|
||||
--startup order=30,up=15,down=30 --tags "$TAGS" \
|
||||
--description 'Nextcloud Apache native OCI'
|
||||
created_ids+=("$APPLICATION_ID")
|
||||
oci_apply_extra_mounts "$APPLICATION_ID"
|
||||
oci_apply_extra_devices "$APPLICATION_ID"
|
||||
|
||||
oci_quiet pct mount "$APPLICATION_ID"
|
||||
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
|
||||
|
||||
@@ -116,6 +116,11 @@ TRANSFER_ROOT=$(jq -r '.transfer.host_path // empty' "$DEPLOYMENT_FILE")
|
||||
ADMIN_USERNAME=$(jqr '.application.admin_username')
|
||||
OCR_LANGUAGE=$(jqr '.application.ocr_language')
|
||||
TIMEZONE=$(jqr '.timezone')
|
||||
APPLICATION_CORES=$(jqr '.resources.cores // 2')
|
||||
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 2048')
|
||||
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
|
||||
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|
||||
|| die "$(translate "Invalid resources")"
|
||||
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
|
||||
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
|
||||
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
|
||||
@@ -414,13 +419,15 @@ oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS
|
||||
--mp0 "${APPLICATION_STORAGE}:${DATA_SIZE},mp=/usr/src/paperless/data,backup=1" \
|
||||
--mp1 "${APPLICATION_STORAGE}:${MEDIA_SIZE},mp=/usr/src/paperless/media,backup=1" \
|
||||
--mp2 "$EXPORT_MOUNT" --mp3 "$CONSUME_MOUNT" --hostname "$STACK_NAME" \
|
||||
--cores 2 --memory 2048 --swap 1024 \
|
||||
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
|
||||
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
|
||||
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
|
||||
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
|
||||
--startup order=30,up=15,down=30 --tags "$TAGS" \
|
||||
--description 'Paperless-ngx native OCI'
|
||||
created_ids+=("$APPLICATION_ID")
|
||||
oci_apply_extra_mounts "$APPLICATION_ID"
|
||||
oci_apply_extra_devices "$APPLICATION_ID"
|
||||
|
||||
oci_quiet pct mount "$APPLICATION_ID"
|
||||
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
|
||||
|
||||
@@ -129,6 +129,11 @@ ALLOWED_HOSTS=$(jqr '.application.allowed_hosts')
|
||||
ADMIN_USERNAME=$(jqr '.application.admin_username')
|
||||
ADMIN_EMAIL=$(jqr '.application.admin_email')
|
||||
TIMEZONE=$(jqr '.timezone')
|
||||
APPLICATION_CORES=$(jqr '.resources.cores // 2')
|
||||
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 2048')
|
||||
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 512')
|
||||
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|
||||
|| die "$(translate "Invalid resources")"
|
||||
ONBOOT=$(jqr '.onboot | if . then 1 else 0 end')
|
||||
START_AFTER=$(jqr '.start_after_create | if . then 1 else 0 end')
|
||||
FRONTEND_BRIDGE=$(jqr '.network.frontend_bridge')
|
||||
@@ -392,13 +397,15 @@ msg_info "$(translate "Creating the container...")"
|
||||
oci_create_container "$APPLICATION_ID" "$APPLICATION_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:8" \
|
||||
--mp0 "${APPLICATION_STORAGE}:${STATIC_SIZE},mp=/opt/recipes/staticfiles,backup=1" \
|
||||
--mp1 "$MEDIA_MOUNT" --hostname "$STACK_NAME" \
|
||||
--cores 2 --memory 2048 --swap 512 \
|
||||
--cores "$APPLICATION_CORES" --memory "$APPLICATION_MEMORY" --swap "$APPLICATION_SWAP" \
|
||||
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${FRONTEND_NET},type=veth" \
|
||||
--net1 "name=eth1,bridge=${PRIVATE_BRIDGE},firewall=1,host-managed=1,ip=${APPLICATION_ADDRESS},type=veth" \
|
||||
--unprivileged 1 --features nesting=1 --cmode console --onboot "$ONBOOT" \
|
||||
--startup order=20,up=15,down=30 --tags "$TAGS" \
|
||||
--description 'Tandoor Recipes native OCI'
|
||||
created_ids+=("$APPLICATION_ID")
|
||||
oci_apply_extra_mounts "$APPLICATION_ID"
|
||||
oci_apply_extra_devices "$APPLICATION_ID"
|
||||
|
||||
oci_quiet pct mount "$APPLICATION_ID"
|
||||
APPLICATION_ROOTFS="/var/lib/lxc/${APPLICATION_ID}/rootfs"
|
||||
|
||||
@@ -20,6 +20,7 @@ import contextlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -42,6 +43,12 @@ def start_mark_hook(vmid: int) -> str:
|
||||
# `test`, not `[`: a bracket in the configuration reads as a snapshot section.
|
||||
return (f"lxc.hook.pre-start: /bin/sh -c 'mkdir -p {LOG_DIR}; "
|
||||
f"test -x {script} && {script} {int(vmid)}; exit 0'")
|
||||
|
||||
|
||||
def is_start_mark_hook(line: str) -> bool:
|
||||
"""Whether a configuration line is exactly the start hook written here."""
|
||||
vmid = re.search(r" (\d+); exit 0'$", line)
|
||||
return bool(vmid) and line == start_mark_hook(int(vmid[1]))
|
||||
LOGROTATE = Path('/etc/logrotate.d/proxmenux-oci')
|
||||
# copytruncate, because liblxc keeps the file open for as long as the
|
||||
# container runs; moving it away would leave the application writing into the
|
||||
|
||||
@@ -6,7 +6,6 @@ the same profile.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
import oci_console
|
||||
import oci_nvidia_runtime as nv
|
||||
@@ -15,8 +14,7 @@ from oci_ui import translate
|
||||
|
||||
def console_start_hook(value):
|
||||
"""The hook ProxMenux adds to mark each start in the console log."""
|
||||
vmid = re.search(r' (\d+); exit 0\'$', value)
|
||||
return bool(vmid) and oci_console.start_mark_hook(int(vmid[1])) == f'lxc.hook.pre-start: {value}'
|
||||
return oci_console.is_start_mark_hook(f'lxc.hook.pre-start: {value}')
|
||||
|
||||
|
||||
def gpu_identity(inventory):
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/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)
|
||||
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 recreated:')} {error}")
|
||||
return 1
|
||||
msg_ok(translate('The application has been recreated with the new options.'))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -7,6 +7,8 @@ import stat
|
||||
import shlex
|
||||
import os
|
||||
|
||||
import oci_console
|
||||
import oci_gpu_devices
|
||||
from oci_ui import translate
|
||||
|
||||
|
||||
@@ -75,6 +77,9 @@ def immich_record(record):
|
||||
path = fields.get('path')
|
||||
if cuda and path and path.startswith('/dev/nvidia'):
|
||||
continue
|
||||
if oci_gpu_devices.peripheral_path(path):
|
||||
devices.append(peripheral_device(fields))
|
||||
continue
|
||||
if not path or not re.fullmatch(r'/dev/dri/renderD[0-9]+', path):
|
||||
raise ValueError(translate('Immich device without a validated translation'))
|
||||
devices.append({'kind': 'character-device', 'host_path': path, 'container_path': path,
|
||||
@@ -219,25 +224,59 @@ def adapter_prerequisites(rootfs, role, image, adapter, required):
|
||||
return checked
|
||||
|
||||
|
||||
def peripheral_device(fields):
|
||||
"""A native devN entry as the device the installer attaches again."""
|
||||
path = fields['path']
|
||||
device = {'id': 'native-' + path.removeprefix('/dev/').replace('/', '-'), 'kind': 'character-device',
|
||||
'host_path': path, 'container_path': path, 'mode': fields.get('mode', '0660'),
|
||||
'deny_write': fields.get('deny-write', '0') == '1',
|
||||
'gid_strategy': 'host-device-gid' if 'gid' in fields else 'none'}
|
||||
if 'uid' in fields:
|
||||
device['uid'] = int(fields['uid'])
|
||||
return device
|
||||
|
||||
|
||||
def translated_devices(projection):
|
||||
"""The devices of a member the update keeps: USB, serial and GPU nodes.
|
||||
Anything else has no translation and stops the update before it starts."""
|
||||
devices = []
|
||||
for item in projection['native_devices']:
|
||||
fields = dict(part.split('=', 1) for part in item['value'].split(',') if '=' in part)
|
||||
path = fields.get('path')
|
||||
if not (oci_gpu_devices.gpu_path(path) or oci_gpu_devices.peripheral_path(path)):
|
||||
raise ValueError(translate('The stack contains devices or directives without a translation'))
|
||||
devices.append(peripheral_device(fields))
|
||||
return devices
|
||||
|
||||
|
||||
def with_devices(record, result):
|
||||
result['deployment']['devices'] = translated_devices(normalize(record))
|
||||
return result
|
||||
|
||||
|
||||
def nextcloud_record(record):
|
||||
"""Build portable desired state from evidence, without writing the registry."""
|
||||
return portable_record(record, nextcloud_installer_profile(record))
|
||||
return with_devices(record, portable_record(record, nextcloud_installer_profile(record)))
|
||||
|
||||
|
||||
def paperless_record(record):
|
||||
"""Translate captured Paperless state; native activation remains separate."""
|
||||
return portable_record(record, paperless_installer_profile(record))
|
||||
return with_devices(record, portable_record(record, paperless_installer_profile(record)))
|
||||
|
||||
|
||||
def tandoor_record(record):
|
||||
"""Project the two-member Tandoor recipe without activating replacement."""
|
||||
return portable_record(record, tandoor_installer_profile(record))
|
||||
return with_devices(record, portable_record(record, tandoor_installer_profile(record)))
|
||||
|
||||
|
||||
def portable_record(record, profile):
|
||||
"""Preserve data mounts and provenance without first-install preparations."""
|
||||
projection = normalize(record)
|
||||
saved = record['deployment'].get('member_replay_projection')
|
||||
if saved is not None:
|
||||
# A projection saved while the start hook was still listed carries it.
|
||||
saved = dict(saved, preserved_raw_runtime=[line for line in saved.get('preserved_raw_runtime', [])
|
||||
if not oci_console.is_start_mark_hook(line)])
|
||||
if saved is not None and saved != projection:
|
||||
raise ValueError(translate('The saved projection does not match the native evidence'))
|
||||
result = copy.deepcopy(record)
|
||||
@@ -297,8 +336,9 @@ def official_application_profile(record, adapter, adapted_entrypoints):
|
||||
recipe = record['deployment']['rootfs_replay']
|
||||
if recipe['adapter'] != adapter:
|
||||
raise ValueError(translate('Stack adapter not recognized by the translator'))
|
||||
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
|
||||
if projection.get('preserved_raw_runtime'):
|
||||
raise ValueError(translate('The stack contains devices or directives without a translation'))
|
||||
translated_devices(projection)
|
||||
runtime = projection['runtime']
|
||||
entrypoint = runtime.get('entrypoint', '')
|
||||
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
|
||||
@@ -338,8 +378,9 @@ def nextcloud_installer_profile(record):
|
||||
recipe = record['deployment']['rootfs_replay']
|
||||
if recipe['adapter'] != 'install_nextcloud_stack.sh':
|
||||
raise ValueError(translate('This translator only supports the Nextcloud stack'))
|
||||
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
|
||||
if projection.get('preserved_raw_runtime'):
|
||||
raise ValueError(translate('The stack contains devices or directives without a translation'))
|
||||
translated_devices(projection)
|
||||
runtime = projection['runtime']
|
||||
entrypoint = runtime.get('entrypoint', '')
|
||||
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
|
||||
@@ -563,11 +604,13 @@ def normalize(record):
|
||||
preserved = {key: single(key) for key in ('arch', 'ostype', 'cmode', 'console', 'tty', 'cpuunits',
|
||||
'net0', 'net1', 'startup', 'hookscript', 'features', 'tags') if key in values}
|
||||
devices = [{'key': key, 'value': single(key)} for key in values if re.fullmatch(r'dev[0-9]+', key)]
|
||||
# The console log line is not replayed: the installer that rebuilds the
|
||||
# member sets it itself, and replaying it too would leave two of them.
|
||||
# The console log line and its start hook are not replayed: the installer
|
||||
# that rebuilds the member sets them itself, and replaying them too would
|
||||
# leave two of each.
|
||||
raw_runtime = [line for line in config.splitlines() if line.startswith('lxc.')
|
||||
and line.partition(': ')[0] not in ('lxc.environment.runtime', 'lxc.init.cwd',
|
||||
'lxc.signal.halt', 'lxc.console.logfile')]
|
||||
'lxc.signal.halt', 'lxc.console.logfile')
|
||||
and not oci_console.is_start_mark_hook(line)]
|
||||
return {'schema_version': 1, 'deployment': plan, 'runtime': runtime,
|
||||
'preserved_native': preserved, 'generated_files': copy.deepcopy(files),
|
||||
'native_devices': devices, 'preserved_raw_runtime': raw_runtime,
|
||||
|
||||
@@ -158,6 +158,68 @@ oci_create_container() {
|
||||
return "$status"
|
||||
}
|
||||
|
||||
# The extra paths the user added to the application container of a
|
||||
# multi-container application, from `.extra_mounts` of the deployment.
|
||||
# Argument: VMID. A path on the host is created for the root of the container.
|
||||
oci_apply_extra_mounts() {
|
||||
local vmid=$1 type target source size read_only index value count=0
|
||||
while IFS=$'\t' read -r type target source size read_only; do
|
||||
[[ -n $type ]] || continue
|
||||
[[ $target == /* && $target != *","* && $target != *[[:space:]]* ]] \
|
||||
|| die "$(translate "Invalid container path:") $target"
|
||||
index=0
|
||||
while pct config "$vmid" | grep -q "^mp${index}:"; do index=$((index + 1)); done
|
||||
if [[ $type == managed-volume ]]; then
|
||||
[[ $size =~ ^[0-9]+$ && $size -ge 1 && $source != /* && $source != *","* ]] \
|
||||
|| die "$(translate "Invalid volume size:") $target"
|
||||
value="${source}:${size},mp=${target},backup=1"
|
||||
elif [[ $type == host-bind ]]; then
|
||||
[[ $source == /* && $source != *","* ]] || die "$(translate "Invalid host path:") $source"
|
||||
if [[ ! -e $source ]]; then
|
||||
install -d -m 0775 -o 100000 -g 100000 "$source"
|
||||
oci_log "Shared directory created: $source (uid=100000 gid=100000)"
|
||||
fi
|
||||
[[ -d $source ]] || die "$(translate "The host bind source is not a regular file or directory:") $source"
|
||||
value="${source},mp=${target},backup=0"
|
||||
else
|
||||
die "$(translate "Unsupported mount type:") $type"
|
||||
fi
|
||||
[[ $read_only == true ]] && value="${value},ro=1"
|
||||
oci_quiet pct set "$vmid" "--mp${index}" "$value" \
|
||||
|| die "$(translate "Could not add the mount point:") $target"
|
||||
count=$((count + 1))
|
||||
done < <(jq -r '.extra_mounts[]? | [.type, .container_path, .source, (.size_gb // "-"), (.read_only // false)] | @tsv' "$DEPLOYMENT_FILE")
|
||||
if (( count > 0 )); then
|
||||
msg_ok "$(translate "Mount points added:") $count"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# The USB, serial or GPU nodes the user added to the application container of
|
||||
# a multi-container application, from `.extra_devices` of the deployment.
|
||||
# Argument: VMID. Each node keeps its path, with the group it has on the host.
|
||||
oci_apply_extra_devices() {
|
||||
local vmid=$1 path mode deny_write gid index count=0
|
||||
while IFS=$'\t' read -r path mode deny_write; do
|
||||
[[ -n $path ]] || continue
|
||||
[[ $path == /dev/* && $path != *","* && $path != *[[:space:]]* && $path != *".."* ]] \
|
||||
|| die "$(translate "Invalid device path:") $path"
|
||||
[[ -c $path ]] || die "$(translate "The character device does not exist:") $path"
|
||||
[[ $mode =~ ^0?[0-7]{3}$ ]] || die "$(translate "Invalid device mode:") $mode"
|
||||
pct config "$vmid" | grep -Eq "^dev[0-9]+: (.*,)?path=${path}(,|$)" && continue
|
||||
index=0
|
||||
while pct config "$vmid" | grep -q "^dev${index}:"; do index=$((index + 1)); done
|
||||
gid=$(stat -c '%g' "$path")
|
||||
oci_quiet pct set "$vmid" "--dev${index}" "path=${path},mode=${mode},deny-write=${deny_write},gid=${gid}" \
|
||||
|| die "$(translate "Could not add the device to the container:") $path"
|
||||
count=$((count + 1))
|
||||
done < <(jq -r '.extra_devices[]? | select(.kind == "character-device") | [.host_path, (.mode // "0660"), (if .deny_write then 1 else 0 end)] | @tsv' "$DEPLOYMENT_FILE")
|
||||
if (( count > 0 )); then
|
||||
msg_ok "$(translate "Devices added:") $count"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Last lines of the log, for the error report.
|
||||
oci_log_tail() {
|
||||
[[ -n $OCI_LOG && -s $OCI_LOG ]] || return 0
|
||||
|
||||
Reference in New Issue
Block a user