Add Docker runtime helper and explicitly labelled service inventory

This commit is contained in:
Mikei386
2026-09-28 18:48:13 +02:00
parent a0941c0549
commit 5cc732f993
15 changed files with 399 additions and 13 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/
COPY image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/
COPY docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/
COPY docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
+8
View File
@@ -71,3 +71,11 @@ CPU-Kerne. Modelle/Encoder/VAE werden weiterhin separat über die Bibliothek gel
Decks `image_encoder_node.py` muss mitinstalliert werden. Der Bildtest macht
5080 und 3060 per UUID sichtbar und lädt den Textencoder explizit auf die zweite
GPU. Beide Geräte werden vor dem Start auf Fremdbelegung geprüft.
### Docker für optionale Studios
`deploy/setup_docker_helper.py` als Teil des nativen Basisinstallers provisionieren,
mit eigener Deck-UID/GID und optional `--allow-install`. Dadurch kann Docker später
über Einstellungen nachinstalliert werden. Der Helfer bleibt ein separater,
root-eigener Dienst mit festem Aktionsumfang. Kein Docker-Socket im Webserver.
Siehe `docs/DOCKER_SERVICES.md`; frischer Debian-Ende-zu-Ende-Test noch erforderlich.
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Root-owned local helper. Only inventory and fresh Docker install; no generic RPC."""
import argparse
import json
import os
from pathlib import Path
import shutil
import socket
import socketserver
import struct
import subprocess
import threading
import time
MANAGED_LABEL='io.athena-deck.managed=true'
ROLE_LABEL='io.athena-deck.role=application'
PACKAGES=['docker-ce','docker-ce-cli','containerd.io','docker-buildx-plugin','docker-compose-plugin']
CONFLICTS=['docker.io','docker-compose','docker-doc','docker-buildx','podman-docker','containerd','runc']
ENV={'PATH':'/usr/sbin:/usr/bin:/sbin:/bin','HOME':'/var/lib/athena-deck-docker','LANG':'C.UTF-8','DEBIAN_FRONTEND':'noninteractive','NEEDRESTART_MODE':'l'}
def run(args,timeout=15):
return subprocess.run(args,capture_output=True,text=True,timeout=timeout,env=ENV)
def release():
return dict(line.split('=',1) for line in Path('/etc/os-release').read_text().replace('"','').splitlines() if '=' in line)
class Manager:
def __init__(self,state,allow_install=False):
self.state=Path(state);self.allow_install=allow_install;self.lock=threading.RLock();self.job=None
if (self.state/'job.json').exists():
self.job=json.loads((self.state/'job.json').read_text())
if self.job['state']=='running':self.job.update(state='interrupted',phase='Installation unterbrochen. Hostzustand prüfen; vorhandene Pakete werden nicht automatisch ersetzt.')
def inventory(self):
# Filter at the daemon before retrieving rows. No inspect, env, mounts or logs.
result=run(['/usr/bin/docker','ps','-a','--no-trunc','--filter','label='+MANAGED_LABEL,'--filter','label='+ROLE_LABEL,'--format','{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}'])
if result.returncode:raise ValueError('Docker-Daemon nicht erreichbar.')
rows=[]
for line in result.stdout.splitlines()[:500]:
fields=line.split('\t')
if len(fields)==5:rows.append(dict(zip(('id','name','image','state','status'),fields)))
return rows
def preflight(self):
info=release()
if info.get('ID')!='debian' or info.get('VERSION_ID') not in ('12','13'):return False,'Unterstützt werden Debian 12 und 13.'
if Path('/usr/bin/docker').exists() or Path('/var/run/docker.sock').exists():return False,'Docker ist vorhanden. Keine automatische Neuinstallation oder Aktualisierung.'
if not self.allow_install:return False,'Docker-Erstinstallation ist für diesen Helfer nicht freigegeben.'
if any(Path(p).exists() for p in ('/var/lib/docker','/var/lib/containerd','/etc/docker')):return False,'Vorhandene Containerdaten oder Konfiguration erkannt. Manuelle Prüfung erforderlich.'
for package in CONFLICTS+PACKAGES:
value=run(['/usr/bin/dpkg-query','-W','-f=${Status}',package])
if value.returncode==0 and 'install ok installed' in value.stdout:return False,'Vorhandene Containerpakete erkannt. Keine automatische Entfernung oder Ersetzung.'
if shutil.disk_usage('/var').free<3*1024**3:return False,'Mindestens 3 GiB freier Speicher erforderlich.'
return True,'Docker Engine, Compose und Buildx aus dem offiziellen Docker-Repository installieren. Docker richtet einen Systemdienst sowie eigene Netzwerk-/Firewallregeln ein. Keine NVIDIA-Treiberinstallation und kein Host-Neustart.'
def status(self):
installed=Path('/usr/bin/docker').exists();reachable=False;version=None;services=[]
if installed:
try:
version_result=run(['/usr/bin/docker','version','--format','{{.Server.Version}}'])
if version_result.returncode==0:version=version_result.stdout.strip();services=self.inventory();reachable=True
except (OSError,ValueError,subprocess.SubprocessError):pass
supported,message=self.preflight()
with self.lock:job=dict(self.job) if self.job else None
return dict(installed=installed,reachable=reachable,version=version,services=services,install_supported=supported,message=message,job=job,labels=[MANAGED_LABEL,ROLE_LABEL])
def _phase(self,text,state='running'):
with self.lock:
self.job.update(state=state,phase=text);self.state.mkdir(parents=True,exist_ok=True,mode=0o700)
temp=self.state/'job.tmp';temp.write_text(json.dumps(self.job));temp.replace(self.state/'job.json')
def install(self):
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Docker-Installation läuft bereits.')
allowed,message=self.preflight()
if not allowed:raise ValueError(message)
self.job=dict(state='running',phase='Docker-Erstinstallation wird vorbereitet',started_at=time.time())
self._phase(self.job['phase']);threading.Thread(target=self._install,daemon=True).start()
return {'started':True}
def _checked(self,args):
result=run(args,timeout=1200)
if result.returncode:raise ValueError('Installationsschritt fehlgeschlagen. Paketverwaltung und Internetverbindung auf dem Host prüfen.')
def _install(self):
try:
info=release();codename={'12':'bookworm','13':'trixie'}[info['VERSION_ID']]
arch=run(['/usr/bin/dpkg','--print-architecture']).stdout.strip()
if arch not in ('amd64','arm64'):raise ValueError('Diese Architektur wird vom Deck-Installer noch nicht unterstützt.')
self._phase('Paketquellen prüfen und Download-Werkzeuge installieren')
self._checked(['/usr/bin/apt-get','update'])
self._checked(['/usr/bin/apt-get','install','-y','--no-remove','ca-certificates','curl'])
key=Path('/etc/apt/keyrings/athena-deck-docker.asc');source=Path('/etc/apt/sources.list.d/athena-deck-docker.sources')
key.parent.mkdir(mode=0o755,exist_ok=True)
self._checked(['/usr/bin/curl','--fail','--silent','--show-error','--proto','=https','--tlsv1.2','--max-time','60','https://download.docker.com/linux/debian/gpg','-o',str(key)])
key.chmod(0o644)
source.write_text(f'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: {codename}\nComponents: stable\nArchitectures: {arch}\nSigned-By: {key}\n');source.chmod(0o644)
self._phase('Docker Engine, Compose und Buildx installieren')
self._checked(['/usr/bin/apt-get','update'])
self._checked(['/usr/bin/apt-get','install','-y','--no-remove',*PACKAGES])
self._phase('Docker-Systemdienst starten und Erreichbarkeit prüfen')
self._checked(['/usr/bin/systemctl','enable','--now','docker.service'])
self._checked(['/usr/bin/docker','version','--format','{{.Server.Version}}'])
self._checked(['/usr/bin/docker','compose','version','--short'])
self._phase('Docker bereit. Keine Anwendungen oder Modelle installiert.','complete')
except Exception as exc:self._phase(str(exc) if isinstance(exc,ValueError) else 'Docker-Installation fehlgeschlagen. Hostzustand vor erneutem Versuch prüfen.','failed')
def dispatch(self,data):
if not isinstance(data,dict) or set(data)!={'action'}:raise ValueError('Ungültige Helferanfrage.')
if data['action']=='status':return self.status()
if data['action']=='install':return self.install()
raise ValueError('Aktion nicht erlaubt.')
class Handler(socketserver.StreamRequestHandler):
def handle(self):
self.connection.settimeout(20)
try:
_,uid,_=struct.unpack('3i',self.connection.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid not in (0,self.server.client_uid):raise ValueError('Client nicht erlaubt.')
raw=self.rfile.readline(4097)
if len(raw)>4096:raise ValueError('Anfrage zu groß.')
result=self.server.manager.dispatch(json.loads(raw))
except Exception as exc:result={'error':str(exc) if isinstance(exc,ValueError) else 'Docker-Systemhelfer nicht verfügbar.'}
self.wfile.write(json.dumps(result).encode()+b'\n')
class Server(socketserver.ThreadingUnixStreamServer):
daemon_threads=True
def main():
parser=argparse.ArgumentParser();parser.add_argument('--client-uid',type=int,required=True);parser.add_argument('--client-gid',type=int,required=True);parser.add_argument('--allow-install',action='store_true');parser.add_argument('--state',default='/var/lib/athena-deck-docker');args=parser.parse_args()
socket_path=Path('/run/athena-deck-docker/control.sock');socket_path.parent.mkdir(mode=0o755,exist_ok=True);socket_path.unlink(missing_ok=True)
with Server(str(socket_path),Handler) as server:
os.chown(socket_path,0,args.client_gid);socket_path.chmod(0o660);server.client_uid=args.client_uid;server.manager=Manager(args.state,args.allow_install);server.serve_forever()
if __name__=='__main__':main()
+12 -6
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['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','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.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']
FILES = ['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','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.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):
@@ -102,6 +102,8 @@ def launch(config):
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 config.get('reference_models'):
@@ -160,7 +162,8 @@ def install(args):
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,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry,image_runtime=True)
config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,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')
write_manifest(base,config)
provision(config)
try:
@@ -174,14 +177,14 @@ def install(args):
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):
def update(base, rollback=False, force=False):
config=load_manifest(base)
previous=owned(config['name'],base)
if not previous:
raise RuntimeError('Kein installierter Deck-Container vorhanden.')
image=config.get('previous_image') if rollback else build()
if not image:raise RuntimeError('Kein vorheriger Build gespeichert.')
if image==config['image']:
if image==config['image'] 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.')
@@ -206,7 +209,7 @@ def update(base, rollback=False):
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'):
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')
@@ -226,7 +229,10 @@ def main():
else:
config=load_manifest(args.directory)
item=owned(config['name'],args.directory)
if args.status:
if args.docker_helper:
run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534')
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:
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Provision Deck's fixed Unix-socket helper; does not install Docker itself."""
import argparse
import os
from pathlib import Path
import shutil
import subprocess
MARKER='# Athena Deck Docker helper — managed by setup_docker_helper.py'
def main():
p=argparse.ArgumentParser();p.add_argument('--client-uid',type=int,required=True);p.add_argument('--client-gid',type=int,required=True);p.add_argument('--allow-install',action='store_true',help='Erlaubt Erstinstallation über die authentifizierte Deck-GUI auf einem unbenutzten Debian-Host.');args=p.parse_args()
if os.geteuid()!=0 or not Path('/run/systemd/system').is_dir():raise SystemExit('Als root auf dem Debian-Zielserver mit systemd ausführen.')
if args.client_uid<1 or args.client_gid<1:raise SystemExit('Eigene unprivilegierte Deck-UID und GID erforderlich.')
base=Path('/opt/athena-deck-docker-helper');unit=Path('/etc/systemd/system/athena-deck-docker-helper.service')
if base.is_symlink() or unit.is_symlink():raise SystemExit('Symlink-Ziel nicht zulässig.')
if unit.exists() and not unit.read_text().startswith(MARKER):raise SystemExit('Dienstname ist anderweitig belegt.')
if base.exists() and not (base/'managed-by-deck').is_file():raise SystemExit('Installationsverzeichnis ist anderweitig belegt.')
base.mkdir(mode=0o755,exist_ok=True);os.chown(base,0,0);base.chmod(0o755)
target=base/'docker_helper.py'
if target.is_symlink():raise SystemExit('Symlink-Ziel nicht zulässig.')
shutil.copyfile(Path(__file__).with_name('docker_helper.py'),target);os.chown(target,0,0);target.chmod(0o644);(base/'managed-by-deck').touch()
command=f'/usr/bin/python3 {target} --client-uid {args.client_uid} --client-gid {args.client_gid}'+(' --allow-install' if args.allow_install else '')
unit.write_text(MARKER+f'''
[Unit]
Description=Athena Deck restricted Docker helper
After=network.target
[Service]
Type=simple
ExecStart={command}
Restart=on-failure
RuntimeDirectory=athena-deck-docker
RuntimeDirectoryMode=0755
StateDirectory=athena-deck-docker
StateDirectoryMode=0700
UMask=0077
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true
ProtectSystem={'false' if args.allow_install else 'strict'}
[Install]
WantedBy=multi-user.target
''')
unit.chmod(0o644)
subprocess.run(['/usr/bin/systemctl','daemon-reload'],check=True)
subprocess.run(['/usr/bin/systemctl','enable','athena-deck-docker-helper.service'],check=True,capture_output=True)
subprocess.run(['/usr/bin/systemctl','restart','athena-deck-docker-helper.service'],check=True)
print('Deck-Docker-Helfer eingerichtet. Installationsrecht: '+('Erstinstallation erlaubt' if args.allow_install else 'nur lesender Bestand'))
if __name__=='__main__':main()