Files
ProxMenux/oci/remote/install_generic_stack.py
T
MacRimiandClaude Opus 5 bcabcb618c feat(oci): run official container images as native LXC containers
Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:24:59 +02:00

776 lines
38 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
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):
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 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 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 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')
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 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)
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():
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:
lost=target/'lost+found'
if lost.is_dir() and not lost.is_symlink():
lost.rmdir()
run('cp','-a',str(seed)+'/.',str(target))
os.chown(target,*owner[:2])
target.chmod(owner[2])
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')
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:
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 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'))
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'])
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_.-]*',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']}
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")
if s['healthcheck']['type']=='http':
endpoint = s['healthcheck']['endpoint']
s['healthcheck']['url'] = f"{endpoint['scheme']}://{s['ip']}:{endpoint['port']}{endpoint['path']}"
if independent:
fcntl.flock(lock,fcntl.LOCK_UN)
result = finish_independent_suite(services,subnet)
persist_instances(deployment, 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
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'))
fcntl.flock(lock,fcntl.LOCK_UN)
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']
url = f"{endpoint['scheme']}://{primary['ip']}:{endpoint['port']}{endpoint['path']}"
msg_info(translate('Waiting for the application to respond...'))
wait_web(primary,url)
addresses=run('lxc-info','-n',primary_id,'-iH').splitlines()
lan = next((a for a in addresses if re.fullmatch(r'\d+\.\d+\.\d+\.\d+',a) and ipaddress.ip_address(a) not in subnet),None)
if not lan:
raise RuntimeError(translate('No LAN address was obtained'))
public_url = f"{endpoint['scheme']}://{lan}:{endpoint['port']}{endpoint['path']}"
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)
# 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=[{'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