From f66e7fbf713b0fc20a66b9ccdbe2724a2a2fde2d Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Thu, 1 Oct 2026 10:11:58 +0200
Subject: [PATCH] Add scoped start stop and delete actions for application
containers
---
STUDIO.md | 10 ++++++++++
deploy/docker_helper.py | 13 +++++++++++++
docker-ui.js | 18 ++++++++++++++----
docker_support.py | 4 ++++
server.py | 7 +++++++
test_docker_support.py | 17 +++++++++++++++++
test_server.py | 17 +++++++++++++++++
7 files changed, 82 insertions(+), 4 deletions(-)
diff --git a/STUDIO.md b/STUDIO.md
index 52caa46..d46ede0 100644
--- a/STUDIO.md
+++ b/STUDIO.md
@@ -281,3 +281,13 @@ Technische Konfiguration und Profilfreigaben bleiben unter Einstellungen → End
Für die derzeit unterstützten YuE2-Musikprofile führt die Steuerung direkt zu
LadyPoly auf Port 8127, analog zum LTX-DeskWEB-Link bei Video. Die Oberfläche
kann bereits vor dem Start geöffnet werden; Generierung benötigt Musikbetrieb.
+
+### Anwendungscontainer verwalten
+
+Weitere Dienste bietet Starten, Stoppen und Löschen für Container mit beiden
+Deck-Anwendungslabels. Der begrenzte Systemhelfer prüft die aktuelle Zuordnung
+bei jeder Aktion anhand der vollständigen Container-ID. Registrierte Video-
+Laufzeiten müssen weiter über die Steuerung verwaltet werden. Löschen benötigt
+einen gestoppten Container und eine Bestätigung; weder Images noch Volumes
+werden gelöscht. Daten nur in der beschreibbaren Container-Schicht gehen verloren.
+Es gibt keine pauschalen Stop-, Prune- oder Löschaktionen für den Docker-Stack.
diff --git a/deploy/docker_helper.py b/deploy/docker_helper.py
index ee3de86..a6790fe 100644
--- a/deploy/docker_helper.py
+++ b/deploy/docker_helper.py
@@ -141,8 +141,21 @@ class Manager:
result=run(['/usr/bin/docker','start',config['container_id']] if start else ['/usr/bin/docker','stop','--time','30',config['container_id']],timeout=45)
if result.returncode:raise ValueError('Videodienst konnte nicht gestartet oder beendet werden.')
return self.video_status(config)
+ def container_action(self,ident,action):
+ if action not in ('start','stop','delete') or not isinstance(ident,str) or not re.fullmatch('[a-f0-9]{64}',ident):raise ValueError('Ungültige Containeraktion.')
+ with self.lock:
+ row=next((c for c in self.inventory() if c['id']==ident),None)
+ if row is None:raise ValueError('Container nicht für Deck freigegeben oder nicht mehr vorhanden.')
+ if any(c['container_id']==ident for c in self.video_configs()):raise ValueError('Registrierte Video-Laufzeiten über die Steuerung verwalten.')
+ if action=='delete' and row['state'] not in ('exited','created','dead'):raise ValueError('Container vor dem Löschen stoppen.')
+ args=['/usr/bin/docker']+(['stop','--time','30',ident] if action=='stop' else ['rm',ident] if action=='delete' else ['start',ident])
+ result=run(args,timeout=45)
+ if result.returncode:raise ValueError('Containeraktion fehlgeschlagen. Status erneut prüfen.')
+ return dict(changed=True,action=action,id=ident)
def dispatch(self,data):
if not isinstance(data,dict):raise ValueError('Ungültige Helferanfrage.')
+ if set(data)=={'action','service'} and data['action'] in ('container-start','container-stop','container-delete'):
+ return self.container_action(data['service'],data['action'].removeprefix('container-'))
if data.get('action')=='backup-export' and set(data)=={'action'}:
import docker_backup
return docker_backup.export(self)
diff --git a/docker-ui.js b/docker-ui.js
index 0c13499..4771471 100644
--- a/docker-ui.js
+++ b/docker-ui.js
@@ -2,13 +2,23 @@ window.DockerUI=(()=>{
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
async function api(action='',data){const r=await fetch('/api/v1/docker'+action,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});if(r.status===401){location.reload();throw Error('Bitte erneut anmelden.');}const value=await r.json();if(!r.ok)throw Error(value.error||'Docker-Anfrage fehlgeschlagen');return value;}
function html(services=false){return `
${services?'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.
Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Starten und Stoppen betrifft nur markierte Container. Löschen entfernt den Container einschließlich seiner beschreibbaren Schicht; Images und Volumes bleiben erhalten.
${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);}}
+ }
+ if(services)panel.querySelectorAll('[data-container-action]').forEach(b=>b.onclick=async()=>{
+ const action=b.dataset.containerAction,id=b.dataset.containerId,c=s.services.find(c=>c.id===id);
+ if(pending||!c)return;
+ if(action==='delete'&&!confirm(`Container „${c.name}“ endgültig löschen? Die beschreibbare Container-Schicht geht verloren. Images und Volumes bleiben erhalten.`))return;
+ pending=true;panel.querySelectorAll('button').forEach(x=>x.disabled=true);message.textContent=action==='stop'?'Container wird gestoppt (bis zu 30 Sekunden) …':action==='start'?'Container wird gestartet …':'Container wird gelöscht …';
+ try{await api('/'+action,{id,...(action==='delete'?{confirm:true}:{})});message.textContent=action==='delete'?'Container gelöscht. Images und Volumes bleiben erhalten.':action==='start'?'Container gestartet.':'Container gestoppt.';}
+ catch(error){message.textContent=error.message;}
+ finally{pending=false;signature='';if(panel.isConnected)refresh();}
+ });
+ }catch(error){if(panel.isConnected){message.textContent=error.message;signature='';}}finally{if(panel.isConnected)timer=setTimeout(refresh,5000);}}
refresh();
}
return {html,bind};
diff --git a/docker_support.py b/docker_support.py
index 61cbcf1..511bee4 100644
--- a/docker_support.py
+++ b/docker_support.py
@@ -20,3 +20,7 @@ class DockerSupport:
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')
+
+ def container_action(self,action,ident):
+ if action not in ('start','stop','delete'):raise ValueError('Ungültige Containeraktion.')
+ return self.call('container-'+action,ident)
diff --git a/server.py b/server.py
index 7310eeb..8b71156 100644
--- a/server.py
+++ b/server.py
@@ -488,6 +488,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 in ('/api/v1/docker/start','/api/v1/docker/stop','/api/v1/docker/delete'):
+ try:
+ data=self.read_json();action=self.path.rsplit('/',1)[1]
+ expected={'id','confirm'} if action=='delete' else {'id'}
+ if set(data)!=expected or (action=='delete' and data['confirm'] is not True):raise ValueError('Ungültige oder unbestätigte Containeraktion.')
+ return self.respond(self.server.docker.container_action(action,data['id']))
+ except (OSError,ValueError) as exc:return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Docker-Systemhelfer nicht erreichbar.'},400)
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.')
diff --git a/test_docker_support.py b/test_docker_support.py
index 9b92431..154fb48 100644
--- a/test_docker_support.py
+++ b/test_docker_support.py
@@ -21,6 +21,23 @@ class DockerTests(unittest.TestCase):
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_container_actions_require_current_labelled_full_id(self):
+ with tempfile.TemporaryDirectory() as d:
+ manager=Manager(d);ident='a'*64
+ with patch.object(manager,'inventory',return_value=[]),patch('deploy.docker_helper.run') as run:
+ with self.assertRaises(ValueError):manager.container_action(ident,'stop')
+ run.assert_not_called()
+ with patch.object(manager,'inventory',return_value=[dict(id=ident,state='running')]),patch.object(manager,'video_configs',return_value=[]),patch('deploy.docker_helper.run') as run:
+ with self.assertRaises(ValueError):manager.container_action(ident,'delete')
+ run.assert_not_called()
+ with patch.object(manager,'inventory',return_value=[dict(id=ident,state='exited')]),patch.object(manager,'video_configs',return_value=[]),patch('deploy.docker_helper.run',return_value=Mock(returncode=0)) as run:
+ for action,command in [('start',['start',ident]),('stop',['stop','--time','30',ident]),('delete',['rm',ident])]:
+ self.assertTrue(manager.dispatch({'action':'container-'+action,'service':ident})['changed'])
+ self.assertEqual(run.call_args.args[0],['/usr/bin/docker']+command)
+ with patch.object(manager,'inventory',return_value=[dict(id=ident,state='exited')]),patch.object(manager,'video_configs',return_value=[dict(container_id=ident)]),patch('deploy.docker_helper.run') as run:
+ with self.assertRaises(ValueError):manager.container_action(ident,'start')
+ run.assert_not_called()
+
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
diff --git a/test_server.py b/test_server.py
index c3bf382..1574fc3 100644
--- a/test_server.py
+++ b/test_server.py
@@ -41,6 +41,23 @@ class Tests(unittest.TestCase):
self.assertEqual(runtime.call_count,1)
self.assertTrue(all('execution' in row and 'used_by_profiles' in row for row in result['entries']))
+ def test_container_actions_require_admin_and_delete_confirmation(self):
+ ident='a'*64
+ with patch.object(self.server.docker,'container_action',return_value={'changed':True}) as action:
+ for operation in ('start','stop','delete'):
+ data={'id':ident,**({'confirm':True} if operation=='delete' else {})}
+ req=urllib.request.Request(self.url+'/api/v1/docker/'+operation,data=json.dumps(data).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
+ with urllib.request.urlopen(req) as response:self.assertTrue(json.load(response)['changed'])
+ action.assert_called_with(operation,ident)
+ count=action.call_count
+ req=urllib.request.Request(self.url+'/api/v1/docker/delete',data=json.dumps({'id':ident}).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
+ with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
+ self.assertEqual(error.exception.code,400);error.exception.close()
+ req=urllib.request.Request(self.url+'/api/v1/docker/start',data=json.dumps({'id':ident}).encode(),headers={'X-Athena-Deck':'1','Content-Type':'application/json'})
+ with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
+ self.assertEqual(error.exception.code,401);error.exception.close()
+ self.assertEqual(action.call_count,count)
+
def test_demo_removed_from_status(self):
state=self.request('status')
self.assertNotIn('demo',state)