Add Docker runtime helper and explicitly labelled service inventory
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 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.')+`<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
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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 `<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};
|
||||
})();
|
||||
@@ -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')
|
||||
@@ -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
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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():`<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>`;
|
||||
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 `<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 openProfile(page,modelId){render(page,true,modelId);}
|
||||
return {render,openProfile};
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user