Files
ProxMenux/oci/remote/install_generic_stack.py
T

1147 lines
55 KiB
Python

#!/usr/bin/env python3
"""Host-side orchestration; only standard-library dependencies are needed on Proxmox."""
from __future__ import annotations
import base64
import copy
import configparser
import fcntl
import hashlib
import http.cookiejar
import ipaddress
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import tempfile
import time
import urllib.request
import urllib.parse
import uuid
import xml.etree.ElementTree as ET
from allocate_private_network import allocate_network, existing_bridges, existing_networks
import oci_instances
import oci_nested_mounts
import oci_stack_public_routes
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2, stop_spinner, log
HERE = Path(__file__).resolve().parent
LOG_DIR = Path(os.environ.get('OCI_LOG_DIR', '/var/log/proxmenux/oci'))
LOG = os.environ.get('OCI_LOG') or None
RESULT_MARKER = b'PROXMENUX_RESULT='
ERROR_REPORTED = False
class ServiceFailed(RuntimeError):
"""The child installer already printed its own error and log tail."""
def access_address(address, gateway):
"""ip= and gw= options of an access interface: DHCP or a static IPv4."""
if address == 'dhcp':
return 'ip=dhcp'
try:
interface = ipaddress.IPv4Interface(address) if '/' in address else None
router = ipaddress.IPv4Address(gateway) if gateway else None
except ValueError:
interface = None
if interface is None or (router is not None and (router not in interface.network or router == interface.ip)):
raise RuntimeError(f"{translate('Invalid access address:')} {address} {gateway or ''}".rstrip())
return f'ip={interface}' + (f',gw={router}' if router else '')
def init_log(name):
"""Private run log shared with every child installer through OCI_LOG."""
global LOG
if not LOG:
LOG_DIR.mkdir(parents=True, exist_ok=True)
try:
LOG_DIR.chmod(0o700)
except OSError:
pass
safe = re.sub(r'[^A-Za-z0-9._-]', '_', name or 'stack')
path = LOG_DIR / f"{safe}-{time.strftime('%Y%m%d-%H%M%S')}.log"
os.close(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600))
path.chmod(0o600)
LOG = str(path)
os.environ['OCI_LOG'] = LOG
def log_tail(lines=12):
if not LOG:
return []
try:
content = Path(LOG).read_text(errors='replace').splitlines()
except OSError:
return []
return content[-lines:]
def report_error(text, tail=True):
"""msg_error plus the end of the run log, like die() in the bash installers."""
global ERROR_REPORTED
ERROR_REPORTED = True
msg_error(text)
if not LOG:
return
if tail:
for line in log_tail():
sys.stderr.write(' '+line+'\n')
sys.stderr.write(f" {translate('Full log:')} {LOG}\n")
sys.stderr.flush()
def persist_instances(deployment, services, primary_id=None):
template = json.loads(Path(sys.argv[1]).read_text()) if primary_id is not None else {}
with oci_instances.locked(oci_instances.ROOT, timeout=60):
if primary_id is not None:
oci_instances.save_assembly(oci_instances.ROOT, primary_id, template, deployment, services)
oci_instances.resume_assembly(oci_instances.ROOT, primary_id)
else:
oci_instances.publish_stack(oci_instances.ROOT, None, template, deployment, services)
def refresh_notes(services):
"""The notes of each container were written when it was created, before
it had its leg on the local network: write them again with the address
the application is reached at."""
from oci_description import render
from oci_native_stack import access_address as reachable_address
for service in services:
vmid = int(service['vmid'])
try:
record = oci_instances.read(oci_instances.ROOT, vmid)
running = 'running' in run('pct', 'status', vmid)
description = render(record['template'], '', record['installation_id'],
reachable_address(vmid, wait=20 if running else 0))
run('pct', 'set', vmid, '--description', description)
except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
log(LOG, f'Notes of CT {vmid} not refreshed: {error}')
def run(*args, capture=True, timeout=180):
argv = list(map(str,args))
if capture:
try:
return subprocess.run(argv, check=True, text=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout).stdout
except subprocess.CalledProcessError as error:
log(LOG, '$ '+' '.join(argv)+'\n'+(error.stdout or '')+(error.stderr or ''))
raise
# Command output belongs to the run log, never to the terminal.
with open(LOG or os.devnull, 'a') as output:
return subprocess.run(argv, check=True, text=True, stdout=output,
stderr=subprocess.STDOUT, timeout=timeout).stdout
def exists(vmid):
return any(Path('/etc/pve/nodes').glob(f'*/lxc/{vmid}.conf')) or any(Path('/etc/pve/nodes').glob(f'*/qemu-server/{vmid}.conf'))
def retire_stale_contract(path, primary_id):
if not path.exists():
return
if path.is_symlink():
raise RuntimeError(translate('The previous stack contract is not safe; review it before reusing it'))
try:
contract = json.loads(path.read_text())
if contract.get('schema') != 1 or not isinstance(contract.get('dependencies'),list):
raise ValueError('unrecognized structure')
ids = {primary_id}
for dependency in contract['dependencies']:
vmid = dependency['vmid']
if type(vmid) is not int or vmid < 100:
raise ValueError('invalid VMID')
ids.add(vmid)
except (ValueError, KeyError, TypeError, AttributeError) as error:
raise RuntimeError(translate('The previous stack contract is not valid; it is not archived automatically')) from error
live = sorted(vmid for vmid in ids if exists(vmid))
if live:
raise RuntimeError(f"{translate('The previous stack contract still has containers or VMs:')} {', '.join(map(str,live))}")
archive = path.with_name(f'proxmenux-retired-stack-{primary_id}-{uuid.uuid4().hex}.json')
path.rename(archive)
msg_info2(f"{translate('Orphan stack contract archived:')} CT {primary_id} → {archive}")
log(LOG, 'The shared hookscript is kept.')
def write_json(path, value):
path.write_text(json.dumps(value,indent=2)+'\n')
path.chmod(0o600)
def create_service(service, directory):
template = directory / 'template.json'
deployment = directory / 'deployment.json'
write_json(template, service['template'])
child_plan = copy.deepcopy(service['deployment'])
child_plan['mounts'] = []
child_plan['stack_managed'] = True
write_json(deployment, child_plan)
# The child shares this run log; its steps are relayed as they are drawn
# (spinner frames included) and its result line is kept (one stack result).
environment = dict(os.environ, OCI_LOG=LOG or '',
OCI_SPINNER='1' if sys.stdout.isatty() or os.environ.get('OCI_SPINNER') == '1' else '0')
process = subprocess.Popen(['bash', str(HERE/'install_oci.sh'),str(template),str(deployment),'0'],
stdout=subprocess.PIPE,stderr=subprocess.STDOUT,env=environment)
result = None
try:
result = relay_child_output(process.stdout)
if process.wait() or result is None:
raise ServiceFailed(f"{translate('Could not create the service:')} {service['name']}")
finally:
if process.poll() is None:
process.terminate()
process.wait(timeout=30)
return result
def relay_child_output(stream):
"""Copy the child output to the terminal as it arrives; return its PROXMENUX_RESULT."""
output = sys.stdout.buffer
sys.stdout.flush()
pending = b''
line_start = True
result = None
def parse(line):
return json.loads(base64.b64decode(line[len(RESULT_MARKER):].strip()))
while True:
chunk = os.read(stream.fileno(), 4096)
if not chunk:
break
pending += chunk
while pending:
if line_start and pending.startswith(RESULT_MARKER):
end = pending.find(b'\n')
if end < 0:
break
result = parse(pending[:end])
pending = pending[end+1:]
continue
if line_start and RESULT_MARKER.startswith(pending):
break
cuts = [index for index in (pending.find(b'\n'), pending.find(b'\r')) if index >= 0]
if cuts:
cut = min(cuts)+1
output.write(pending[:cut])
pending = pending[cut:]
line_start = True
else:
output.write(pending)
pending = b''
line_start = False
output.flush()
if pending:
if line_start and pending.startswith(RESULT_MARKER):
result = parse(pending)
else:
output.write(pending)
output.flush()
return result
def assign_health_address(service):
"""Bind a generated dependency probe to its allocated private address."""
check = service['healthcheck']
if check['type'] == 'http':
endpoint = check['endpoint']
check['url'] = f"{endpoint['scheme']}://{service['ip']}:{endpoint['port']}{endpoint['path']}"
elif check['type'] == 'tcp':
check['host'] = service['ip']
def public_bindings(services):
"""Validate compiler-owned targets before any host/container mutation."""
names = {service['name']: service for service in services}
result = []
failure = translate('Invalid stack contract')
for service in services:
bindings = service.get('public_environment_bindings', {})
if not isinstance(bindings, dict):
raise RuntimeError(failure)
environment = service['deployment'].get('environment', [])
for key, binding in bindings.items():
if (not isinstance(key, str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', key)
or not isinstance(binding, dict)
or set(binding) != {'target_service', 'container_port', 'value'}
or not isinstance(binding.get('target_service'), str)):
raise RuntimeError(failure)
target = names.get(binding['target_service'])
value = binding.get('value')
if (target is None or not (target.get('frontend') or target.get('main'))
or type(binding.get('container_port')) is not int
or not 0 < binding['container_port'] < 65536
or not isinstance(value, str) or value.count('@STACK_LAN_IP@') != 1
or any(char in value for char in '\r\n')
or sum(entry.get('name') == key for entry in environment) != 1):
raise RuntimeError(failure)
result.append((service, key, binding, target))
if any(target.get('frontend_ipv4') == 'dhcp' for _, _, _, target in result):
raise RuntimeError(translate('Automatic public URLs require static frontend addresses or explicit URLs'))
return result
def _set_public_value(service, key, value):
for entry in service['deployment']['environment']:
if entry['name'] == key:
entry['value'] = value
return
raise RuntimeError(translate('Invalid stack contract'))
def prepare_public_environment(services):
"""Known static destinations are final before any application's first start."""
for service, key, binding, target in public_bindings(services):
address = target.get('frontend_ipv4')
try:
address = ipaddress.ip_interface(address).ip
if address.version != 4:
raise ValueError()
except (ValueError, TypeError) as error:
raise RuntimeError(translate('Invalid stack contract')) from error
_set_public_value(service, key, binding['value'].replace('@STACK_LAN_IP@', str(address)))
def install_public_routes(services, primary_id, bridge):
"""Private members can reach only this stack's own static frontend IPs."""
frontends = [service for service in services if service.get('main') or service.get('frontend')]
targets = {}
for service in frontends:
address = service.get('frontend_ipv4')
if not address or address == 'dhcp':
continue
destination = str(ipaddress.IPv4Interface(address).ip)
if destination in targets:
raise RuntimeError(translate('Duplicate stack frontend address'))
targets[destination] = service
if not targets:
return
records = {service['vmid']: oci_instances.read(oci_instances.ROOT, service['vmid']) for service in services}
stack_id = records[primary_id]['installation_id']
for service in services:
if service.get('main') or service.get('frontend'):
continue
vmid = service['vmid']
identity = records[vmid]['installation_id']
spec = {'schema': 1, 'stack_id': stack_id, 'primary_vmid': primary_id,
'vmid': vmid, 'installation_id': identity, 'private_bridge': bridge,
'private_address': service['ip'] + '/24', 'routes': [
{'destination': destination, 'via': target['ip'], 'target_vmid': target['vmid'],
'target_installation_id': records[target['vmid']]['installation_id']}
for destination, target in sorted(targets.items())]}
plan = service['deployment']
plan['stack_public_routes'] = spec
oci_stack_public_routes.validate(plan, vmid, identity)
config = Path(f'/etc/pve/lxc/{vmid}.conf')
text = config.read_text()
# Never replace another include or accept a stale route contract.
oci_stack_public_routes.check(text, {}, vmid, require_host_assets=False)
line = oci_stack_public_routes.install(plan, vmid, identity)
config.write_text(text.rstrip('\n') + '\n' + line + '\n')
oci_stack_public_routes.check(config.read_text(), plan, vmid, identity)
def bind_completed_jobs(services):
"""All members and private aliases exist before a job can be authorized."""
from oci_compose_jobs import job_spec_from_record
for service in services:
check = service['healthcheck']
if check.get('type') != 'completed':
continue
vmid = int(service['vmid'])
run('pct', 'set', vmid, '--onboot', '0')
service['deployment']['onboot'] = False
record = oci_instances.read(oci_instances.ROOT, vmid)
check['job'] = job_spec_from_record(record, run('pct', 'config', vmid),
int(check.get('timeout_seconds', 300)))
def lan_access_urls(services, subnet, strict=True):
urls = []
for service in services:
endpoint = service['healthcheck'].get('endpoint')
if not endpoint or not (service['main'] or service.get('frontend')):
continue
try:
addresses = run('lxc-info','-n',service['vmid'],'-iH').splitlines()
candidates = []
for value in addresses:
try:
address = ipaddress.ip_address(value.strip())
except ValueError:
continue
if address.version==4 and address not in subnet and not (
address.is_loopback or address.is_link_local or address.is_unspecified or address.is_multicast):
candidates.append(str(address))
if not candidates:
raise RuntimeError(f"{translate('No LAN address was obtained for the service:')} {service['name']}")
urls.append({'label':service['name'],
'url':f"{endpoint['scheme']}://{candidates[0]}:{endpoint['port']}{endpoint['path']}"})
except Exception:
if strict:
raise
return urls
def wait_main_web(service, subnet):
"""Check the advertised frontend, preserving private IPs for backend aliases.
A service may legitimately refuse HTTP requests whose Host is its private
stack address. The endpoint shown to the user is the readiness contract.
"""
deadline = time.monotonic() + min(120, int(service['healthcheck']['timeout_seconds']))
url = None
while time.monotonic() < deadline:
try:
found = lan_access_urls([service], subnet)
if found:
url = found[0]['url']
break
except (RuntimeError, OSError, subprocess.SubprocessError):
pass
time.sleep(1)
if url is None:
raise RuntimeError(translate('No LAN address was obtained'))
service['healthcheck']['url'] = url
wait_web(service, url)
return url
def _last_int_env(service, names, default=0):
"""Use the last explicit environment override, falling back only if absent.
Invalid explicit values retain the installer's existing zero fallback.
"""
value = None
found = False
for entry in service['deployment'].get('environment', []):
if entry.get('name') in names:
found = True
value = entry.get('value')
if not found:
return default
try:
parsed = int(value)
except (TypeError, ValueError):
return 0
return parsed if parsed >= 0 else 0
def runtime_volume_owner(service, root=None):
"""Container IDs from the OCI import, with image USER as a fallback.
Proxmox has already resolved a named USER and Compose user override into
lxc.init.uid/gid. No ownership is changed here. The mounted image account
files are only needed on versions that omit one of those numeric fields.
"""
vmid = service.get('vmid')
config = {}
if type(vmid) is int and vmid >= 100:
from oci_installation_state import parse_config
config = parse_config(run('pct', 'config', vmid).encode())
resolved = {}
for name, key in (('uid', 'lxc.init.uid'), ('gid', 'lxc.init.gid')):
value = config.get(key)
if value is not None:
if not re.fullmatch(r'[0-9]+', value):
raise RuntimeError(translate('The imported OCI user or group is not numeric'))
resolved[name] = int(value)
if len(resolved) == 2:
return resolved['uid'], resolved['gid']
image = (service.get('observed') or {}).get('image') or {}
if not image and type(vmid) is int and vmid >= 100:
try:
record = oci_instances.read(oci_instances.ROOT, vmid)
image = (record.get('observed') or {}).get('image') or {}
except (OSError, ValueError, KeyError):
pass
declared = str((image.get('defaults') or {}).get('User') or '')
user, separator, group = declared.partition(':')
accounts, groups = {}, {}
if root is not None:
root = Path(root)
for filename, target in (('passwd', accounts), ('group', groups)):
path = root / 'etc' / filename
if path.resolve().is_relative_to(root.resolve()) and path.is_file() and not path.is_symlink():
for line in path.read_text(errors='replace').splitlines():
fields = line.split(':')
if len(fields) >= (4 if filename == 'passwd' else 3) and fields[2].isdigit():
if filename == 'passwd' and fields[3].isdigit():
target[fields[0]] = (int(fields[2]), int(fields[3]))
elif filename == 'group':
target[fields[0]] = int(fields[2])
if not user:
uid, gid = 0, 0
elif user.isdigit():
uid = int(user)
gid = next((value[1] for value in accounts.values() if value[0] == uid), 0)
elif user in accounts:
uid, gid = accounts[user]
elif 'uid' in resolved:
uid = resolved['uid']
gid = next((value[1] for value in accounts.values() if value[0] == uid), 0)
else:
raise RuntimeError(translate('The imported OCI user or group is not numeric'))
if separator and group:
if group.isdigit():
gid = int(group)
elif group in groups:
gid = groups[group]
elif 'gid' not in resolved:
raise RuntimeError(translate('The imported OCI user or group is not numeric'))
return resolved.get('uid', uid), resolved.get('gid', gid)
def resolve_volume_owner(service, owner_strategy, imported_owner=None):
"""Resolve fresh volume ownership without changing existing host directories.
Mapped root remains fixed. Application ownership starts with the actual
OCI import user, then honors explicit PUID/PGID aliases and the recipe's
declared volume_owner override. All values are container IDs until the
standard unprivileged offset is applied exactly once.
"""
unprivileged = bool(service['deployment']['security']['unprivileged'])
if owner_strategy == 'mapped-root':
return (100000, 100000) if unprivileged else (0, 0)
if owner_strategy != 'mapped-application-user':
raise RuntimeError(f"{translate('Unsupported owner strategy:')} {owner_strategy}")
imported_uid, imported_gid = (runtime_volume_owner(service) if imported_owner is None else imported_owner)
puid = _last_int_env(service, ('PUID', 'USER_ID', 'UID'), imported_uid)
pgid = _last_int_env(service, ('PGID', 'GROUP_ID', 'GID'), imported_gid)
volume_owner = service.get('template', {}).get('proxmox', {}) \
.get('installer_profile', {}).get('volume_owner') or {}
if 'uid' in volume_owner:
try:
parsed = int(volume_owner['uid'])
puid = parsed if parsed >= 0 else 0
except (TypeError, ValueError):
puid = 0
if 'gid' in volume_owner:
try:
parsed = int(volume_owner['gid'])
pgid = parsed if parsed >= 0 else 0
except (TypeError, ValueError):
pgid = 0
offset = 100000 if unprivileged else 0
return (puid + offset, pgid + offset)
def apply_volume_preparation(service, mount, target, imported_owner=None):
"""Declarative volume_preparations for one already-attached (but
still empty/freshly-mounted) mount, mirroring install_oci.sh's
apply_installer_profile() semantics (only_when_mount_type /
remove_lost_found / owner_strategy) for the generic-multi-lxc-stack
path, where install_oci.sh's own volume_preparations loop never
sees the real mount (it is invoked per-service with an empty
DEPLOYMENT_FILE.mounts — see create_service()). container_path in
volume_preparations is always already the final, normalized path by
the time it reaches this service's own template (service_template()
in stack.py performs that normalization before this function ever
runs).
remove_lost_found (if declared) is applied immediately, since it
must run before the image seed is copied back onto this mount.
owner_strategy is only RESOLVED here, not applied: attach_mounts()
must chown with this returned owner AFTER it copies the image seed
back and AFTER its own fallback `os.chown(target, *owner[:2])` —
otherwise that fallback (which always runs, to preserve the image's
ownership for mounts with no declared preparation) would silently
overwrite a declared owner_strategy's result. Returns (applied,
owner) where owner is an (uid, gid) tuple when applied is True, and
None when applied is False (no preparation declared for this mount,
or only_when_mount_type did not match the real mount type) — in
that case attach_mounts() must fall back to its own unconditional
lost+found removal and image-owner chown.
"""
preparations = service.get('template', {}).get('proxmox', {}) \
.get('installer_profile', {}).get('volume_preparations', [])
match = next((p for p in preparations
if p.get('container_path') == mount['container_path']), None)
if match is None:
return False, None
only_when_mount_type = match.get('only_when_mount_type')
if only_when_mount_type and mount['type'] != only_when_mount_type:
log(LOG, f"Skipping the preparation of {mount['container_path']} "
f"for mount type {mount['type']}")
return False, None
if match.get('remove_lost_found'):
lost = target / 'lost+found'
if lost.is_dir() and not lost.is_symlink():
lost.rmdir()
owner_strategy = match.get('owner_strategy')
owner = resolve_volume_owner(service, owner_strategy, imported_owner)
log(LOG, f"Volume prepared before the first start: {mount['container_path']}")
return True, owner
def attach_mounts(service, temporary):
"""Populate new managed volumes from the image, preserving its ownership."""
vmid = service['vmid']
root = Path(f'/var/lib/lxc/{vmid}/rootfs')
imported_owner = None
for index, mount in enumerate(service['deployment']['mounts']):
target = root / mount['container_path'].lstrip('/')
seed = temporary / f'seed-{vmid}-{index}'
seed.mkdir()
run('pct','mount',vmid)
try:
if not target.resolve().is_relative_to(root.resolve()) or target.is_symlink():
raise RuntimeError(translate('Unsafe volume path'))
if imported_owner is None:
imported_owner = runtime_volume_owner(service, root)
if target.is_dir():
stat = target.stat()
owner = (stat.st_uid,stat.st_gid,stat.st_mode & 0o777)
if mount['type']=='managed-volume':
run('cp','-a',str(target)+'/.',str(seed))
else:
owner = (100000,100000,0o755)
if mount['type'] == 'managed-volume':
from oci_image_mountpoints import normalize
try:
normalize(root, [mount['container_path']])
except (OSError, ValueError) as error:
# Nothing was moved; the image keeps its files as before.
print(f'Image defaults left in place for {mount["container_path"]}: {error}', file=sys.stderr)
finally:
run('pct','unmount',vmid)
if mount['type']=='managed-volume':
value=f"{mount['source']}:{mount['size_gb']},mp={mount['container_path']},backup=1"
else:
source=Path(mount['source'])
if not source.is_absolute() or ',' in str(source) or '\n' in str(source):
raise RuntimeError(translate('Invalid shared path'))
if not source.exists():
shared_owner = mount.get('host_owner')
if shared_owner is not None:
if (not isinstance(shared_owner, list) or len(shared_owner) != 2
or any(type(value) is not int or not 0 <= value <= 2**32-2 for value in shared_owner)):
raise RuntimeError(translate('Invalid shared path'))
owner = (*shared_owner, owner[2])
source.mkdir(parents=True)
os.chown(source,*owner[:2])
source.chmod(owner[2])
if not source.is_dir():
raise RuntimeError(translate('The shared destination is not a directory'))
value=f"{source},mp={mount['container_path']},backup=0"
run('pct','set',vmid,f'--mp{index}',value)
if mount['type']=='managed-volume':
run('pct','mount',vmid)
try:
applied, declared_owner = apply_volume_preparation(service, mount, target, imported_owner)
if not applied:
lost=target/'lost+found'
if lost.is_dir() and not lost.is_symlink():
lost.rmdir()
run('cp','-a',str(seed)+'/.',str(target))
# The image-owner chown always runs first, to preserve
# the seeded content's ownership for a mount with no
# declared preparation; a declared owner_strategy is
# applied last so it is the mount's final ownership,
# not silently overwritten by this fallback.
os.chown(target,*owner[:2])
target.chmod(owner[2])
if applied:
os.chown(target, *declared_owner)
finally:
run('pct','unmount',vmid)
if mount.get('read_only'):
config = run('pct', 'config', vmid)
current = next(line.split(': ', 1)[1] for line in config.splitlines()
if line.startswith(f'mp{index}: '))
run('pct', 'set', vmid, f'--mp{index}', current + ',ro=1')
oci_nested_mounts.follow(vmid)
def wait_web(primary, url):
deadline = time.monotonic()+primary['healthcheck']['timeout_seconds']
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
while True:
if run('pct','status',primary['vmid']).strip()!='status: running':
raise RuntimeError(f"{translate('The container stopped:')} {primary['name']} (CT {primary['vmid']})")
try:
with opener.open(url,timeout=4) as response:
if response.status<400:
check = primary['healthcheck']
if check.get('type') == 'exec':
remaining = max(1, deadline-time.monotonic())
process = subprocess.run(['pct', 'exec', str(primary['vmid']), '--', *check['argv']],
capture_output=True, timeout=min(30, remaining), check=False)
if process.returncode != 0:
raise RuntimeError('Health check has not succeeded yet')
return
except Exception:
pass
if time.monotonic()>=deadline:
raise RuntimeError(f"{translate('The application did not pass its HTTP check:')} {primary['name']}")
time.sleep(3)
def qbittorrent_password_hash(password):
salt = os.urandom(16)
digest = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000, 64)
return base64.b64encode(salt).decode()+':'+base64.b64encode(digest).decode()
def seed_qbittorrent(service):
"""Seed the image's official defaults in its new persistent config volume."""
vmid = service['vmid']
root = Path(f'/var/lib/lxc/{vmid}/rootfs')
run('pct','mount',vmid)
try:
directory = root/'config/qBittorrent'
defaults = root/'defaults/qBittorrent.conf'
target = directory/'qBittorrent.conf'
for path in (directory, target, defaults):
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise RuntimeError(translate('Unsafe qBittorrent configuration path'))
if target.exists():
raise RuntimeError(translate('qBittorrent already has a configuration; it is not overwritten'))
config = configparser.ConfigParser(interpolation=None)
config.optionxform = str
config.read_string(defaults.read_text())
if not config.has_section('Preferences'):
raise RuntimeError(translate('Unrecognized qBittorrent configuration format'))
config['Preferences'][r'WebUI\Username'] = service['setup_credentials']['username']
config['Preferences'][r'WebUI\Password_PBKDF2'] = '"@ByteArray('+qbittorrent_password_hash(service['setup_credentials']['password'])+')"'
directory.mkdir(exist_ok=True,mode=0o700)
owner = 101000 if service['deployment']['security']['unprivileged'] else 1000
os.chown(directory,owner,owner)
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd,'w') as output:
config.write(output,space_around_delimiters=False)
os.chown(target,owner,owner)
finally:
run('pct','unmount',vmid)
def same_download_path(actual, expected):
return isinstance(actual,str) and actual.startswith('/') and actual.rstrip('/')==expected.rstrip('/')
def configure_qbittorrent(service, selected):
port = service['healthcheck']['endpoint']['port']
base = f"http://{service['ip']}:{port}"
cookies = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}),urllib.request.HTTPCookieProcessor(cookies))
def api(path, values=None, with_status=False):
request = urllib.request.Request(base+'/api/v2/'+path,
data=urllib.parse.urlencode(values).encode() if values is not None else None,
headers={'Referer':base+'/'})
with opener.open(request,timeout=30) as response:
body = response.read()
return (getattr(response,'status',200),body) if with_status else body
status, body = api('auth/login',service['setup_credentials'],with_status=True)
# 5.2 uses HTTP 204 and a port-scoped cookie; older releases return "Ok.".
legacy = status==200 and body.strip()==b'Ok.'
modern = status==204 and not body.strip()
cookie_name = f'QBT_SID_{port}' if modern else 'SID'
if not (legacy or modern) or not any(c.name==cookie_name and c.value for c in cookies):
raise RuntimeError(translate('qBittorrent: invalid login response or missing session cookie'))
try:
probe = json.loads(api('app/preferences'))
if not isinstance(probe,dict) or not isinstance(probe.get('save_path'),str):
raise RuntimeError(translate('qBittorrent: authenticated access to the preferences could not be verified'))
preferences = {'save_path':'/data/downloads/','temp_path':'/data/downloads/incomplete/', 'temp_path_enabled':True}
api('app/setPreferences',{'json':json.dumps(preferences)})
actual = json.loads(api('app/preferences'))
if (not all(same_download_path(actual.get(k),preferences[k]) for k in ('save_path','temp_path'))
or actual.get('temp_path_enabled') is not True):
raise RuntimeError(translate('qBittorrent did not apply the download paths'))
categories = json.loads(api('torrents/categories'))
for app,category in [('sonarr','tv'),('radarr','movies')]:
if app not in selected:
continue
path = '/data/downloads/'+category
action = 'editCategory' if category in categories else 'createCategory'
api('torrents/'+action,{'category':category,'savePath':path})
if not same_download_path(json.loads(api('torrents/categories')).get(category,{}).get('savePath'),path):
raise RuntimeError(f"{translate('qBittorrent did not apply the category:')} {category}")
finally:
api('auth/logout',{})
def configure_arr(services):
"""Use generated API keys and upstream schemas, without changing image files."""
apps = {s['name']:s for s in services}
keys = {}
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
for name, service in apps.items():
if name == 'qbittorrent':
msg_info(translate('Configuring qBittorrent...'))
configure_qbittorrent(service,apps)
msg_ok(translate('qBittorrent configured'))
continue
if name not in ('prowlarr','sonarr','radarr','lidarr'):
continue
config = run('pct','exec',service['vmid'],'--','cat','/config/config.xml')
keys[name] = ET.fromstring(config).findtext('ApiKey')
if not keys[name]:
raise RuntimeError(f"{name}: {translate('the API key was not generated on the first start')}")
def api(name, path, payload=None):
service = apps[name]
port = service['healthcheck']['endpoint']['port']
request = urllib.request.Request(f"http://{service['ip']}:{port}{path}",
data=json.dumps(payload).encode() if payload is not None else None,
headers={'X-Api-Key':keys[name],'Content-Type':'application/json'})
with opener.open(request, timeout=30) as response:
body = response.read()
return json.loads(body) if body else None
for name, folder in (('sonarr','series'),('radarr','movies')):
if name not in apps:
continue
text = arr_texts(name)
msg_info(text['root_info'])
api(name,'/api/v3/system/status')
root = '/data/media/'+folder
if not any(x.get('path') == root for x in api(name,'/api/v3/rootfolder')):
api(name,'/api/v3/rootfolder',{'path':root})
msg_ok(f"{text['root_ok']}: {root}")
if 'qbittorrent' in apps:
msg_info(text['client_info'])
qbit = apps['qbittorrent']
schema = next((x for x in api(name,'/api/v3/downloadclient/schema')
if x.get('implementation')=='QBittorrent'),None)
if schema is None:
raise RuntimeError(f"{name}: {translate('the qBittorrent schema is not available')}")
schema.pop('id',None)
schema.update(name='qBittorrent',enable=True,priority=1)
category_key = 'tvCategory' if name=='sonarr' else 'movieCategory'
values = dict(qbit['setup_credentials'],host=qbit['ip'],port=qbit['healthcheck']['endpoint']['port'],
useSsl=False,urlBase='',apiKey='')
values[category_key] = 'tv' if name=='sonarr' else 'movies'
if not {'host','port','username','password',category_key}.issubset({f['name'] for f in schema['fields']}):
raise RuntimeError(f"{name}: {translate('incompatible qBittorrent schema')}")
for field in schema['fields']:
if field['name'] in values:
field['value'] = values[field['name']]
api(name,'/api/v3/downloadclient/test',schema)
api(name,'/api/v3/downloadclient',schema)
msg_ok(text['client_ok'])
if 'prowlarr' not in apps:
continue
msg_info(text['prowlarr_info'])
if any(x.get('name') == name for x in api('prowlarr','/api/v1/applications')):
msg_ok(text['prowlarr_ok'])
continue
schema = next((x for x in api('prowlarr','/api/v1/applications/schema')
if x.get('implementation','').lower()==name),None)
if schema is None:
raise RuntimeError(f"{translate('Prowlarr does not offer the application schema:')} {name}")
schema.pop('id',None)
schema.update(name=name,syncLevel='fullSync')
values = {'apiKey':keys[name],
'baseUrl':f"http://{apps[name]['ip']}:{apps[name]['healthcheck']['endpoint']['port']}",
'prowlarrUrl':f"http://{apps['prowlarr']['ip']}:{apps['prowlarr']['healthcheck']['endpoint']['port']}"}
for field in schema['fields']:
if field['name'] in values:
field['value'] = values[field['name']]
api('prowlarr','/api/v1/applications',schema)
msg_ok(text['prowlarr_ok'])
if 'qbittorrent' not in apps:
msg_info2(translate('The download client still needs to be configured.'))
msg_info2(translate('Indexers and quality profiles still need to be configured.'))
return keys
def arr_texts(name):
"""Visible steps of the Sonarr/Radarr wiring, one literal per application."""
if name == 'sonarr':
return {'root_info': translate('Configuring the Sonarr root folder...'),
'root_ok': translate('Sonarr root folder configured'),
'client_info': translate('Connecting Sonarr to qBittorrent...'),
'client_ok': translate('Sonarr connected to qBittorrent'),
'prowlarr_info': translate('Adding Sonarr to Prowlarr...'),
'prowlarr_ok': translate('Sonarr added to Prowlarr')}
return {'root_info': translate('Configuring the Radarr root folder...'),
'root_ok': translate('Radarr root folder configured'),
'client_info': translate('Connecting Radarr to qBittorrent...'),
'client_ok': translate('Radarr connected to qBittorrent'),
'prowlarr_info': translate('Adding Radarr to Prowlarr...'),
'prowlarr_ok': translate('Radarr added to Prowlarr')}
def prepare_suite_config(service):
"""Prepare only newly allocated configuration volumes, never image binaries."""
if service['name'] not in ('sabnzbd','seerr','unpackerr'):
return
vmid = service['vmid']
root = Path(f'/var/lib/lxc/{vmid}/rootfs')
path = root/('app/config' if service['name']=='seerr' else 'config')
run('pct','mount',vmid)
try:
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise RuntimeError(translate('Unsafe private configuration path'))
if service.get('config_owner') is not None:
owner = service['config_owner'] + (100000 if service['deployment']['security']['unprivileged'] else 0)
os.chown(path,owner,owner)
if service['name']=='sabnzbd':
target = path/'sabnzbd.ini'
fd = os.open(target,os.O_WRONLY | os.O_CREAT | os.O_EXCL,0o600)
with os.fdopen(fd,'w') as output:
output.write('[misc]\ndownload_dir = /data/downloads/usenet-incomplete\ncomplete_dir = /data/downloads/usenet\n')
os.chown(target,101000,101000)
finally:
run('pct','unmount',vmid)
def configure_unpackerr(services, keys):
worker = next((s for s in services if s['name']=='unpackerr'),None)
if worker is None:
return
msg_info(translate('Configuring Unpackerr...'))
variables = {}
for service in services:
name = service['name']
if name not in ('sonarr','radarr','lidarr'):
continue
prefix = 'UN_'+name.upper()+'_0_'
variables[prefix+'URL'] = f"http://{service['ip']}:{service['healthcheck']['endpoint']['port']}"
variables[prefix+'API_KEY'] = keys[name]
variables[prefix+'PATHS_0'] = '/data/downloads'
variables[prefix+'DELETE_ORIG'] = 'false'
config = Path(f"/etc/pve/lxc/{worker['vmid']}.conf")
content = config.read_text()
for key,value in variables.items():
if '\n' in value or '\r' in value:
raise RuntimeError(translate('Invalid Unpackerr variable'))
content = re.sub(r'^lxc\.environment\.runtime: '+re.escape(key)+r'=.*\n','',content,flags=re.M)
content += 'lxc.environment.runtime: '+key+'='+value+'\n'
config.write_text(content)
run('pct','start',worker['vmid'],capture=False)
for _ in range(3):
time.sleep(1)
if run('pct','status',worker['vmid']).strip()!='status: running':
raise RuntimeError(translate('Unpackerr stopped during its first start'))
msg_ok(translate('Unpackerr configured'))
def finish_independent_suite(services, subnet):
log(LOG, 'First start to configure the applications; each container is independent.')
for service in services:
if service.get('deferred_setup'):
continue
msg_info(f"{translate('Starting the service:')} {service['name']}")
run('pct','start',service['vmid'],capture=False,timeout=600)
if service['healthcheck']['type']=='http':
wait_web(service,service['healthcheck']['url'])
msg_ok(f"{translate('Service ready:')} {service['name']}")
keys = configure_arr(services)
configure_unpackerr(services,keys)
result = {'suite_arr':True,'lifecycle_mode':'independent',
'stack_vmids':{s['name']:s['vmid'] for s in services},
'urls':lan_access_urls(services,subnet),'credentials':[]}
for service in services:
if service['name']=='qbittorrent':
result['credentials'].append(dict(service['setup_credentials'],label='qBittorrent',change_required=False))
return result
def check_selinux_labels(template, enforcing_path=Path('/sys/fs/selinux/enforce')):
if (template.get('proxmox', {}).get('custom_compose_selinux_labels')
and enforcing_path.is_file() and enforcing_path.read_text().strip() == '1'):
raise RuntimeError(translate('SELinux volume labels cannot be ignored while SELinux is enforcing.'))
def main():
deployment = json.loads(Path(sys.argv[2]).read_text())
if deployment.get('deployment_kind') != 'generic-multi-lxc-stack':
raise RuntimeError(translate('Invalid stack contract'))
if len(sys.argv)>3 and sys.argv[3]=='1':
msg_info2(f"{translate('Containers:')} {len(deployment['services'])}")
msg_info2(translate('Startup: independent, without hookscript') if deployment.get('suite_arr')
else translate('Startup: coordinated by the stack startup hook'))
msg_ok(translate('Dry run completed; no changes were made.'))
return
if os.geteuid()!=0:
raise RuntimeError(translate('The installer must run as root on Proxmox VE'))
check_selinux_labels(json.loads(Path(sys.argv[1]).read_text()))
init_log(deployment.get('stack_name') or 'stack')
services = copy.deepcopy(deployment['services'])
independent = bool(deployment.get('suite_arr'))
primary = None if independent else next(s for s in services if s['main'])
prepare_public_environment(services)
created = []
bridge_created = False
lifecycle = None
node = socket.gethostname()
# Serialize this installer's allocation through creation, and let pct enforce ownership.
msg_info(translate('Reserving a private network...'))
with open('/run/lock/proxmenux-private-network.lock','w') as lock, tempfile.TemporaryDirectory(prefix='proxmenux-stack-') as tmp:
fcntl.flock(lock,fcntl.LOCK_EX)
base = deployment.get('base_vmid') or int(run('pvesh','get','/cluster/nextid').strip())
while any(exists(base+s['offset']) for s in services):
if deployment.get('base_vmid'):
raise RuntimeError(translate('The requested VMID block is already in use'))
base += 1
selection = allocate_network(deployment,existing_bridges(),existing_networks())
if selection is None:
raise RuntimeError(translate('The private network must be assigned automatically'))
bridge, subnet = selection
deployment['network'].update(private_bridge=bridge, private_subnet=str(subnet),
private_host_address=str(subnet.network_address+1)+'/24')
primary_id = base
aliases = []
for s in services:
s['vmid'] = base+s['offset']
s['ip'] = str(subnet.network_address+30+s['offset'])
for alias in s['aliases']:
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,252}',alias):
raise RuntimeError(translate('Invalid service alias'))
aliases.append({'hostname':alias,'address':s['ip']})
if not independent:
lifecycle = Path(f'/etc/pve/priv/proxmenux-stack-{primary_id}.json')
retire_stale_contract(lifecycle,primary_id)
try:
log(LOG, f"Stack {deployment['stack_name']}: CT {base}-{base+len(services)-1}; network {subnet} on {bridge}")
run('pvesh','create',f'/nodes/{node}/network','--iface',bridge,'--type','bridge','--autostart','1','--cidr',str(subnet.network_address+1)+'/24')
bridge_created = True
run('ip','link','add','name',bridge,'type','bridge')
run('ip','address','add',str(subnet.network_address+1)+'/24','dev',bridge)
run('ip','link','set',bridge,'up')
msg_ok(f"{translate('Private network:')} {bridge} ({subnet})")
if deployment.get('suite_arr') and deployment.get('shared_media'):
shared = Path(deployment['shared_media'])
for folder in [shared, shared/'downloads', shared/'downloads/incomplete', shared/'downloads/tv',
shared/'downloads/movies',shared/'downloads/music',shared/'downloads/usenet',shared/'downloads/usenet-incomplete',
shared/'media', shared/'media/series', shared/'media/movies',shared/'media/music']:
if not folder.exists():
folder.mkdir(parents=True)
os.chown(folder,101000,101000)
folder.chmod(0o775)
results = {}
for order,s in enumerate(services,1):
plan = s['deployment']
s['public_environment'] = {e['name']:e['value'] for e in plan['environment'] if '@STACK_LAN_IP@' in e['value'] and e['name'] not in s.get('public_environment_bindings', {})}
for e in plan['environment']:
e['value'] = e['value'].replace('@STACK_LAN_IP@',s['ip'])
plan['vmid'] = s['vmid']
plan['extra_hosts'] = aliases
plan['network'].update(bridge=bridge,ipv4=s['ip']+'/24',gateway=None)
if exists(s['vmid']):
raise RuntimeError(f"{translate('The VMID was taken during the installation:')} {s['vmid']}")
msg_info2(f"{translate('Service:')} {s['name']}")
with tempfile.TemporaryDirectory(dir=tmp) as service_tmp:
results[s['name']] = create_service(s,Path(service_tmp))
created.append(s['vmid'])
if s['deployment']['mounts']:
msg_info(translate('Attaching the volumes...'))
attach_mounts(s,Path(tmp))
if s['deployment']['mounts']:
msg_ok(translate('Volumes attached'))
if deployment.get('suite_arr'):
prepare_suite_config(s)
if deployment.get('suite_arr') and s['name']=='qbittorrent':
seed_qbittorrent(s)
if not independent:
run('pct','set',s['vmid'],'--startup',f'order={order*10},up=5,down=30')
if s['main'] or s.get('frontend'):
address = access_address(s.get('frontend_ipv4') or 'dhcp', deployment['network'].get('frontend_gateway'))
run('pct','set',s['vmid'],'--net1',f"name=eth1,bridge={deployment['network']['frontend_bridge']},{address},host-managed=1,firewall=1,type=veth")
assign_health_address(s)
# Existing CT/network configurations now reserve every allocated ID and subnet.
# Never retain the allocation lock while startup/hooks wait for another registry operation.
fcntl.flock(lock,fcntl.LOCK_UN)
if independent:
result = finish_independent_suite(services,subnet)
persist_instances(deployment, services)
refresh_notes(services)
result.update(completion_notes=list(deployment.get('completion_notes',[])), log=LOG)
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
return
install_public_routes(services, primary_id, bridge)
bind_completed_jobs(services)
hook_spec = Path(tmp)/'lifecycle.json'
write_json(hook_spec,{'schema':1,'stack':deployment['stack_name'],'dependencies':[
{'vmid':s['vmid'],'label':s['name'],'healthcheck':s['healthcheck']} for s in services if not s['main'] and not s.get('deferred_setup')]})
if len(services) > 1:
msg_info(translate('Installing the stack startup hook...'))
run('bash',HERE/'stack_dependency_hook.sh','--install',primary_id,hook_spec,capture=False)
msg_ok(translate('Stack startup hook installed'))
msg_info(translate('Starting the main container and its dependencies...') if len(services) > 1
else translate('Starting the container...'))
run('pct','start',primary_id,capture=False,timeout=600)
msg_ok(f"{translate('Container started')}: CT {primary_id} ({primary['name']})")
endpoint = primary['healthcheck']['endpoint']
msg_info(translate('Waiting for the application to respond...'))
public_url = wait_main_web(primary, subnet)
url = public_url
lan = urllib.parse.urlsplit(public_url).hostname
msg_ok(f"{translate('Application responding:')} {public_url}")
if primary['public_environment']:
msg_info(translate('Applying the LAN address to the application URLs...'))
run('pct','shutdown',primary_id,'--timeout','180',timeout=200)
config=Path(f'/etc/pve/lxc/{primary_id}.conf')
text=config.read_text()
for key,value in primary['public_environment'].items():
if '\n' in value or '\r' in value:
raise RuntimeError(translate('Multi-line variables are not supported'))
text=re.sub(r'^lxc\.environment\.runtime: '+re.escape(key)+r'=.*\n','',text,flags=re.M)
text+='lxc.environment.runtime: '+key+'='+value.replace('@STACK_LAN_IP@',lan)+'\n'
config.write_text(text)
run('pct','start',primary_id,capture=False,timeout=600)
wait_web(primary,url)
msg_ok(translate('LAN address applied to the application URLs'))
result=results[primary['name']]
persist_instances(deployment, services, primary_id)
refresh_notes(services)
# The credentials of the main service come from its own installation.
result.update(vmid=primary_id,ip=lan,stack_vmids={s['name']:s['vmid'] for s in services},
urls=(lan_access_urls(services, subnet) if sum(bool(s['main'] or s.get('frontend')) for s in services) > 1
else [{'label':'Web UI','url':public_url}]),
credentials=result.get('credentials') or [])
result.update(completion_notes=[note.replace('{main_vmid}',str(primary_id))
for note in deployment.get('completion_notes', [])], log=LOG)
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
except BaseException as error:
# Keep volumes and configs for diagnosis; never delete a database on a late startup failure.
stop_spinner()
if isinstance(error, ServiceFailed):
report_error(str(error), tail=False)
elif isinstance(error, SystemExit):
if isinstance(error.code, str):
report_error(error.code)
else:
report_error(str(error) or type(error).__name__)
if created:
msg_warn(f"{translate('Installation incomplete. These containers and their data are kept:')} "
f"{', '.join('CT '+str(vmid) for vmid in created)}")
accesses = lan_access_urls([s for s in services if s.get('vmid') in created],subnet,strict=False)
if accesses:
msg_warn(translate('LAN access to the kept containers (stack configuration incomplete):'))
for access in accesses:
msg_info2(access['label']+': '+access['url'])
if not created and bridge_created:
with open(LOG or os.devnull, 'a') as output:
subprocess.run(['ip','link','delete',bridge,'type','bridge'],check=False,stdout=output,stderr=output)
subprocess.run(['pvesh','delete',f'/nodes/{node}/network/{bridge}'],check=False,stdout=output,stderr=output)
raise
if __name__=='__main__':
try:
main()
except (Exception, KeyboardInterrupt) as error:
if not ERROR_REPORTED:
report_error(str(error) or type(error).__name__)
sys.exit(1)
except SystemExit as error:
if isinstance(error.code, str):
if not ERROR_REPORTED:
report_error(error.code)
sys.exit(1)
raise