103 lines
6.0 KiB
Python
103 lines
6.0 KiB
Python
"""Unprivileged, pinned image-runtime installation in Deck's own state directory."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
COMFY_REV='8d534945ebd53cff61e8def81757c6a6c1b9cf2d'
|
|
GGUF_REV='373048b8403a7820620065210a691263d4da0a61'
|
|
BUNDLED=(Path('/opt/deck-image-python/bin/python'),Path('/opt/deck-comfy'))
|
|
|
|
class ImageRuntime:
|
|
def __init__(self,root):
|
|
self.root=Path(root);self.lock=threading.RLock();self.cancel=threading.Event();self.process=None;self.worker=None;self.job=None
|
|
if (self.root/'installation.json').is_file():
|
|
self.job=json.loads((self.root/'installation.json').read_text())
|
|
if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Installation unterbrochen; erneut installieren.')
|
|
def paths(self):
|
|
marker=self.root/'active.json'
|
|
if marker.is_file():
|
|
ident=json.loads(marker.read_text()).get('id','')
|
|
if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):
|
|
base=self.root/ident
|
|
if (base/'ready').is_file():return base/'python/bin/python',base/'comfy'
|
|
return BUNDLED
|
|
def status(self):
|
|
with self.lock:
|
|
python,comfy=self.paths()
|
|
installed=python.is_file() and (comfy/'main.py').is_file()
|
|
return dict(installed=installed,origin='Deck-Installer' if (python,comfy)==BUNDLED else 'GUI-Installation',job=dict(self.job) if self.job else None,comfy_revision=COMFY_REV,gguf_revision=GGUF_REV,required_disk_gib=25)
|
|
def _save(self):
|
|
p=self.root/'installation.tmp';p.write_text(json.dumps(self.job));p.replace(self.root/'installation.json')
|
|
def _phase(self,text):
|
|
with self.lock:self.job['phase']=text;self._save()
|
|
def start(self):
|
|
with self.lock:
|
|
if self.job and self.job['state']=='running':raise ValueError('Eine Installation läuft bereits.')
|
|
if self.status()['installed']:raise ValueError('Die Bildlaufzeit ist bereits installiert und einsatzbereit.')
|
|
if sys.platform!='linux' or not shutil.which('git'):raise ValueError('Debian/Linux mit Python venv und Git erforderlich. Systemvoraussetzungen über den Deck-Installer bereitstellen.')
|
|
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
|
if shutil.disk_usage(self.root).free<25*1024**3:raise ValueError('Mindestens 25 GiB freier Speicher für die Bildlaufzeit erforderlich.')
|
|
self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Installation wird vorbereitet',started_at=time.time())
|
|
self._save();self.worker=threading.Thread(target=self._run,daemon=True);self.worker.start();return self.status()
|
|
def stop(self):
|
|
with self.lock:
|
|
self.cancel.set();process=self.process
|
|
if process and process.poll() is None:
|
|
try:os.killpg(process.pid,signal.SIGTERM)
|
|
except ProcessLookupError:pass
|
|
if self.worker:self.worker.join(timeout=7)
|
|
return {'cancellation_requested':True}
|
|
def _command(self,args):
|
|
with self.lock:
|
|
if self.cancel.is_set():raise InterruptedError()
|
|
temporary=self.root/self.job['id']/'tmp';temporary.mkdir(exist_ok=True)
|
|
env=dict(os.environ,TMPDIR=str(temporary),PIP_NO_INPUT='1',PIP_DISABLE_PIP_VERSION_CHECK='1',GIT_TERMINAL_PROMPT='0',OMP_NUM_THREADS='2')
|
|
self.process=subprocess.Popen([str(a) for a in args],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True,env=env)
|
|
process=self.process
|
|
deadline=time.monotonic()+3600
|
|
while process.poll() is None:
|
|
if self.cancel.is_set() or time.monotonic()>deadline:
|
|
try:
|
|
os.killpg(process.pid,signal.SIGTERM)
|
|
try:process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
|
|
except ProcessLookupError:pass
|
|
if self.cancel.is_set():raise InterruptedError()
|
|
raise RuntimeError('Zeitlimit beim Installationsschritt erreicht.')
|
|
time.sleep(.2)
|
|
if self.cancel.is_set():raise InterruptedError()
|
|
if process.returncode:raise RuntimeError('Installationsschritt fehlgeschlagen. Internetzugang, freien Speicher und Python-venv-Voraussetzungen prüfen.')
|
|
def _run(self):
|
|
base=self.root/self.job['id'];comfy=base/'comfy';python=base/'python/bin/python'
|
|
phase='';state='failed'
|
|
try:
|
|
base.mkdir(mode=0o700)
|
|
self._phase('Python-Umgebung erstellen');self._command([sys.executable,'-m','venv',base/'python'])
|
|
self._phase('ComfyUI herunterladen')
|
|
for url,path,rev in [('https://github.com/Comfy-Org/ComfyUI.git',comfy,COMFY_REV),('https://github.com/leejet/ComfyUI-GGUF.git',comfy/'custom_nodes/ComfyUI-GGUF',GGUF_REV)]:
|
|
self._command(['git','init',path]);self._command(['git','-C',path,'fetch','--depth','1',url,rev]);self._command(['git','-C',path,'checkout','--detach','FETCH_HEAD'])
|
|
self._phase('PyTorch mit CUDA herunterladen · mehrere GiB, kann einige Minuten dauern')
|
|
self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchvision==0.26.0','--index-url','https://download.pytorch.org/whl/cu128'])
|
|
self._phase('Bildlaufzeit-Abhängigkeiten installieren')
|
|
self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/image-requirements.lock','-r',comfy/'requirements.txt','-r',comfy/'custom_nodes/ComfyUI-GGUF/requirements.txt'])
|
|
self._phase('Installation prüfen')
|
|
self._command([python,'-m','pip','check'])
|
|
self._command([python,'-c','import torch, gguf, safetensors, aiohttp; assert torch.version.cuda'])
|
|
with self.lock:
|
|
if self.cancel.is_set():raise InterruptedError()
|
|
(base/'ready').write_text(COMFY_REV)
|
|
marker=self.root/'active.tmp';marker.write_text(json.dumps({'id':base.name}));marker.replace(self.root/'active.json')
|
|
state='complete';phase='Bildlaufzeit installiert. Es wurde kein Modell gestartet.'
|
|
except InterruptedError:state='cancelled';phase='Installation abgebrochen. Erneuter Versuch möglich.'
|
|
except Exception as exc:phase=str(exc) if isinstance(exc,RuntimeError) else 'Installation fehlgeschlagen. Systemvoraussetzungen und Speicher prüfen.'
|
|
finally:
|
|
if state!='complete':shutil.rmtree(base,ignore_errors=True)
|
|
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save()
|