feat(oci): Recreate for multi-container apps and CPU/memory in every advanced install

This commit is contained in:
MacRimi
2026-10-01 19:39:01 +02:00
parent d137bb3d31
commit a4cb9936a5
29 changed files with 1405 additions and 110 deletions
+8 -1
View File
@@ -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"
+8 -1
View File
@@ -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"
+8 -1
View File
@@ -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"
+8 -1
View File
@@ -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"
+7
View File
@@ -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
+1 -3
View File
@@ -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):
+255
View File
@@ -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())
+51 -8
View File
@@ -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,
+62
View File
@@ -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