From 5cc732f9934cd294d5891822bae4fe046f1a5d95 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:48:13 +0200 Subject: [PATCH] Add Docker runtime helper and explicitly labelled service inventory --- README.md | 8 ++ app.js | 2 +- deploy/Dockerfile | 4 +- deploy/NATIVE_INSTALL_REQUIREMENTS.md | 8 ++ deploy/docker_helper.py | 126 ++++++++++++++++++++++++++ deploy/install.py | 18 ++-- deploy/setup_docker_helper.py | 49 ++++++++++ docker-ui.js | 15 +++ docker_support.py | 22 +++++ docs/DOCKER_SERVICES.md | 79 ++++++++++++++++ index.html | 2 +- server.py | 11 ++- studio.js | 5 +- test_docker_support.py | 50 ++++++++++ test_server.py | 13 +++ 15 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 deploy/docker_helper.py create mode 100644 deploy/setup_docker_helper.py create mode 100644 docker-ui.js create mode 100644 docker_support.py create mode 100644 docs/DOCKER_SERVICES.md create mode 100644 test_docker_support.py diff --git a/README.md b/README.md index 22bb84c..d24d8ff 100644 --- a/README.md +++ b/README.md @@ -138,3 +138,11 @@ Repositories absteigend; sie sortiert nicht nur die beliebtesten 20 Treffer um. Name A–Z sortiert weiterhin die angezeigte Treffermenge. Prüfung vorhandener Studios: [Native Anwendungen und Docker](docs/NATIVE_APPLICATION_AUDIT.md). + +### Optionale Docker-Anwendungen + +Einstellungen → Laufzeiten → Docker zeigt Verbindung, Version und gegebenenfalls +Docker-Erstinstallation. Weitere Dienste zeigt ausschließlich Container mit +`io.athena-deck.managed=true` **und** `io.athena-deck.role=application`. +Bestehende Container werden nicht automatisch übernommen. Details zu Systemhelfer, +Installer und Grenzen: [Docker-Dienste](docs/DOCKER_SERVICES.md). diff --git a/app.js b/app.js index d7a0da5..ca118e8 100644 --- a/app.js +++ b/app.js @@ -8,7 +8,7 @@ const metric=(title,value)=>`
${title}${value}v==null?'':``; const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']}; async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; -if(['chat','image','audio','video','runtime','runtimes','image-runtime'].includes(page)){Studio.render(page);return;} +if(['chat','image','audio','video','runtime','runtimes','image-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;} if(page==='access'){await accessPage();return;} if(page==='network'){await networkPage();return;} if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`
Anwendung
Bereit

Athena Deck läuft auf ${esc(s.location)}.

Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1
Betriebsmodus
Isoliert

Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.

Keine Modell- oder Router-Steuerung angebunden

Demo-Dienst

LOKAL / CPU

Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.

${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}
Lädt keine Modelle · endet beim Beenden von Athena Deck
`; diff --git a/deploy/Dockerfile b/deploy/Dockerfile index a4376a0..6bcef17 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 \ diff --git a/deploy/NATIVE_INSTALL_REQUIREMENTS.md b/deploy/NATIVE_INSTALL_REQUIREMENTS.md index 21dde3b..c204e51 100644 --- a/deploy/NATIVE_INSTALL_REQUIREMENTS.md +++ b/deploy/NATIVE_INSTALL_REQUIREMENTS.md @@ -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. diff --git a/deploy/docker_helper.py b/deploy/docker_helper.py new file mode 100644 index 0000000..d15350a --- /dev/null +++ b/deploy/docker_helper.py @@ -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() diff --git a/deploy/install.py b/deploy/install.py index 8b580e7..87c7d61 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -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: diff --git a/deploy/setup_docker_helper.py b/deploy/setup_docker_helper.py new file mode 100644 index 0000000..59b7e9c --- /dev/null +++ b/deploy/setup_docker_helper.py @@ -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() diff --git a/docker-ui.js b/docker-ui.js new file mode 100644 index 0000000..f1368d5 --- /dev/null +++ b/docker-ui.js @@ -0,0 +1,15 @@ +window.DockerUI=(()=>{ + const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + async function api(action='',data){const r=await fetch('/api/v1/docker'+action,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});if(r.status===401){location.reload();throw Error('Bitte erneut anmelden.');}const value=await r.json();if(!r.ok)throw Error(value.error||'Docker-Anfrage fehlgeschlagen');return value;} + function html(services=false){return `
${services?'ANWENDUNGEN':'EINSTELLUNGEN / LAUFZEITEN'}

${services?'Weitere Dienste':'Docker'}

${services?'Zusätzliche Oberflächen werden getrennt von Modellen und Laufzeiten verwaltet. Hier erscheinen ausschließlich ausdrücklich für Deck markierte Anwendungscontainer.':'Optionale Umgebung für zusätzliche Studios. Native Modelllaufzeiten bleiben unabhängig davon.'}

`;} + function bind(services=false){const panel=document.querySelector('#docker-panel'),message=document.querySelector('#docker-message');let signature='',pending=false; + async function refresh(){try{const s=await api();if(!panel.isConnected)return;const next=JSON.stringify(s);if(signature!==next){signature=next;const running=s.job?.state==='running'; + if(services){panel.innerHTML=`

Explizite Zuordnung

Beide Container-Labels müssen gesetzt sein:

io.athena-deck.managed=true\nio.athena-deck.role=application

Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.

Docker-Verbindung prüfen →
${!s.helper_available||!s.reachable?'

Docker nicht verbunden

'+esc(s.message)+'

':s.services.length?'
'+s.services.map(c=>`
DOCKER · DECK-ANWENDUNG

${esc(c.name)}

${esc(c.image)}

${esc(c.state)} · ${esc(c.status)}

`).join('')+'
':'

Noch keine Deck-Anwendungen

Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.

'}`;} + else {panel.innerHTML=`
${s.installed?'INSTALLIERT':s.installed===false?'NICHT INSTALLIERT':'NICHT ERMITTELBAR'}

Docker Engine ${esc(s.version||'')}

${s.reachable?'Daemon erreichbar · Verbindung über begrenzten Systemhelfer':s.helper_available?'Systemhelfer erreichbar · Docker-Daemon nicht erreichbar':'Systemhelfer nicht verbunden'}

${esc(s.message)}

${s.job?`

${esc(s.job.state)} · ${esc(s.job.phase)}

`:''}${!s.installed&&s.install_supported&&!running?'

':``}

Keine automatische Aktualisierung vorhandener Docker-Installationen. NVIDIA Container Toolkit und Treiber sind separate Voraussetzungen für GPU-Container. Die Paketinstallation wird nicht mitten im Vorgang abgebrochen.

Weitere Dienste öffnen →
`; + const button=panel.querySelector('#docker-install'),ack=panel.querySelector('#docker-install-ack');if(button){ack.onchange=()=>button.disabled=pending||!ack.checked;button.onclick=async()=>{pending=true;button.disabled=true;try{await api('/install',{confirm:true});message.textContent='Docker-Erstinstallation gestartet.';}catch(error){message.textContent=error.message;}finally{pending=false;signature='';}};} + } + }}catch(error){if(panel.isConnected){message.textContent=error.message;signature='';}}finally{if(panel.isConnected)setTimeout(refresh,5000);}} + refresh(); + } + return {html,bind}; +})(); diff --git a/docker_support.py b/docker_support.py new file mode 100644 index 0000000..6ee9e61 --- /dev/null +++ b/docker_support.py @@ -0,0 +1,22 @@ +"""Narrow client: the web app never receives Docker's daemon socket.""" +import json +import os +import socket + +MANAGED_LABEL='io.athena-deck.managed=true' +ROLE_LABEL='io.athena-deck.role=application' + +class DockerSupport: + def call(self,action): + path=os.environ.get('DECK_DOCKER_HELPER_SOCKET','/run/athena-deck-docker/control.sock') + with socket.socket(socket.AF_UNIX) as sock: + sock.settimeout(20);sock.connect(path);sock.sendall(json.dumps({'action':action}).encode()+b'\n') + with sock.makefile('rb') as stream:raw=stream.readline(1048577) + if len(raw)>1048576:raise ValueError('Docker-Helferantwort zu groß.') + value=json.loads(raw) + if 'error' in value:raise ValueError(value['error']) + return value + def status(self): + try:return dict(self.call('status'),helper_available=True) + except (OSError,ValueError):return dict(helper_available=False,installed=None,reachable=False,services=[],install_supported=False,job=None,message='Docker-Systemhelfer nicht verbunden. Der Deck-Installer muss ihn auf dem Debian-Host einrichten.',labels=[MANAGED_LABEL,ROLE_LABEL]) + def install(self):return self.call('install') diff --git a/docs/DOCKER_SERVICES.md b/docs/DOCKER_SERVICES.md new file mode 100644 index 0000000..0f722bc --- /dev/null +++ b/docs/DOCKER_SERVICES.md @@ -0,0 +1,79 @@ +# Docker-Laufzeit und Weitere Dienste + +Decks Webserver besitzt keinen Docker-Socket. Ein kleiner, root-eigener +Systemhelfer bietet über `/run/athena-deck-docker/control.sock` ausschließlich +zwei feste Aktionen: Status/Inventar und Docker-Erstinstallation. Keine freie +Shell, keine Exec-/Mount-/Container-Erstellungsoperationen. Unix-Dateirechte +und Linux SO_PEERCRED begrenzen Clients auf root und die eingerichtete Deck-UID. +Die öffentliche Deck-API verlangt eine Admin-Sitzung, POST zusätzlich den +bestehenden CSRF-Schutz. + +## Explizite Container-Zuordnung + +Nur Container mit **beiden exakten Labels** erscheinen unter Weitere Dienste: + +```yaml +services: + studio: + labels: + io.athena-deck.managed: "true" + io.athena-deck.role: "application" +``` + +`docker ps -a` wird bereits beim Daemon auf diese Labels gefiltert. Es werden nur +ID, Name, Image, Zustand und Status übertragen; keine Umgebungsvariablen, +Mounts, Secrets oder Anwendungslogs. Vorhandene Container werden nicht automatisch +übernommen. Labels sind eine explizite Zuordnung, keine Sicherheitsgrenze gegenüber +jemandem mit eigenem Docker-Administratorzugriff. + +Die erste Ansicht zeigt nur den Bestand. Ein Anwendungskatalog sowie Installation, +Start/Stopp, Öffnen und Deinstallation einzelner Studios folgen separat mit +anwendungsspezifischen Rezepten. Keine dekorativen, funktionslosen Aktionsknöpfe. +Gewichte und Nutzerdaten werden durch diese Funktion nicht verändert. + +## Bestehende Docker-Testinstallation anbinden + +```sh +sudo ./install.sh --docker-helper --directory /opt/athena-deck-dev/runtime +``` + +Richtet ausschließlich `athena-deck-docker-helper.service` ein und erneuert Deck, +um das private Helfer-Socketverzeichnis einzubinden. Kein Docker-Daemon-Neustart, +keine Paketinstallation. Der Helfer läuft hier mit schreibgeschütztem System und +**ohne Installationsrecht**. Bestehendes Docker wird nur erkannt und verwendet. +Neue Docker-Testinstallationen richten diese lesende Anbindung automatisch ein. + +## Optionales Nachinstallieren auf nativem Debian + +Der native Deck-Basisinstaller muss den Helfer mit der tatsächlichen UID/GID des +Deck-Service und ausdrücklich mit Installationsrecht provisionieren: + +```sh +sudo python3 deploy/setup_docker_helper.py --client-uid DECK_UID --client-gid DECK_GID --allow-install +``` + +Die Flags DECK_UID/DECK_GID durch die numerischen Werte ersetzen. Der vollständige +native Basisinstaller ist weiterhin noch nicht fertig; dieser Bootstrap ist separat. +Anschließend bietet Einstellungen → Laufzeiten → Docker die Erstinstallation an. +Vor Auslösung zeigt die GUI die konkreten Auswirkungen und verlangt die bewusste +Bestätigung der Installation samt Dienst und Docker-Netzwerk-/Firewallregeln. + +Unterstützt Debian 12/13, amd64/arm64. Installiert aus dem offiziellen signierten +Docker-APT-Repository: docker-ce, docker-ce-cli, containerd.io, Buildx und Compose. +Vorhandene Docker-/Containerd-Pakete, Konfigurationen oder Daten führen zum Abbruch +vor Änderungen. Kein Entfernen von Konfliktpaketen, kein Upgrade bestehender +Docker-Installationen, keine Treiberinstallation und kein Host-Reboot. NVIDIA +Container Toolkit ist für GPU-Container separat erforderlich. Bereits vorhandenes +Docker wird nicht über diese Oberfläche aktualisiert. + +Paketinstallation läuft serverseitig weiter, wenn die Seite geschlossen wird. +Kein Abbrechen mitten in dpkg; Fehler werden angezeigt, Teilinstallationen nicht +blind repariert. Der Helfer muss apt und Maintainer-Skripte privilegiert ausführen; +diese Berechtigung wird auf vorhandenen Athena-Installationen nicht aktiviert. + +Tests decken API-Schutz, Label-Filter, verbotene Helferaktionen, feste Paketbefehle, +Installationsfehler und Bestandsschutz ab. Ein echter Erstinstallationslauf auf +einem frischen Debian-Host steht noch aus; auf Athena wird dafür nichts ersetzt. + +API: `GET /api/v1/docker`, `POST /api/v1/docker/install` mit `{"confirm": true}`. +Referenz: https://docs.docker.com/engine/install/debian/ diff --git a/index.html b/index.html index 24952ab..30534f0 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.6 · Entwicklung
+Athena Deck
ATHENA CONTROL SURFACEv0.6 · Entwicklung
diff --git a/server.py b/server.py index 7e86441..aaf8109 100644 --- a/server.py +++ b/server.py @@ -12,6 +12,7 @@ from capacity import assess, overview from runtime import Runtime from image_runtime import ImageRuntime from image_test import ImageTests +from docker_support import DockerSupport from urllib.parse import urlsplit, parse_qs from network.client import NetworkClient from network.config import parse_config, ConfigError @@ -108,6 +109,7 @@ class Server(ThreadingHTTPServer): self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime") self.image_tests.runtime = self.image_runtime self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"] + self.docker = DockerSupport() self.demo = DemoService() self.hardware = HardwareProvider() self.started = time.time() @@ -271,12 +273,13 @@ class Handler(BaseHTTPRequestHandler): return self.respond({'error':'Anmeldung erforderlich.'},401) if not self.authenticated() and self.path == '/': return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') - routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')} + routes = {'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')} if self.path in routes: name, mime = routes[self.path] return self.respond((ROOT/name).read_bytes(), mime=mime) if self.path == '/api/v1/status': return self.respond(dict(name='Athena Deck', version='0.6.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), demo=self.server.demo.status())) + if self.path == '/api/v1/docker':return self.respond(self.server.docker.status()) if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status()) if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status()) if urlsplit(self.path).path == '/api/v1/image-tests/image': @@ -344,7 +347,13 @@ class Handler(BaseHTTPRequestHandler): with self.server.auth_lock: self.server.sessions.pop(self.session_token(),None) return self.respond({'logged_out':True}) + if self.path == '/api/v1/docker/install': + try: + if self.read_json()!={'confirm':True}:raise ValueError('Die Docker-Erstinstallation muss ausdrücklich bestätigt werden.') + return self.respond(self.server.docker.install()) + except (OSError,ValueError) as exc:return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Docker-Systemhelfer nicht erreichbar.'},400) if self.path.startswith('/api/v1/runtime/'): + try: data=self.read_json();action=self.path.rsplit('/',1)[1] if action=='build' and set(data)=={'revision','backend','jobs'}:return self.respond(self.server.runtime.start(**data)) diff --git a/studio.js b/studio.js index e8e9a64..b681d80 100644 --- a/studio.js +++ b/studio.js @@ -6,8 +6,9 @@ const Studio=(()=>{ if(!force&&document.querySelector('#studio')?.dataset.page===page)return; if(category!==page){category=page;section='discover';} if(modelId)section='profiles'; - const body=page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.

${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='image'?[['test','Testen']]:[]),['running','Laufend']].map(([id,label])=>``).join('')}
${section==='test'?ImageTestUI.html():section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; + const body=['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.

${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='image'?[['test','Testen']]:[]),['running','Laufend']].map(([id,label])=>``).join('')}
${section==='test'?ImageTestUI.html():section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; document.querySelector('#view').innerHTML=`
${body}
`; + if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;} if(page==='runtime'){RuntimeUI.bind();return;} if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;} if(page==='runtimes')return; @@ -16,7 +17,7 @@ const Studio=(()=>{ else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads'); document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);}); } - function runtimeOverview(){return `
EINSTELLUNGEN

Laufzeiten

Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.

llama.cpp

Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →
`;} + function runtimeOverview(){return `
EINSTELLUNGEN

Laufzeiten

Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.

llama.cpp

Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →

Docker

Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.

Docker öffnen →
`;} function imageRuntime(){return `
EINSTELLUNGEN / LAUFZEITEN

Bildgenerierung

ComfyUI + ComfyUI-GGUF

Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.

Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.

Installationsstatus wird geprüft …

Bildgenerierung → Testen öffnen
${ImageTestUI.html(true)}`;} function openProfile(page,modelId){render(page,true,modelId);} return {render,openProfile}; diff --git a/test_docker_support.py b/test_docker_support.py new file mode 100644 index 0000000..53e9e78 --- /dev/null +++ b/test_docker_support.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch,Mock +from docker_support import DockerSupport +from deploy.docker_helper import Manager,MANAGED_LABEL,ROLE_LABEL,PACKAGES + +class DockerTests(unittest.TestCase): + def test_absent_helper_is_not_reported_as_absent_docker(self): + with patch.object(DockerSupport,'call',side_effect=OSError()): + value=DockerSupport().status();self.assertIsNone(value['installed']);self.assertFalse(value['helper_available']);self.assertEqual(value['services'],[]) + def test_inventory_requires_both_exact_labels_and_exposes_no_inspect_data(self): + with tempfile.TemporaryDirectory() as d,patch('deploy.docker_helper.run',return_value=Mock(returncode=0,stdout='a'*64+'\tstudio\timage:v1\trunning\tUp 1 minute\n')) as run: + rows=Manager(d).inventory();args=run.call_args.args[0] + self.assertIn('label='+MANAGED_LABEL,args);self.assertIn('label='+ROLE_LABEL,args) + self.assertEqual(set(rows[0]),{'id','name','image','state','status'}) + self.assertNotIn('inspect',args) + def test_arbitrary_helper_actions_are_rejected(self): + with tempfile.TemporaryDirectory() as d: + manager=Manager(d) + for data in ({'action':'exec','command':'id'},{'action':'stop'},[],{'action':'install','args':['--privileged']}): + with self.assertRaises(ValueError):manager.dispatch(data) + def test_existing_docker_is_never_reinstalled(self): + with tempfile.TemporaryDirectory() as d,patch('deploy.docker_helper.release',return_value={'ID':'debian','VERSION_ID':'13'}),patch('deploy.docker_helper.Path.exists',return_value=True),patch('deploy.docker_helper.threading.Thread') as thread: + manager=Manager.__new__(Manager);manager.allow_install=True;manager.lock=__import__('threading').RLock();manager.job=None + with self.assertRaises(ValueError):manager.install() + thread.assert_not_called() + def test_missing_authorization_prevents_install(self): + with tempfile.TemporaryDirectory() as d,patch('deploy.docker_helper.release',return_value={'ID':'debian','VERSION_ID':'13'}),patch('deploy.docker_helper.Path.exists',return_value=False): + manager=Manager(d);self.assertFalse(manager.preflight()[0]) + with self.assertRaises(ValueError):manager.install() + def test_install_uses_fixed_official_packages_and_checks_compose(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);(root/'etc/apt/sources.list.d').mkdir(parents=True) + manager=Manager(root/'state',True);manager.job={'state':'running'};commands=[] + def path(value):return root/str(value).lstrip('/') + def checked(args): + commands.append(args) + if args[0]=='/usr/bin/curl':Path(args[-1]).touch() + with patch('deploy.docker_helper.release',return_value={'ID':'debian','VERSION_ID':'13'}),patch('deploy.docker_helper.run',return_value=Mock(stdout='amd64\n')),patch('deploy.docker_helper.Path',side_effect=path),patch.object(manager,'_checked',side_effect=checked):manager._install() + self.assertEqual(manager.job['state'],'complete') + self.assertIn(['/usr/bin/apt-get','install','-y','--no-remove',*PACKAGES],commands) + self.assertIn(['/usr/bin/docker','compose','version','--short'],commands) + self.assertFalse(any('restart' in c or 'reboot' in c for c in commands)) + def test_package_failure_does_not_claim_success_or_reboot(self): + with tempfile.TemporaryDirectory() as d: + manager=Manager(d,True);manager.job={'state':'running'} + with patch('deploy.docker_helper.release',return_value={'VERSION_ID':'13'}),patch('deploy.docker_helper.run',return_value=Mock(stdout='amd64')),patch.object(manager,'_checked',side_effect=ValueError('failed')):manager._install() + self.assertEqual(manager.job['state'],'failed') diff --git a/test_server.py b/test_server.py index e96d546..526175f 100644 --- a/test_server.py +++ b/test_server.py @@ -70,6 +70,19 @@ class Tests(unittest.TestCase): with self.assertRaises(urllib.error.HTTPError) as error:self.request('image-tests/image?id=../../auth.json') self.assertEqual(error.exception.code,404);error.exception.close() + def test_docker_admin_auth_and_explicit_install_confirmation(self): + with patch.object(self.server.docker,'status',return_value={'installed':True,'services':[]}): + self.assertTrue(self.request('docker')['installed']) + with self.assertRaises(urllib.error.HTTPError) as error: + urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/docker',headers={'Authorization':'Bearer '+self.api_token})) + self.assertEqual(error.exception.code,401);error.exception.close() + with patch.object(self.server.docker,'install',return_value={'started':True}) as install: + for payload,expected in [({},400),({'confirm':True},200)]: + req=urllib.request.Request(self.url+'/api/v1/docker/install',data=json.dumps(payload).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'}) + try: + with urllib.request.urlopen(req) as result:self.assertEqual(result.status,expected) + except urllib.error.HTTPError as error:self.assertEqual(error.code,expected);error.close() + install.assert_called_once() def test_control_guard(self): for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}): with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)