mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 10:06:41 +00:00
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>
776 lines
38 KiB
Python
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
|