286 lines
18 KiB
Python
286 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Standalone Debian installer. Explicit allowlist; never manages production services."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
LABEL = 'de.casaderoll.athena-deck.standalone'
|
|
FILES = ['separator.py','separator_runtime.py','separator_worker.py','separator-ui.js','deploy/ladypoly/Dockerfile','deploy/ladypoly/bridge.py','music.py','music-ui.js','audio_cpp_runtime.py','audio-cpp-ui.js','ltx_original_runtime.py','ltx_original_entry.py','video_original.py','ltx-original-ui.js','deploy/ltx-original-requirements.lock','backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock']
|
|
|
|
|
|
def run(*args, check=True, interactive=False):
|
|
result = subprocess.run(args, text=True, capture_output=not interactive)
|
|
if check and result.returncode:
|
|
raise RuntimeError('Befehl fehlgeschlagen: '+args[0]+'. Vorhandene Dienste bleiben unangetastet.')
|
|
return result
|
|
|
|
|
|
def inspect(name):
|
|
result = run('docker','inspect',name,check=False)
|
|
return json.loads(result.stdout)[0] if result.returncode == 0 else None
|
|
|
|
|
|
def owned(name, base):
|
|
value = inspect(name)
|
|
if value and value['Config'].get('Labels',{}).get(LABEL) != str(base):
|
|
raise RuntimeError('Containername ist bereits anderweitig belegt. Keine Änderung ausgeführt.')
|
|
return value
|
|
|
|
|
|
def preflight(base, port):
|
|
if sys.platform != 'linux' or os.geteuid() != 0:
|
|
raise RuntimeError('Auf dem Debian-Zielserver mit sudo ausführen.')
|
|
release = dict(line.split('=',1) for line in Path('/etc/os-release').read_text().splitlines() if '=' in line)
|
|
if release.get('ID','').strip('"') != 'debian' or release.get('VERSION_ID','').strip('"') not in ('12','13'):
|
|
raise RuntimeError('Unterstützt werden Debian 12 und 13.')
|
|
if not shutil.which('docker'):
|
|
raise RuntimeError('Docker fehlt. Zuerst nach der offiziellen Debian-Anleitung installieren: https://docs.docker.com/engine/install/debian/ . Der Installer verändert keine Host-Pakete.')
|
|
version = run('docker','version','--format','{{.Server.Version}}').stdout.strip()
|
|
if int(version.split('.')[0]) < 28:
|
|
raise RuntimeError('Docker Engine >= 28 erforderlich. Kein automatisches Upgrade bestehender Docker-Installationen.')
|
|
if not 1024 <= port <= 65535:
|
|
raise RuntimeError('Port muss zwischen 1024 und 65535 liegen.')
|
|
if not base.is_absolute() or base in (Path('/'),Path('/opt'),Path('/srv'),Path('/var/lib')) or base != base.resolve():
|
|
raise RuntimeError('Eigenes absolutes Installationsverzeichnis ohne Symlinks erforderlich.')
|
|
for name in FILES + ['deploy/provision.py']:
|
|
if not (ROOT/name).is_file():
|
|
raise RuntimeError('Unvollständiger Checkout: '+name)
|
|
|
|
|
|
def free_port(port):
|
|
with socket.socket() as sock:
|
|
try:
|
|
sock.bind(('127.0.0.1',port))
|
|
except OSError:
|
|
raise RuntimeError('Der gewünschte Loopback-Port ist bereits belegt.') from None
|
|
|
|
|
|
def build(runtime_image=None):
|
|
digest = hashlib.sha256()
|
|
for name in FILES:
|
|
digest.update(name.encode());digest.update((ROOT/name).read_bytes())
|
|
tag = 'athena-deck-standalone:'+digest.hexdigest()[:16]
|
|
print('Eigenes Deck-Image bauen (keine Modelle oder llama.cpp-Builds).',flush=True)
|
|
dockerfile='deploy/Dockerfile.app-update' if runtime_image else 'deploy/Dockerfile'
|
|
extra=['--build-arg','DECK_RUNTIME_IMAGE='+runtime_image] if runtime_image else []
|
|
result = run('docker','build',*extra,'--label',LABEL+'=image','-t',tag,'-f',str(ROOT/dockerfile),str(ROOT),interactive=True)
|
|
return tag
|
|
|
|
|
|
def write_manifest(base, config):
|
|
temp=base/'installation.tmp'
|
|
fd=os.open(temp,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
|
with os.fdopen(fd,'w') as stream:
|
|
json.dump(config,stream,indent=2)
|
|
os.replace(temp,base/'installation.json')
|
|
|
|
|
|
def load_manifest(base):
|
|
config=json.loads((base/'installation.json').read_text())
|
|
if config.get('owner') != LABEL or config.get('base') != str(base):
|
|
raise RuntimeError('Kein gültiger eigener Installationsstand.')
|
|
return config
|
|
|
|
|
|
def launch(config):
|
|
base=Path(config['base'])
|
|
args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base),
|
|
'--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true',
|
|
'--pids-limit','256' if config.get('image_runtime') else '128','--memory','32g' if config.get('image_runtime') else '8g','--cpus','6', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m',
|
|
'-v',str(base/'state')+':/var/lib/deck:rw','--mount','type=bind,src=/proc,dst=/host/proc,readonly','-e','DECK_HOST_PROC=/host/proc','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']),
|
|
'-p','127.0.0.1:'+str(config['port'])+':8108',
|
|
'--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"',
|
|
'--health-interval','30s','--health-timeout','5s','--health-retries','3']
|
|
if config.get('api_ports'):
|
|
args+=['-e','DECK_API_BIND=0.0.0.0','-e','DECK_API_PORTS='+','.join(map(str,config['api_ports']))]
|
|
for port in config['api_ports']:args+=['-p',f'127.0.0.1:{port}:{port}']
|
|
if config.get('development_setup'):
|
|
args[args.index('--health-cmd')+1] = 'python3 -c "import urllib.request; urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3)"'
|
|
# Explicit isolated development bootstrap, reachable only through host loopback/SSH.
|
|
args += ['-e','DECK_REQUIRE_SETUP=0']
|
|
if config.get('docker_helper'):
|
|
args += ['--mount','type=bind,src=/run/athena-deck-docker,dst=/run/athena-deck-docker,readonly','-e','DECK_DOCKER_HELPER_SOCKET=/run/athena-deck-docker/control.sock']
|
|
if config['gpu_telemetry']:
|
|
args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=compute,utility']
|
|
if Path('/data/models').is_dir():
|
|
args+=['--mount','type=bind,src=/data/models,dst=/host/models,readonly','-e','DECK_HOST_DATA_DISK=/host/models']
|
|
if Path('/data/emergency-backups').is_dir():
|
|
args+=['--mount','type=bind,src=/data/emergency-backups,dst=/host/backups,readonly']
|
|
if config.get('reference_models'):
|
|
for folder,filename in [('qwen3.8-27b-iq4-mix','Qwen3.8-27B-IQ4-MIX.gguf'),('qwen3.8-27b-iq4-xs-pure','qwen3.8-27b-IQ4_XS-pure.gguf'),('qwen3.8-27b-abliterated','Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf')]:
|
|
source=Path('/data/models')/folder/filename
|
|
if not source.is_file():raise RuntimeError('Referenzmodell fehlt; keine Ersatzpfade.')
|
|
args+=['--mount','type=bind,src='+str(source)+',dst=/reference-models/'+filename+',readonly']
|
|
args.append(config['image'])
|
|
run(*args)
|
|
|
|
|
|
def ready(config):
|
|
# Verify the new container itself as well as the published port.
|
|
for _ in range(30):
|
|
item=owned(config['name'],Path(config['base']))
|
|
if item and item['State']['Running']:
|
|
try:
|
|
with urllib.request.urlopen('http://127.0.0.1:'+str(config['port'])+'/api/v1/auth/status',timeout=2) as r:
|
|
if json.load(r).get('initialized') is True or config.get('development_setup'):
|
|
return
|
|
except (OSError, ValueError):
|
|
pass
|
|
time.sleep(1)
|
|
raise RuntimeError('Neue Deck-Instanz wurde nicht bereit.')
|
|
|
|
|
|
def provision(config):
|
|
base=Path(config['base'])
|
|
if (base/'state/auth.json').exists():
|
|
raise RuntimeError('Zugangsdaten existieren bereits; keine Überschreibung.')
|
|
os.chown(base/'bootstrap',65534,65534)
|
|
run('docker','run','--rm','-it','--network','none','--user','65534:65534','--cap-drop','ALL',
|
|
'--security-opt','no-new-privileges:true','--read-only','--tmpfs','/tmp:rw,nosuid,nodev,size=8m',
|
|
'-v',str(base/'state')+':/var/lib/deck:rw','-v',str(base/'bootstrap')+':/bootstrap:rw',
|
|
'-v',str(ROOT/'deploy/provision.py')+':/provision.py:ro','-e','PYTHONPATH=/app',config['image'],
|
|
'python3','/provision.py',interactive=True)
|
|
os.chown(base/'state/auth.json',65534,65534)
|
|
|
|
|
|
def install(args):
|
|
base=args.directory
|
|
preflight(base,args.port)
|
|
if owned(args.name,base):
|
|
raise RuntimeError('Deck-Container existiert bereits. Für Updates --update verwenden.')
|
|
if base.exists():
|
|
raise RuntimeError('Installationsverzeichnis existiert bereits. Es wird nicht überschrieben.')
|
|
free_port(args.port)
|
|
api_ports=parse_api_ports(args.api_ports or '8120-8124')
|
|
if args.port in api_ports:raise RuntimeError('Verwaltungsport und API-Ports müssen verschieden sein.')
|
|
for port in api_ports:free_port(port)
|
|
parent=base.parent
|
|
while not parent.exists():parent=parent.parent
|
|
if shutil.disk_usage(parent).free < 25*1024**3:
|
|
raise RuntimeError('Mindestens 25 GiB freier Speicher für Installation inklusive Bildlaufzeit erforderlich.')
|
|
if not sys.stdin.isatty():
|
|
raise RuntimeError('Interaktives Terminal für Zugangseinrichtung benötigt (über SSH: ssh -t).')
|
|
image=build()
|
|
base.mkdir(parents=True,mode=0o700)
|
|
for sub in ('state','models','backups','bootstrap'):
|
|
(base/sub).mkdir(mode=0o700)
|
|
os.chown(base/'state',65534,65534)
|
|
config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,api_ports=api_ports,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry,image_runtime=True,docker_helper=True)
|
|
run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534','--deck-state',str(base/'state'))
|
|
write_manifest(base,config)
|
|
provision(config)
|
|
try:
|
|
launch(config);ready(config)
|
|
except Exception:
|
|
item=owned(config['name'],base)
|
|
if item:run('docker','rm','-f',config['name'])
|
|
raise RuntimeError('Deck-Start fehlgeschlagen. Nur der neue Deck-Container wurde entfernt; Zugangsdaten und Installationsstand bleiben für --start erhalten.') from None
|
|
print('Deck ist bereit: http://127.0.0.1:'+str(args.port))
|
|
if (base/'bootstrap/api-token.txt').exists():
|
|
print('Generierter API-Token: geschützte Datei '+str(base/'bootstrap/api-token.txt')+'. In Passwortmanager übernehmen und Datei danach entfernen.')
|
|
|
|
|
|
def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=False):
|
|
config=load_manifest(base)
|
|
previous=owned(config['name'],base)
|
|
if not previous:
|
|
raise RuntimeError('Kein installierter Deck-Container vorhanden.')
|
|
selected_ports=config.get('api_ports',[]) if api_ports is None else parse_api_ports(api_ports)
|
|
if config['port'] in selected_ports:raise RuntimeError('Verwaltungsport und API-Ports müssen verschieden sein.')
|
|
for port in set(selected_ports)-set(config.get('api_ports',[])):free_port(port)
|
|
image=config.get('previous_image') if rollback else build(config['image'] if reuse_runtime else None)
|
|
if not image:raise RuntimeError('Kein vorheriger Build gespeichert.')
|
|
if image==config['image'] and selected_ports==config.get('api_ports',[]) and not force:
|
|
print('Dieser Quellstand ist bereits installiert.');return
|
|
backup_name=config['name']+'-previous'
|
|
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
|
stamp=str(time.time_ns())
|
|
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('music-verification','music-test-temp','music-jobs','separator-runtime','separator-tests','audio-cpp-runtime','ltx-original-runtime','original-work','tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*'))
|
|
was_running=previous['State']['Running']
|
|
if was_running:run('docker','stop','--time','15',config['name'])
|
|
run('docker','rename',config['name'],backup_name)
|
|
new=dict(config,image=image,previous_image=config['image'],api_ports=selected_ports)
|
|
try:
|
|
launch(new);ready(new);write_manifest(base,new)
|
|
except Exception:
|
|
item=owned(config['name'],base)
|
|
if item:run('docker','rm','-f',config['name'])
|
|
run('docker','rename',backup_name,config['name'])
|
|
if was_running:run('docker','start',config['name'])
|
|
raise RuntimeError('Update fehlgeschlagen. Vorheriger Deck-Container wiederhergestellt.') from None
|
|
run('docker','rm',backup_name)
|
|
# Keep only two automatic pre-update state snapshots after a successful update.
|
|
snapshots=sorted((p for p in (base/'backups').iterdir() if p.is_dir() and not p.is_symlink() and p.name.isdigit()),key=lambda p:int(p.name))
|
|
for snapshot in snapshots[:-2]:shutil.rmtree(snapshot)
|
|
print('Nur Athena Deck wurde aktualisiert. Vorheriges Image bleibt für --rollback erhalten.')
|
|
|
|
|
|
def parse_api_ports(value):
|
|
import re
|
|
if not isinstance(value,str) or not re.fullmatch(r'[0-9]{4,5}(?:-[0-9]{4,5})?',value):raise RuntimeError('API-Port oder kleiner Portbereich erwartet, z. B. 8120-8124.')
|
|
parts=[int(x) for x in value.split('-')];start=parts[0];end=parts[-1]
|
|
if not 1024<=start<=end<=65535 or end-start>=16:raise RuntimeError('Maximal 16 nicht privilegierte API-Ports verwenden.')
|
|
return list(range(start,end+1))
|
|
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description='Athena Deck auf Debian 12/13 getrennt installieren. Kein WireGuard, keine Host-Paket- oder Treiberänderungen.')
|
|
actions=parser.add_mutually_exclusive_group(required=True)
|
|
for action in ('check','install','setup','start','stop','status','update','rollback','docker-helper'):
|
|
actions.add_argument('--'+action,action='store_true')
|
|
parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone'))
|
|
parser.add_argument('--name',default='athena-deck-standalone')
|
|
parser.add_argument('--port',type=int,default=8110)
|
|
parser.add_argument('--reuse-runtime',action='store_true',help='Bei reinen App-Updates vorhandenes Deck-Image mit seinen Laufzeiten wiederverwenden; keine Laufzeitinstallation.')
|
|
parser.add_argument('--api-ports',help='Separater API-Port oder Bereich, z. B. 8120-8124. Installationsstandard: 8120-8124; Updates behalten den bisherigen Bereich.')
|
|
parser.add_argument('--gpu-telemetry',action='store_true',help='Vorhandenes NVIDIA Container Toolkit für Messwerte und Fit-Prüfung nutzen; installiert keine Treiber.')
|
|
args=parser.parse_args()
|
|
import re
|
|
if not re.fullmatch(r'athena-deck-[a-z0-9-]{1,40}',args.name):
|
|
parser.error('Name muss mit athena-deck- beginnen und nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.')
|
|
preflight(args.directory,args.port)
|
|
if args.check:
|
|
item=owned(args.name,args.directory)
|
|
if not item:free_port(args.port)
|
|
print('Vorprüfung erfolgreich. Keine Dateien, Images oder Dienste geändert.')
|
|
elif args.install:
|
|
install(args)
|
|
else:
|
|
config=load_manifest(args.directory)
|
|
item=owned(config['name'],args.directory)
|
|
if args.docker_helper:
|
|
run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534','--deck-state',str(args.directory/'state'))
|
|
config['docker_helper']=True;write_manifest(args.directory,config);update(args.directory,force=True)
|
|
elif args.status:
|
|
print('Deck: '+('running' if item and item['State']['Running'] else 'stopped / absent'))
|
|
print('URL: http://127.0.0.1:'+str(config['port']))
|
|
elif args.setup:
|
|
provision(config)
|
|
print('Zugang eingerichtet. Jetzt --start ausführen.')
|
|
elif args.stop:
|
|
if item:run('docker','stop','--time','15',config['name'])
|
|
elif args.start:
|
|
if not (args.directory/'state/auth.json').exists() and not config.get('development_setup'):
|
|
raise RuntimeError('Zugangseinrichtung unvollständig. Siehe INSTALL.md zur Wiederaufnahme.')
|
|
if (args.directory/'state/auth.json').exists():
|
|
os.chown(args.directory/'state/auth.json',65534,65534)
|
|
if item:run('docker','start',config['name'])
|
|
else:launch(config)
|
|
ready(config);print('Deck bereit.')
|
|
elif args.update or args.rollback:update(args.directory,args.rollback,api_ports=args.api_ports,reuse_runtime=args.reuse_runtime)
|
|
|
|
if __name__=='__main__':
|
|
try:main()
|
|
except (RuntimeError,OSError,ValueError,KeyError) as exc:
|
|
print('Installation/Betrieb abgebrochen: '+str(exc),file=sys.stderr)
|
|
sys.exit(1)
|