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?'ANWENDUNGEN':'EINSTELLUNGEN / LAUFZEITEN'}

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

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

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

Explizite Zuordnung

Beide Container-Labels müssen gesetzt sein:

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

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

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

Docker nicht verbunden

'+esc(s.message)+'

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

${esc(c.name)}

${esc(c.image)}

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

${c.name==='athena-ladypoly'?`

LadyPoly YuE2 · Oberfläche für Decks Musiklaufzeit

LadyPoly öffnen →

Zuerst Deck-Verbindung einrichten und in der Übersicht Musik aktivieren. SSH-Tunnel: Port 8127.

`:''}${c.name==='athena-swarm-ui'?`

API By URL · Deck steuert die Video-Laufzeit

SwarmUI öffnen →

In Deck zuerst Video aktivieren. Zugriff über den SSH-Tunnel auf Port 8125.

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

Noch keine Deck-Anwendungen

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

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

Explizite Zuordnung

Beide Container-Labels müssen gesetzt sein:

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

Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Starten und Stoppen betrifft nur markierte Container. Löschen entfernt den Container einschließlich seiner beschreibbaren Schicht; Images und Volumes bleiben erhalten.

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

Docker nicht verbunden

'+esc(s.message)+'

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

${esc(c.name)}

${esc(c.image)}

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

${c.name==='athena-ladypoly'?`

LadyPoly YuE2 · Oberfläche für Decks Musiklaufzeit

LadyPoly öffnen →

Zuerst Deck-Verbindung einrichten und in der Übersicht Musik aktivieren. SSH-Tunnel: Port 8127.

`:''}${c.name==='athena-swarm-ui'?`

API By URL · Deck steuert die Video-Laufzeit

SwarmUI öffnen →

In Deck zuerst Video aktivieren. Zugriff über den SSH-Tunnel auf Port 8125.

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

Noch keine Deck-Anwendungen

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

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

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

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

${esc(s.message)}

${s.job?`

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

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

':``}

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

Weitere Dienste öffnen →
`; const button=panel.querySelector('#docker-install'),ack=panel.querySelector('#docker-install-ack');if(button){ack.onchange=()=>button.disabled=pending||!ack.checked;button.onclick=async()=>{pending=true;button.disabled=true;try{await api('/install',{confirm:true});message.textContent='Docker-Erstinstallation gestartet.';}catch(error){message.textContent=error.message;}finally{pending=false;signature='';}};} } - }}catch(error){if(panel.isConnected){message.textContent=error.message;signature='';}}finally{if(panel.isConnected)setTimeout(refresh,5000);}} + } + 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)