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
+8
View File
@@ -138,3 +138,11 @@ Repositories absteigend; sie sortiert nicht nur die beliebtesten 20 Treffer um.
Name A–Z sortiert weiterhin die angezeigte Treffermenge. Name A–Z sortiert weiterhin die angezeigte Treffermenge.
Prüfung vorhandener Studios: [Native Anwendungen und Docker](docs/NATIVE_APPLICATION_AUDIT.md). 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).
+1 -1
View File
@@ -8,7 +8,7 @@ const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</stron
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`; const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
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.']}; 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; 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==='access'){await accessPage();return;}
if(page==='network'){await networkPage();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.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`; 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.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
+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 && /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
WORKDIR /app WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock 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 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 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-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/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ 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 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 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. 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 ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' 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): 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)"' 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. # Explicit isolated development bootstrap, reachable only through host loopback/SSH.
args += ['-e','DECK_REQUIRE_SETUP=0'] 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']: if config['gpu_telemetry']:
args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=compute,utility'] args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=compute,utility']
if config.get('reference_models'): if config.get('reference_models'):
@@ -160,7 +162,8 @@ def install(args):
for sub in ('state','models','backups','bootstrap'): for sub in ('state','models','backups','bootstrap'):
(base/sub).mkdir(mode=0o700) (base/sub).mkdir(mode=0o700)
os.chown(base/'state',65534,65534) 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) write_manifest(base,config)
provision(config) provision(config)
try: 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.') 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) config=load_manifest(base)
previous=owned(config['name'],base) previous=owned(config['name'],base)
if not previous: if not previous:
raise RuntimeError('Kein installierter Deck-Container vorhanden.') raise RuntimeError('Kein installierter Deck-Container vorhanden.')
image=config.get('previous_image') if rollback else build() image=config.get('previous_image') if rollback else build()
if not image:raise RuntimeError('Kein vorheriger Build gespeichert.') 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 print('Dieser Quellstand ist bereits installiert.');return
backup_name=config['name']+'-previous' backup_name=config['name']+'-previous'
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') 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(): def main():
parser=argparse.ArgumentParser(description='Athena Deck auf Debian 12/13 getrennt installieren. Kein WireGuard, keine Host-Paket- oder Treiberänderungen.') 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) 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') actions.add_argument('--'+action,action='store_true')
parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone')) parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone'))
parser.add_argument('--name',default='athena-deck-standalone') parser.add_argument('--name',default='athena-deck-standalone')
@@ -226,7 +229,10 @@ def main():
else: else:
config=load_manifest(args.directory) config=load_manifest(args.directory)
item=owned(config['name'],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('Deck: '+('running' if item and item['State']['Running'] else 'stopped / absent'))
print('URL: http://127.0.0.1:'+str(config['port'])) print('URL: http://127.0.0.1:'+str(config['port']))
elif args.setup: 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()
+15
View File
@@ -0,0 +1,15 @@
window.DockerUI=(()=>{
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 `<div class="kicker">${services?'ANWENDUNGEN':'EINSTELLUNGEN / LAUFZEITEN'}</div><h1>${services?'Weitere Dienste':'Docker'}</h1><p>${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.'}</p><section id="docker-panel"></section><p id="docker-message" role="status"></p>`;}
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=`<section class="card"><h2>Explizite Zuordnung</h2><p>Beide Container-Labels müssen gesetzt sein:</p><pre><code>io.athena-deck.managed=true\nio.athena-deck.role=application</code></pre><p class="small">Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.</p><a class="link" href="#docker-runtime">Docker-Verbindung prüfen →</a></section>${!s.helper_available||!s.reachable?'<section class="empty"><h2>Docker nicht verbunden</h2><p>'+esc(s.message)+'</p></section>':s.services.length?'<div class="grid">'+s.services.map(c=>`<section class="card"><span class="pill">DOCKER · DECK-ANWENDUNG</span><h2>${esc(c.name)}</h2><p>${esc(c.image)}</p><p><strong>${esc(c.state)}</strong> · ${esc(c.status)}</p></section>`).join('')+'</div>':'<section class="empty"><h2>Noch keine Deck-Anwendungen</h2><p>Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.</p></section>'}`;}
else {panel.innerHTML=`<section class="card"><span class="pill">${s.installed?'INSTALLIERT':s.installed===false?'NICHT INSTALLIERT':'NICHT ERMITTELBAR'}</span><h2>Docker Engine ${esc(s.version||'')}</h2><p>${s.reachable?'Daemon erreichbar · Verbindung über begrenzten Systemhelfer':s.helper_available?'Systemhelfer erreichbar · Docker-Daemon nicht erreichbar':'Systemhelfer nicht verbunden'}</p><p>${esc(s.message)}</p>${s.job?`<p role="status">${esc(s.job.state)} · ${esc(s.job.phase)}</p>`:''}${!s.installed&&s.install_supported&&!running?'<label><input type="checkbox" id="docker-install-ack"> Docker Engine, Compose und Buildx auf diesem Server installieren. Docker richtet einen Dienst und Netzwerk-/Firewallregeln ein.</label><p><button id="docker-install" disabled>Docker installieren</button></p>':`<button disabled>${running?'Installation läuft …':s.installed?'Docker bereits vorhanden':'Installation hier nicht verfügbar'}</button>`}<p class="small">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.</p><a class="link" href="#services">Weitere Dienste öffnen →</a></section>`;
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};
})();
+22
View File
@@ -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')
+79
View File
@@ -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/
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/studio.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.6 · Entwicklung</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html> <!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/studio.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.6 · Entwicklung</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
+10 -1
View File
@@ -12,6 +12,7 @@ from capacity import assess, overview
from runtime import Runtime from runtime import Runtime
from image_runtime import ImageRuntime from image_runtime import ImageRuntime
from image_test import ImageTests from image_test import ImageTests
from docker_support import DockerSupport
from urllib.parse import urlsplit, parse_qs from urllib.parse import urlsplit, parse_qs
from network.client import NetworkClient from network.client import NetworkClient
from network.config import parse_config, ConfigError 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_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime")
self.image_tests.runtime = self.image_runtime self.image_tests.runtime = self.image_runtime
self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"] self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
self.docker = DockerSupport()
self.demo = DemoService() self.demo = DemoService()
self.hardware = HardwareProvider() self.hardware = HardwareProvider()
self.started = time.time() self.started = time.time()
@@ -271,12 +273,13 @@ class Handler(BaseHTTPRequestHandler):
return self.respond({'error':'Anmeldung erforderlich.'},401) return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/': if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') 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: if self.path in routes:
name, mime = routes[self.path] name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime) return self.respond((ROOT/name).read_bytes(), mime=mime)
if self.path == '/api/v1/status': 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())) 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-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 self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
if urlsplit(self.path).path == '/api/v1/image-tests/image': if urlsplit(self.path).path == '/api/v1/image-tests/image':
@@ -344,7 +347,13 @@ class Handler(BaseHTTPRequestHandler):
with self.server.auth_lock: with self.server.auth_lock:
self.server.sessions.pop(self.session_token(),None) self.server.sessions.pop(self.session_token(),None)
return self.respond({'logged_out':True}) 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/'): if self.path.startswith('/api/v1/runtime/'):
try: try:
data=self.read_json();action=self.path.rsplit('/',1)[1] 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)) if action=='build' and set(data)=={'revision','backend','jobs'}:return self.respond(self.server.runtime.start(**data))
+3 -2
View File
@@ -6,8 +6,9 @@ const Studio=(()=>{
if(!force&&document.querySelector('#studio')?.dataset.page===page)return; if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
if(category!==page){category=page;section='discover';} if(category!==page){category=page;section='discover';}
if(modelId)section='profiles'; if(modelId)section='profiles';
const body=page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.</p><div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='image'?[['test','Testen']]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='test'?ImageTestUI.html():section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`; const body=['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.</p><div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='image'?[['test','Testen']]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='test'?ImageTestUI.html():section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`; document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
if(page==='runtime'){RuntimeUI.bind();return;} if(page==='runtime'){RuntimeUI.bind();return;}
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;} if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
if(page==='runtimes')return; if(page==='runtimes')return;
@@ -16,7 +17,7 @@ const Studio=(()=>{
else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads'); 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);}); document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
} }
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section></div>`;} function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section><section class="card"><h2>Docker</h2><p>Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.</p><a class="link" href="#docker-runtime">Docker öffnen →</a></section></div>`;}
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>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.</p><p>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.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;} function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>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.</p><p>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.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
function openProfile(page,modelId){render(page,true,modelId);} function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile}; return {render,openProfile};
+50
View File
@@ -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')
+13
View File
@@ -70,6 +70,19 @@ class Tests(unittest.TestCase):
with self.assertRaises(urllib.error.HTTPError) as error:self.request('image-tests/image?id=../../auth.json') 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() 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): def test_control_guard(self):
for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}): 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) with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)