From 5cc732f9934cd294d5891822bae4fe046f1a5d95 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Mon, 28 Sep 2026 18:48:13 +0200
Subject: [PATCH] Add Docker runtime helper and explicitly labelled service
inventory
---
README.md | 8 ++
app.js | 2 +-
deploy/Dockerfile | 4 +-
deploy/NATIVE_INSTALL_REQUIREMENTS.md | 8 ++
deploy/docker_helper.py | 126 ++++++++++++++++++++++++++
deploy/install.py | 18 ++--
deploy/setup_docker_helper.py | 49 ++++++++++
docker-ui.js | 15 +++
docker_support.py | 22 +++++
docs/DOCKER_SERVICES.md | 79 ++++++++++++++++
index.html | 2 +-
server.py | 11 ++-
studio.js | 5 +-
test_docker_support.py | 50 ++++++++++
test_server.py | 13 +++
15 files changed, 399 insertions(+), 13 deletions(-)
create mode 100644 deploy/docker_helper.py
create mode 100644 deploy/setup_docker_helper.py
create mode 100644 docker-ui.js
create mode 100644 docker_support.py
create mode 100644 docs/DOCKER_SERVICES.md
create mode 100644 test_docker_support.py
diff --git a/README.md b/README.md
index 22bb84c..d24d8ff 100644
--- a/README.md
+++ b/README.md
@@ -138,3 +138,11 @@ Repositories absteigend; sie sortiert nicht nur die beliebtesten 20 Treffer um.
Name A–Z sortiert weiterhin die angezeigte Treffermenge.
Prüfung vorhandener Studios: [Native Anwendungen und Docker](docs/NATIVE_APPLICATION_AUDIT.md).
+
+### Optionale Docker-Anwendungen
+
+Einstellungen → Laufzeiten → Docker zeigt Verbindung, Version und gegebenenfalls
+Docker-Erstinstallation. Weitere Dienste zeigt ausschließlich Container mit
+`io.athena-deck.managed=true` **und** `io.athena-deck.role=application`.
+Bestehende Container werden nicht automatisch übernommen. Details zu Systemhelfer,
+Installer und Grenzen: [Docker-Dienste](docs/DOCKER_SERVICES.md).
diff --git a/app.js b/app.js
index d7a0da5..ca118e8 100644
--- a/app.js
+++ b/app.js
@@ -8,7 +8,7 @@ const metric=(title,value)=>`
${title}${value}v==null?'':``;
const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
-if(['chat','image','audio','video','runtime','runtimes','image-runtime'].includes(page)){Studio.render(page);return;}
+if(['chat','image','audio','video','runtime','runtimes','image-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
if(page==='access'){await accessPage();return;}
if(page==='network'){await networkPage();return;}
if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`
Anwendung
Bereit
Athena Deck läuft auf ${esc(s.location)}.
Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1
Betriebsmodus
Isoliert
Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.
Keine Modell- oder Router-Steuerung angebunden
Demo-Dienst
LOKAL / CPU
Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.
${services?'Zusätzliche Oberflächen werden getrennt von Modellen und Laufzeiten verwaltet. Hier erscheinen ausschließlich ausdrücklich für Deck markierte Anwendungscontainer.':'Optionale Umgebung für zusätzliche Studios. Native Modelllaufzeiten bleiben unabhängig davon.'}
`;}
+ function bind(services=false){const panel=document.querySelector('#docker-panel'),message=document.querySelector('#docker-message');let signature='',pending=false;
+ async function refresh(){try{const s=await api();if(!panel.isConnected)return;const next=JSON.stringify(s);if(signature!==next){signature=next;const running=s.job?.state==='running';
+ if(services){panel.innerHTML=`
Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.
${s.reachable?'Daemon erreichbar · Verbindung über begrenzten Systemhelfer':s.helper_available?'Systemhelfer erreichbar · Docker-Daemon nicht erreichbar':'Systemhelfer nicht verbunden'}
Keine automatische Aktualisierung vorhandener Docker-Installationen. NVIDIA Container Toolkit und Treiber sind separate Voraussetzungen für GPU-Container. Die Paketinstallation wird nicht mitten im Vorgang abgebrochen.
Weitere Dienste öffnen →`;
+ const button=panel.querySelector('#docker-install'),ack=panel.querySelector('#docker-install-ack');if(button){ack.onchange=()=>button.disabled=pending||!ack.checked;button.onclick=async()=>{pending=true;button.disabled=true;try{await api('/install',{confirm:true});message.textContent='Docker-Erstinstallation gestartet.';}catch(error){message.textContent=error.message;}finally{pending=false;signature='';}};}
+ }
+ }}catch(error){if(panel.isConnected){message.textContent=error.message;signature='';}}finally{if(panel.isConnected)setTimeout(refresh,5000);}}
+ refresh();
+ }
+ return {html,bind};
+})();
diff --git a/docker_support.py b/docker_support.py
new file mode 100644
index 0000000..6ee9e61
--- /dev/null
+++ b/docker_support.py
@@ -0,0 +1,22 @@
+"""Narrow client: the web app never receives Docker's daemon socket."""
+import json
+import os
+import socket
+
+MANAGED_LABEL='io.athena-deck.managed=true'
+ROLE_LABEL='io.athena-deck.role=application'
+
+class DockerSupport:
+ def call(self,action):
+ path=os.environ.get('DECK_DOCKER_HELPER_SOCKET','/run/athena-deck-docker/control.sock')
+ with socket.socket(socket.AF_UNIX) as sock:
+ sock.settimeout(20);sock.connect(path);sock.sendall(json.dumps({'action':action}).encode()+b'\n')
+ with sock.makefile('rb') as stream:raw=stream.readline(1048577)
+ if len(raw)>1048576:raise ValueError('Docker-Helferantwort zu groß.')
+ value=json.loads(raw)
+ if 'error' in value:raise ValueError(value['error'])
+ return value
+ def status(self):
+ try:return dict(self.call('status'),helper_available=True)
+ except (OSError,ValueError):return dict(helper_available=False,installed=None,reachable=False,services=[],install_supported=False,job=None,message='Docker-Systemhelfer nicht verbunden. Der Deck-Installer muss ihn auf dem Debian-Host einrichten.',labels=[MANAGED_LABEL,ROLE_LABEL])
+ def install(self):return self.call('install')
diff --git a/docs/DOCKER_SERVICES.md b/docs/DOCKER_SERVICES.md
new file mode 100644
index 0000000..0f722bc
--- /dev/null
+++ b/docs/DOCKER_SERVICES.md
@@ -0,0 +1,79 @@
+# Docker-Laufzeit und Weitere Dienste
+
+Decks Webserver besitzt keinen Docker-Socket. Ein kleiner, root-eigener
+Systemhelfer bietet über `/run/athena-deck-docker/control.sock` ausschließlich
+zwei feste Aktionen: Status/Inventar und Docker-Erstinstallation. Keine freie
+Shell, keine Exec-/Mount-/Container-Erstellungsoperationen. Unix-Dateirechte
+und Linux SO_PEERCRED begrenzen Clients auf root und die eingerichtete Deck-UID.
+Die öffentliche Deck-API verlangt eine Admin-Sitzung, POST zusätzlich den
+bestehenden CSRF-Schutz.
+
+## Explizite Container-Zuordnung
+
+Nur Container mit **beiden exakten Labels** erscheinen unter Weitere Dienste:
+
+```yaml
+services:
+ studio:
+ labels:
+ io.athena-deck.managed: "true"
+ io.athena-deck.role: "application"
+```
+
+`docker ps -a` wird bereits beim Daemon auf diese Labels gefiltert. Es werden nur
+ID, Name, Image, Zustand und Status übertragen; keine Umgebungsvariablen,
+Mounts, Secrets oder Anwendungslogs. Vorhandene Container werden nicht automatisch
+übernommen. Labels sind eine explizite Zuordnung, keine Sicherheitsgrenze gegenüber
+jemandem mit eigenem Docker-Administratorzugriff.
+
+Die erste Ansicht zeigt nur den Bestand. Ein Anwendungskatalog sowie Installation,
+Start/Stopp, Öffnen und Deinstallation einzelner Studios folgen separat mit
+anwendungsspezifischen Rezepten. Keine dekorativen, funktionslosen Aktionsknöpfe.
+Gewichte und Nutzerdaten werden durch diese Funktion nicht verändert.
+
+## Bestehende Docker-Testinstallation anbinden
+
+```sh
+sudo ./install.sh --docker-helper --directory /opt/athena-deck-dev/runtime
+```
+
+Richtet ausschließlich `athena-deck-docker-helper.service` ein und erneuert Deck,
+um das private Helfer-Socketverzeichnis einzubinden. Kein Docker-Daemon-Neustart,
+keine Paketinstallation. Der Helfer läuft hier mit schreibgeschütztem System und
+**ohne Installationsrecht**. Bestehendes Docker wird nur erkannt und verwendet.
+Neue Docker-Testinstallationen richten diese lesende Anbindung automatisch ein.
+
+## Optionales Nachinstallieren auf nativem Debian
+
+Der native Deck-Basisinstaller muss den Helfer mit der tatsächlichen UID/GID des
+Deck-Service und ausdrücklich mit Installationsrecht provisionieren:
+
+```sh
+sudo python3 deploy/setup_docker_helper.py --client-uid DECK_UID --client-gid DECK_GID --allow-install
+```
+
+Die Flags DECK_UID/DECK_GID durch die numerischen Werte ersetzen. Der vollständige
+native Basisinstaller ist weiterhin noch nicht fertig; dieser Bootstrap ist separat.
+Anschließend bietet Einstellungen → Laufzeiten → Docker die Erstinstallation an.
+Vor Auslösung zeigt die GUI die konkreten Auswirkungen und verlangt die bewusste
+Bestätigung der Installation samt Dienst und Docker-Netzwerk-/Firewallregeln.
+
+Unterstützt Debian 12/13, amd64/arm64. Installiert aus dem offiziellen signierten
+Docker-APT-Repository: docker-ce, docker-ce-cli, containerd.io, Buildx und Compose.
+Vorhandene Docker-/Containerd-Pakete, Konfigurationen oder Daten führen zum Abbruch
+vor Änderungen. Kein Entfernen von Konfliktpaketen, kein Upgrade bestehender
+Docker-Installationen, keine Treiberinstallation und kein Host-Reboot. NVIDIA
+Container Toolkit ist für GPU-Container separat erforderlich. Bereits vorhandenes
+Docker wird nicht über diese Oberfläche aktualisiert.
+
+Paketinstallation läuft serverseitig weiter, wenn die Seite geschlossen wird.
+Kein Abbrechen mitten in dpkg; Fehler werden angezeigt, Teilinstallationen nicht
+blind repariert. Der Helfer muss apt und Maintainer-Skripte privilegiert ausführen;
+diese Berechtigung wird auf vorhandenen Athena-Installationen nicht aktiviert.
+
+Tests decken API-Schutz, Label-Filter, verbotene Helferaktionen, feste Paketbefehle,
+Installationsfehler und Bestandsschutz ab. Ein echter Erstinstallationslauf auf
+einem frischen Debian-Host steht noch aus; auf Athena wird dafür nichts ersetzt.
+
+API: `GET /api/v1/docker`, `POST /api/v1/docker/install` mit `{"confirm": true}`.
+Referenz: https://docs.docker.com/engine/install/debian/
diff --git a/index.html b/index.html
index 24952ab..30534f0 100644
--- a/index.html
+++ b/index.html
@@ -1 +1 @@
-Athena DeckATHENA CONTROL SURFACEv0.6 · Entwicklung
+Athena DeckATHENA CONTROL SURFACEv0.6 · Entwicklung
diff --git a/server.py b/server.py
index 7e86441..aaf8109 100644
--- a/server.py
+++ b/server.py
@@ -12,6 +12,7 @@ from capacity import assess, overview
from runtime import Runtime
from image_runtime import ImageRuntime
from image_test import ImageTests
+from docker_support import DockerSupport
from urllib.parse import urlsplit, parse_qs
from network.client import NetworkClient
from network.config import parse_config, ConfigError
@@ -108,6 +109,7 @@ class Server(ThreadingHTTPServer):
self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime")
self.image_tests.runtime = self.image_runtime
self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
+ self.docker = DockerSupport()
self.demo = DemoService()
self.hardware = HardwareProvider()
self.started = time.time()
@@ -271,12 +273,13 @@ class Handler(BaseHTTPRequestHandler):
return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
- routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
+ routes = {'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
if self.path in routes:
name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime)
if self.path == '/api/v1/status':
return self.respond(dict(name='Athena Deck', version='0.6.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), demo=self.server.demo.status()))
+ if self.path == '/api/v1/docker':return self.respond(self.server.docker.status())
if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status())
if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
if urlsplit(self.path).path == '/api/v1/image-tests/image':
@@ -344,7 +347,13 @@ class Handler(BaseHTTPRequestHandler):
with self.server.auth_lock:
self.server.sessions.pop(self.session_token(),None)
return self.respond({'logged_out':True})
+ if self.path == '/api/v1/docker/install':
+ try:
+ if self.read_json()!={'confirm':True}:raise ValueError('Die Docker-Erstinstallation muss ausdrücklich bestätigt werden.')
+ return self.respond(self.server.docker.install())
+ except (OSError,ValueError) as exc:return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Docker-Systemhelfer nicht erreichbar.'},400)
if self.path.startswith('/api/v1/runtime/'):
+
try:
data=self.read_json();action=self.path.rsplit('/',1)[1]
if action=='build' and set(data)=={'revision','backend','jobs'}:return self.respond(self.server.runtime.start(**data))
diff --git a/studio.js b/studio.js
index e8e9a64..b681d80 100644
--- a/studio.js
+++ b/studio.js
@@ -6,8 +6,9 @@ const Studio=(()=>{
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
if(category!==page){category=page;section='discover';}
if(modelId)section='profiles';
- const body=page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG
${labels[page]}
Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.
Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.
Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.
Installationsstatus wird geprüft …
Bildgenerierung → Testen öffnen${ImageTestUI.html(true)}`;}
function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile};
diff --git a/test_docker_support.py b/test_docker_support.py
new file mode 100644
index 0000000..53e9e78
--- /dev/null
+++ b/test_docker_support.py
@@ -0,0 +1,50 @@
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch,Mock
+from docker_support import DockerSupport
+from deploy.docker_helper import Manager,MANAGED_LABEL,ROLE_LABEL,PACKAGES
+
+class DockerTests(unittest.TestCase):
+ def test_absent_helper_is_not_reported_as_absent_docker(self):
+ with patch.object(DockerSupport,'call',side_effect=OSError()):
+ value=DockerSupport().status();self.assertIsNone(value['installed']);self.assertFalse(value['helper_available']);self.assertEqual(value['services'],[])
+ def test_inventory_requires_both_exact_labels_and_exposes_no_inspect_data(self):
+ with tempfile.TemporaryDirectory() as d,patch('deploy.docker_helper.run',return_value=Mock(returncode=0,stdout='a'*64+'\tstudio\timage:v1\trunning\tUp 1 minute\n')) as run:
+ rows=Manager(d).inventory();args=run.call_args.args[0]
+ self.assertIn('label='+MANAGED_LABEL,args);self.assertIn('label='+ROLE_LABEL,args)
+ self.assertEqual(set(rows[0]),{'id','name','image','state','status'})
+ self.assertNotIn('inspect',args)
+ def test_arbitrary_helper_actions_are_rejected(self):
+ with tempfile.TemporaryDirectory() as d:
+ manager=Manager(d)
+ for data in ({'action':'exec','command':'id'},{'action':'stop'},[],{'action':'install','args':['--privileged']}):
+ with self.assertRaises(ValueError):manager.dispatch(data)
+ def test_existing_docker_is_never_reinstalled(self):
+ with tempfile.TemporaryDirectory() as d,patch('deploy.docker_helper.release',return_value={'ID':'debian','VERSION_ID':'13'}),patch('deploy.docker_helper.Path.exists',return_value=True),patch('deploy.docker_helper.threading.Thread') as thread:
+ manager=Manager.__new__(Manager);manager.allow_install=True;manager.lock=__import__('threading').RLock();manager.job=None
+ with self.assertRaises(ValueError):manager.install()
+ thread.assert_not_called()
+ def test_missing_authorization_prevents_install(self):
+ with tempfile.TemporaryDirectory() as d,patch('deploy.docker_helper.release',return_value={'ID':'debian','VERSION_ID':'13'}),patch('deploy.docker_helper.Path.exists',return_value=False):
+ manager=Manager(d);self.assertFalse(manager.preflight()[0])
+ with self.assertRaises(ValueError):manager.install()
+ def test_install_uses_fixed_official_packages_and_checks_compose(self):
+ with tempfile.TemporaryDirectory() as d:
+ root=Path(d);(root/'etc/apt/sources.list.d').mkdir(parents=True)
+ manager=Manager(root/'state',True);manager.job={'state':'running'};commands=[]
+ def path(value):return root/str(value).lstrip('/')
+ def checked(args):
+ commands.append(args)
+ if args[0]=='/usr/bin/curl':Path(args[-1]).touch()
+ with patch('deploy.docker_helper.release',return_value={'ID':'debian','VERSION_ID':'13'}),patch('deploy.docker_helper.run',return_value=Mock(stdout='amd64\n')),patch('deploy.docker_helper.Path',side_effect=path),patch.object(manager,'_checked',side_effect=checked):manager._install()
+ self.assertEqual(manager.job['state'],'complete')
+ self.assertIn(['/usr/bin/apt-get','install','-y','--no-remove',*PACKAGES],commands)
+ self.assertIn(['/usr/bin/docker','compose','version','--short'],commands)
+ self.assertFalse(any('restart' in c or 'reboot' in c for c in commands))
+ def test_package_failure_does_not_claim_success_or_reboot(self):
+ with tempfile.TemporaryDirectory() as d:
+ manager=Manager(d,True);manager.job={'state':'running'}
+ with patch('deploy.docker_helper.release',return_value={'VERSION_ID':'13'}),patch('deploy.docker_helper.run',return_value=Mock(stdout='amd64')),patch.object(manager,'_checked',side_effect=ValueError('failed')):manager._install()
+ self.assertEqual(manager.job['state'],'failed')
diff --git a/test_server.py b/test_server.py
index e96d546..526175f 100644
--- a/test_server.py
+++ b/test_server.py
@@ -70,6 +70,19 @@ class Tests(unittest.TestCase):
with self.assertRaises(urllib.error.HTTPError) as error:self.request('image-tests/image?id=../../auth.json')
self.assertEqual(error.exception.code,404);error.exception.close()
+ def test_docker_admin_auth_and_explicit_install_confirmation(self):
+ with patch.object(self.server.docker,'status',return_value={'installed':True,'services':[]}):
+ self.assertTrue(self.request('docker')['installed'])
+ with self.assertRaises(urllib.error.HTTPError) as error:
+ urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/docker',headers={'Authorization':'Bearer '+self.api_token}))
+ self.assertEqual(error.exception.code,401);error.exception.close()
+ with patch.object(self.server.docker,'install',return_value={'started':True}) as install:
+ for payload,expected in [({},400),({'confirm':True},200)]:
+ req=urllib.request.Request(self.url+'/api/v1/docker/install',data=json.dumps(payload).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
+ try:
+ with urllib.request.urlopen(req) as result:self.assertEqual(result.status,expected)
+ except urllib.error.HTTPError as error:self.assertEqual(error.code,expected);error.close()
+ install.assert_called_once()
def test_control_guard(self):
for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}):
with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)