399 lines
35 KiB
Python
399 lines
35 KiB
Python
"""Encrypted configuration export and sequential, inspectable recovery jobs.
|
|
No chats, prompts, media, logs, model weights or container layers are read into an export.
|
|
"""
|
|
import base64,copy,hashlib,json,os,re,secrets,shutil,sqlite3,tempfile,threading,time,zlib
|
|
from pathlib import Path,PurePosixPath
|
|
from auth import atomic_write,validate_record
|
|
from backup_codec import seal,open_backup
|
|
from catalog import repo_id,file_role,KINDS
|
|
from profiles import SCHEMAS,CHAT_GPU_DEFAULTS,chat_parameters
|
|
SETTINGS=('profiles.json','endpoint.json','audio-policy.json','video/comfy-selection.json','models/huggingface.json','video/comfy-client-token','video/original-work/settings.json')
|
|
MAX_HISTORY=40*1024**2
|
|
VERSION=1
|
|
# Exact byte hash verified against official Hub metadata, not a filename heuristic.
|
|
LOCAL_SOURCES={'83ee4f4f205fa514161778c41df1ea14144faa0f713510893b63c2395f5c2d53':dict(repo='unsloth/Qwen3.8-27B-GGUF',revision='4ca720788d1e01f1bff70c033e0d0028fd02e502',file='mmproj-BF16.gguf')}
|
|
|
|
def validate(doc):
|
|
if not isinstance(doc,dict) or doc.get('format')!='athena-deck' or doc.get('version')!=VERSION:raise ValueError('Backup-Version nicht unterstützt.')
|
|
if set(doc)-{'format','version','created_at','settings','models','credentials','theme','network','services','runtimes','warnings','history','runtime_versions'}:raise ValueError('Unbekannte Backup-Felder.')
|
|
if not isinstance(doc.get('warnings',[]),list) or len(doc.get('warnings',[]))>100 or any(not isinstance(w,str) or len(w)>1024 for w in doc.get('warnings',[])):raise ValueError('Ungültige Backup-Hinweise.')
|
|
if 'history' in doc and (not isinstance(doc['history'],str) or len(doc['history'])>56*1024**2):raise ValueError('History im Backup zu groß.')
|
|
models=doc.get('models');profiles=doc.get('settings',{}).get('profiles.json',[])
|
|
if not isinstance(models,list) or len(models)>4096 or not isinstance(profiles,list) or len(profiles)>1000:raise ValueError('Ungültiges Modell-/Profilverzeichnis.')
|
|
ids=set()
|
|
for m in models:
|
|
repo_id(m.get('repo'));f=m.get('file','');parts=PurePosixPath(f).parts
|
|
if not isinstance(f,str) or not parts or f.startswith('/') or '..' in parts or '\\' in f or len(f)>1024 or any(ord(c)<32 for c in f):raise ValueError('Unsicherer Modell-Dateiname.')
|
|
local=m.get('source')=='local'
|
|
if not re.fullmatch('[a-f0-9]{64}' if local else '[a-f0-9]{40}',m.get('revision','')) or m.get('kind') not in KINDS or type(m.get('size')) is not int or not 0<m['size']<4*1024**4:raise ValueError('Ungültige Modellquelle.')
|
|
ident=hashlib.sha256((m['repo']+m['revision']+f).encode()).hexdigest()
|
|
if (not local and m.get('id')!=ident) or not re.fullmatch('[a-f0-9]{64}',m.get('id','')) or m['id'] in ids or not re.fullmatch('[a-f0-9]{64}',m.get('sha256','')):raise ValueError('Ungültige Modell-ID oder Prüfsumme.')
|
|
if local:
|
|
if not m['repo'].startswith('local/') or m['revision']!=m['sha256']:raise ValueError('Ungültige lokale Modellherkunft.')
|
|
source=m.get('download_source')
|
|
if source is not None:
|
|
if not isinstance(source,dict) or set(source)!={'repo','revision','file'}:raise ValueError('Ungültige Ersatzquelle.')
|
|
repo_id(source['repo'])
|
|
if not re.fullmatch('[a-f0-9]{40}',source['revision']) or source['file'].startswith('/') or '..' in PurePosixPath(source['file']).parts or '\\' in source['file'] or any(ord(c)<32 for c in source['file']):raise ValueError('Ungültige Ersatzquelle.')
|
|
ids.add(m['id'])
|
|
pids=set();names=set()
|
|
for p in profiles:
|
|
if not isinstance(p,dict) or not re.fullmatch('[a-f0-9]{32}',p.get('id','')) or p['id'] in pids or p.get('name') in names:raise ValueError('Ungültige oder doppelte Profil-ID.')
|
|
name=p.get('name','');kind=p.get('kind');params=p.get('parameters')
|
|
if not isinstance(name,str) or not 2<=len(name)<=64 or not re.fullmatch(r'[\w .-]+',name) or not name[0].isalnum() or not name[-1].isalnum() or kind not in SCHEMAS or p.get('model_id') not in ids:raise ValueError('Ungültiges Profil oder Modellreferenz.')
|
|
if type(p.get('revision')) is not int or not 1<=p['revision']<=1000000:raise ValueError('Ungültige Profilrevision.')
|
|
if not isinstance(params,dict):raise ValueError('Ungültige Profilparameter.')
|
|
params=chat_parameters(params) if kind=='chat' else params
|
|
if set(params)!=(set(SCHEMAS[kind])|(set(CHAT_GPU_DEFAULTS) if kind=='chat' else set(params)&{'video_device','text_encoder_device'} if kind=='video' else set())):raise ValueError('Unbekannte Profilparameter.')
|
|
for key,(lo,hi,_) in SCHEMAS[kind].items():
|
|
value=params[key]
|
|
if isinstance(value,bool) or not isinstance(value,(int,float)) or not lo<=value<=hi or key not in ('temperature','top_p','guidance','speed','mtp_min_p') and type(value) is not int:raise ValueError('Ungültiger Profilparameter: '+key)
|
|
if kind=='chat' and (params['ubatch']>params['batch'] or params.get('vision_projector') and params['vision_projector'] not in ids):raise ValueError('Ungültige Chat-Zuordnung.')
|
|
if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Ungültige Bildgröße.')
|
|
components=p.get('components',{})
|
|
if not isinstance(components,dict) or any((k not in ('text_encoder','vae','video_vae','audio_vae','spatial_upsampler','model_config','generation_config','tokenizer','vae_config','manual_text_encoder','manual_vae','manual_tokenizer','manual_configuration','manual_projector','manual_auxiliary') and not re.fullmatch(r'runtime_file_[0-9]{1,2}',k)) or v not in ids for k,v in components.items()):raise ValueError('Ungültige Komponentenreferenz.')
|
|
pids.add(p['id']);names.add(name)
|
|
settings=doc.get('settings')
|
|
if not isinstance(settings,dict) or set(settings)-set(SETTINGS):raise ValueError('Unbekannte Einstellungsdateien.')
|
|
ep=settings.get('endpoint.json',{})
|
|
if not isinstance(ep,dict) or set(ep)-{'port','enabled_profiles','autostart'} or type(ep.get('port',8120)) is not int or not 1024<=ep.get('port',8120)<=65535 or type(ep.get('autostart',False)) is not bool:raise ValueError('Ungültige Endpunkt-Einstellungen.')
|
|
enabled=ep.get('enabled_profiles',[])
|
|
if not isinstance(enabled,list) or any(p not in pids for p in enabled) or len(set(enabled))!=len(enabled):raise ValueError('Unbekannte Profilfreigabe.')
|
|
if sum(p['kind']=='image' and p['id'] in enabled for p in profiles)>1:raise ValueError('Nur ein Bildprofil darf freigegeben sein.')
|
|
policy=settings.get('audio-policy.json',{})
|
|
if not isinstance(policy,dict) or set(policy)-{'tts','stt'} or any(not isinstance(v,dict) or set(v)!={'mode','profile_id'} or v['mode'] not in ('auto','warm','per_request') or v['profile_id'] is not None and v['profile_id'] not in pids for v in policy.values()):raise ValueError('Ungültige Audio-Einstellung.')
|
|
selection=settings.get('video/comfy-selection.json',{})
|
|
if isinstance(selection,dict) and selection.get('runtime','comfy') not in ('comfy','original'):raise ValueError('Unbekannte Video-Laufzeit.')
|
|
if not isinstance(selection,dict) or set(selection)-{'model_id','runtime'} or selection.get('model_id') and selection['model_id'] not in ids:raise ValueError('Ungültige Video-Auswahl.')
|
|
if 'models/huggingface.json' in settings:
|
|
hf=settings['models/huggingface.json']
|
|
if not isinstance(hf,dict) or set(hf)!={'token'} or hf['token'] is not None and (not isinstance(hf['token'],str) or not re.fullmatch(r'hf_[A-Za-z0-9]{10,252}',hf['token'])):raise ValueError('Ungültiger Hugging-Face-Zugang.')
|
|
if 'video/comfy-client-token' in settings and not re.fullmatch(r'[A-Za-z0-9_-]{32,256}',settings['video/comfy-client-token'].strip()):raise ValueError('Ungültiger Diensttoken.')
|
|
if 'video/original-work/settings.json' in settings and (not isinstance(settings['video/original-work/settings.json'],dict) or len(json.dumps(settings['video/original-work/settings.json']))>65536):raise ValueError('Ungültige LTX-Einstellungen.')
|
|
validate_record(doc.get('credentials'))
|
|
runtimes=doc.get('runtimes')
|
|
if not isinstance(runtimes,dict) or set(runtimes)-{'llama','llama_builds','image','tts','enhancers','swarm_nodes','ltx_original','audio_cpp','separator'} or not {'llama','llama_builds','image','tts','enhancers','swarm_nodes'}<=set(runtimes):raise ValueError('Ungültiges Laufzeitrezept.')
|
|
if 'separator' in runtimes:
|
|
from separator_runtime import MODELS
|
|
value=runtimes['separator']
|
|
if not isinstance(value,dict) or set(value)-{'installed','models','sources'} or not {'installed','models'}<=set(value) or type(value['installed']) is not bool or not isinstance(value['models'],list) or len(value['models'])>len(MODELS) or any(not isinstance(m,str) or m not in MODELS for m in value['models']) or len(set(value['models']))!=len(value['models']):raise ValueError('Ungültiges Audio-Separator-Rezept.')
|
|
if 'sources' in value:
|
|
from separator_runtime import SOURCES
|
|
if value['sources']!={name:SOURCES[name] for name in value['models']}:raise ValueError('Audio-Separator-Quellen stimmen nicht mit dem geprüften Rezept überein.')
|
|
if 'audio_cpp' in runtimes and type(runtimes['audio_cpp']) is not bool:raise ValueError('Ungültige audio.cpp-Laufzeit.')
|
|
if 'ltx_original' in runtimes and type(runtimes['ltx_original']) is not bool:raise ValueError('Ungültige LTX-Laufzeit.')
|
|
if not isinstance(runtimes['llama_builds'],list) or len(runtimes['llama_builds'])>50 or any(not isinstance(b,dict) or set(b)!={'commit','backend'} or not re.fullmatch('[a-f0-9]{40}',b.get('commit','')) or b.get('backend') not in ('CUDA','CPU') for b in runtimes['llama_builds']):raise ValueError('Ungültige Buildrezepte.')
|
|
if runtimes['llama'] is not None:
|
|
item=runtimes['llama']
|
|
if not isinstance(item,dict) or not re.fullmatch('[a-f0-9]{40}',item.get('commit','')) or item.get('backend') not in ('CUDA','CPU'):raise ValueError('Ungültige llama.cpp-Quelle.')
|
|
if any(type(runtimes[k]) is not bool for k in ('image','tts','swarm_nodes')) or not isinstance(runtimes['enhancers'],dict) or set(runtimes['enhancers'])-{'t2i','i2i'}:raise ValueError('Ungültiges Laufzeitrezept.')
|
|
for revision in runtimes['enhancers'].values():
|
|
if not isinstance(revision,str) or not re.fullmatch('[a-f0-9]{40}',revision):raise ValueError('Ungültige Aufwerterversion.')
|
|
network=doc.get('network')
|
|
if network is not None:
|
|
if not isinstance(network,dict) or set(network)!={'config','enabled','mode'} or type(network['enabled']) is not bool or network['mode'] not in ('lan','both','tunnel'):raise ValueError('Ungültiger Netzwerkstand.')
|
|
if network['config'] is not None:
|
|
from network.config import parse_config
|
|
parse_config(network['config'])
|
|
services=doc.get('services')
|
|
if not isinstance(services,list) or len(services)>50:raise ValueError('Ungültiger Containerbestand.')
|
|
# Validation is shared with the root helper, which independently revalidates before Docker calls.
|
|
from deploy.docker_backup import validate as validate_container
|
|
seen=set()
|
|
for c in services:
|
|
validate_container(c)
|
|
if c['name'] in seen:raise ValueError('Doppelte Containerdefinition.')
|
|
seen.add(c['name'])
|
|
if doc.get('theme','deck') not in ('deck','athena-classic'):raise ValueError('Unbekanntes Theme.')
|
|
return doc
|
|
|
|
class Backup:
|
|
def __init__(self,server):
|
|
self.server=server;self.root=server.catalog.root.parent;self.directory=self.root/'recovery';self.directory.mkdir(parents=True,exist_ok=True,mode=0o700)
|
|
self.lock=threading.RLock();self.cancel=threading.Event();self.plan=None;self.job=None
|
|
path=self.directory/'job.json'
|
|
if path.exists():
|
|
self.job=json.loads(path.read_text())
|
|
if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Restore durch Neustart unterbrochen. Backup erneut importieren; vorhandene Dateien werden geprüft und wiederverwendet.');self._save()
|
|
def busy(self):return bool(self.job and self.job['state']=='running')
|
|
def _save(self):atomic_write(self.directory/'job.json',self.job)
|
|
def status(self):
|
|
with self.lock:return {'job':copy.deepcopy(self.job),'preview':copy.deepcopy(self.plan['summary']) if self.plan else None,'checkpoints':[p.name for p in sorted(self.directory.glob('before-*.adbackup'),reverse=True)]}
|
|
def snapshot(self,theme='deck',include_history=True,_during_restore=False):
|
|
s=self.server
|
|
if self.busy() and not _during_restore:raise ValueError('Restore läuft. Export erst danach.')
|
|
settings={}
|
|
# Explicit allowlist: no recursive backup of process work directories.
|
|
with s.profiles.lock,s.endpoint.lock,s.catalog.lock:
|
|
for name in SETTINGS:
|
|
p=self.root/name
|
|
if p.is_file():settings[name]=p.read_text().strip() if name.endswith('comfy-client-token') else json.loads(p.read_text())
|
|
models=[]
|
|
for m in s.catalog.status()['entries']:
|
|
item={k:m[k] for k in ('id','repo','revision','file','size','sha256','kind')}
|
|
if m['repo'].startswith('local/'):
|
|
item['source']='local';item['download_source']=m.get('download_source') or LOCAL_SOURCES.get(m['sha256'])
|
|
models.append(item)
|
|
state=s.runtime.status();active=next((b for b in state['builds'] if b['id']==state['active']),None)
|
|
llama={'commit':active['commit'],'backend':active['backend']} if active else None
|
|
warnings=[];services=[]
|
|
try:services=s.docker.call('backup-export')['services']
|
|
except (OSError,ValueError):
|
|
if s.docker.status().get('services'):raise ValueError('Container-Konfigurationen können ohne aktualisierten Systemhelfer nicht gesichert werden.')
|
|
warnings.append('Docker-Systemhelfer nicht verfügbar; keine Zusatzdienste gesichert.')
|
|
network=None
|
|
if not s.network.disabled:
|
|
network=s.network.call('backup-export')
|
|
else:warnings.append('Deck-Netzwerkmodul deaktiviert; fremder WireGuard-Gateway wird nicht gesichert.')
|
|
document=dict(format='athena-deck',version=VERSION,created_at=time.time(),settings=settings,models=models,credentials=s.credentials.read(),theme=theme,network=network,services=services,runtime_versions=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']),runtimes=dict(audio_cpp=bool(getattr(s,'audio_cpp_runtime',None) and s.audio_cpp_runtime.status()['installed']),ltx_original=bool(getattr(s,'ltx_original_runtime',None) and s.ltx_original_runtime.status()['installed']),llama=llama,llama_builds=[dict(commit=b['commit'],backend=b['backend']) for b in state['builds']],image=s.image_runtime.status()['installed'],tts=s.tts_runtime.status()['installed'],enhancers={task:data['revision'] for task in ('t2i','i2i') if (data:=s.prompt_enhancer.installed(task))},swarm_nodes=(self.root/'video/swarm-comfy-nodes').is_dir()),warnings=warnings)
|
|
if getattr(s,'separator_runtime',None):
|
|
from separator_runtime import SOURCES
|
|
separator=s.separator_runtime.status();document['runtimes']['separator']=dict(installed=separator['installed'],models=[m['id'] for m in separator['models'] if m['installed']],sources={m['id']:SOURCES[m['id']] for m in separator['models'] if m['installed']});document['runtime_versions']['separator']=separator['version']
|
|
if document['runtimes']['audio_cpp']:
|
|
from audio_cpp_runtime import REVISION
|
|
document['runtime_versions']['audio_cpp']=REVISION
|
|
if document['runtimes']['ltx_original']:
|
|
from ltx_original_runtime import DESKTOP_REV,LTX_REV
|
|
document['runtime_versions']['ltx_original']=dict(desktop=DESKTOP_REV,inference=LTX_REV)
|
|
if include_history:
|
|
with tempfile.TemporaryDirectory(dir=self.directory) as work:
|
|
out=Path(work)/'history.sqlite3'
|
|
with s.dashboard.history._lock,sqlite3.connect(out) as dest:s.dashboard.history._db.backup(dest)
|
|
if out.stat().st_size>MAX_HISTORY:raise ValueError('Dashboard-History größer als 40 MiB. History separat sichern oder Export ohne History wählen.')
|
|
document['history']=base64.b64encode(out.read_bytes()).decode()
|
|
return document
|
|
def export(self,phrase,theme='deck',history=True):return seal(validate(self.snapshot(theme,history)),phrase)
|
|
def inspect(self,raw,phrase):
|
|
doc=validate(open_backup(raw,phrase));s=self.server;blockers=[];warnings=list(doc.get('warnings',[]));existing={m['id']:m for m in s.catalog.status()['entries']}
|
|
if 'history' in doc:self._restore_history(doc['history'],validate_only=True)
|
|
missing=[m for m in doc['models'] if m['id'] not in existing];need=sum(m['size'] for m in missing)
|
|
if missing and need+10*1024**3>shutil.disk_usage(self.root).free:blockers.append('Zu wenig Platz für fehlende Gewichte einschließlich 10 GiB Reserve.')
|
|
for m in doc['models']:
|
|
if m.get('source')=='local' and not m.get('download_source'):
|
|
warnings.append(m['file']+': lokale Datei ohne nachladbare Quelle; separat sichern.')
|
|
if m['id'] not in existing:blockers.append(m['file']+': lokale Gewichtedatei fehlt und hat keine Downloadquelle.')
|
|
if m['id'] in existing and existing[m['id']].get('sha256')!=m['sha256']:blockers.append('Vorhandene Modelldatei hat abweichende Prüfsumme: '+m['file'])
|
|
if doc['runtimes']['llama']:
|
|
prereq=s.runtime.prerequisites();backend=doc['runtimes']['llama']['backend']
|
|
if not prereq['cuda_ready' if backend=='CUDA' else 'cpu_ready']:blockers.append('llama.cpp-Buildwerkzeuge fehlen; Laufzeit-Voraussetzungen installieren.')
|
|
if doc['network'] and doc['network'].get('config'):
|
|
if s.network.disabled:blockers.append('Backup enthält WireGuard: Deck-Netzwerkmodul muss zuerst installiert/angebunden werden. Bestehender Gateway wird nicht übernommen.')
|
|
elif s.network.status().get('enabled') or s.network.status().get('mode')!='lan':blockers.append('Deck-Tunnel vor Netzwerk-Restore im bestätigten LAN-Modus deaktivieren.')
|
|
if doc['network']['mode']!='lan':warnings.append('Tunnelzugang wird mit Rückfallzeit aktiviert und muss über den Tunnel bestätigt werden.')
|
|
versions=doc.get('runtime_versions',{})
|
|
if versions.get('separator'):
|
|
from separator_runtime import VERSION as SEPARATOR_VERSION
|
|
if versions['separator']!=SEPARATOR_VERSION:blockers.append('Backup verlangt eine andere Audio-Separator-Version.')
|
|
if versions.get('audio_cpp'):
|
|
from audio_cpp_runtime import REVISION
|
|
if versions['audio_cpp']!=REVISION:blockers.append('Backup verlangt eine andere audio.cpp-Version. Passende Deck-Version verwenden.')
|
|
if versions.get('ltx_original'):
|
|
from ltx_original_runtime import DESKTOP_REV,LTX_REV
|
|
if versions['ltx_original']!=dict(desktop=DESKTOP_REV,inference=LTX_REV):blockers.append('Backup verlangt eine andere originale LTX-Version. Passende Deck-Version verwenden.')
|
|
if versions and {k:v for k,v in versions.items() if k not in ('ltx_original','audio_cpp','separator')}!=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']):blockers.append('Backup verlangt andere Bild-/TTS-Laufzeitversionen. Passende Deck-Version oder geprüftes Runtime-Upgrade verwenden.')
|
|
for c in doc['services']:
|
|
if not c.get('registry') and not c.get('build_recipe'):warnings.append(c['name']+': Image nur lokal vorhanden; Registry-/Buildquelle fehlt.')
|
|
ports=s.endpoint.allowed_ports
|
|
if ports and doc['settings'].get('endpoint.json',{}).get('port',8120) not in ports:blockers.append('API-Port aus Backup ist nicht veröffentlicht. Installer zuerst für diesen Port konfigurieren.')
|
|
ident=secrets.token_hex(16);summary=dict(id=ident,created_at=doc.get('created_at'),models=[dict(id=m['id'],name=m['file'],bytes=m['size'],present=m['id'] in existing) for m in doc['models']],profiles=[p['name'] for p in doc['settings'].get('profiles.json',[])],services=[dict(name=c['name'],image=c['image'],registry=c.get('registry'),build_recipe=c.get('build_recipe'),restorable=bool(c.get('registry') or c.get('build_recipe')),running=c['running']) for c in doc['services']],download_bytes=need,runtimes=doc['runtimes'],wireguard=bool(doc['network'] and doc['network']['config']),warnings=warnings,blockers=blockers,theme=doc.get('theme','deck'))
|
|
with self.lock:
|
|
if self.busy():raise ValueError('Restore läuft bereits.')
|
|
self.plan={'document':doc,'summary':summary,'expires':time.monotonic()+1800}
|
|
return summary
|
|
def inspect_checkpoint(self,name):
|
|
if not isinstance(name,str) or not re.fullmatch(r'before-[a-f0-9]{32}\.adbackup',name):raise ValueError('Ungültiger Rückfallstand.')
|
|
p=self.directory/name;key=p.with_suffix('.key')
|
|
if p.is_symlink() or key.is_symlink() or not p.is_file() or not key.is_file():raise ValueError('Rückfallstand nicht verfügbar.')
|
|
return self.inspect(p.read_bytes(),key.read_text())
|
|
def start(self,data):
|
|
if not isinstance(data,dict) or set(data)!={'id','services','confirm','restore_credentials'} or data['confirm'] is not True or type(data['restore_credentials']) is not bool or not isinstance(data['services'],list):raise ValueError('Restore-Prüfung und ausdrückliche Bestätigung erforderlich.')
|
|
with self.lock:
|
|
if self.busy():raise ValueError('Restore läuft bereits.')
|
|
if not self.plan or self.plan['expires']<time.monotonic() or self.plan['summary']['id']!=data['id']:raise ValueError('Importprüfung abgelaufen. Backup neu auswählen.')
|
|
if self.plan['summary']['blockers']:raise ValueError('Zuerst die Hindernisse aus der Importprüfung beheben.')
|
|
doc=self.plan['document'];services={c['name']:c for c in doc['services']}
|
|
if len(set(data['services']))!=len(data['services']) or any(name not in services for name in data['services']):raise ValueError('Unbekannter Zusatzdienst.')
|
|
if any(not services[name].get('registry') and not services[name].get('build_recipe') for name in data['services']):raise ValueError('Gewählter Dienst hat keine Wiederherstellungsquelle.')
|
|
s=self.server
|
|
if s.catalog.pending or s.catalog.job and s.catalog.job['state']=='downloading' or s.runtime.busy or any((getattr(o,'job',None) or {}).get('state')=='running' for o in (s.image_runtime,s.tts_runtime,s.ltx_original_runtime,getattr(s,'audio_cpp_runtime',None),getattr(s,'music',None),getattr(s,'separator_runtime',None),getattr(s,'separator_tests',None),s.prompt_enhancer,s.auto_tests,s.chat_tests,s.image_tests,s.tts_tests,s.stt)):raise ValueError('Zuerst laufende Downloads, Builds und Tests abschließen oder abbrechen.')
|
|
self.cancel.clear();self.job=dict(id=secrets.token_hex(16),state='running',phase='Wiederherstellung vorbereiten',completed=0,total=len(doc['models'])+len(data['services'])+5,items=[],started_at=time.time(),theme=doc.get('theme','deck'))
|
|
self._save();threading.Thread(target=self._restore,args=(copy.deepcopy(doc),data['services'],data['restore_credentials']),daemon=True).start();self.plan=None
|
|
return self.status()
|
|
def _phase(self,text):
|
|
with self.lock:self.job['phase']=text;self._save()
|
|
def _item(self,name,state,message):
|
|
with self.lock:self.job['items'].append(dict(name=name,state=state,message=message));self.job['completed']+=1;self._save()
|
|
def _check(self):
|
|
if self.cancel.is_set():raise InterruptedError()
|
|
def _wait(self,status,get_job=lambda x:x.get('job')):
|
|
while True:
|
|
self._check();value=status();job=get_job(value)
|
|
if not job or job.get('state') not in ('running','queued','downloading'):break
|
|
self._phase(job.get('phase') or ('Gewichte herunterladen: '+job.get('file','')) if job.get('file') else job.get('phase','Dateien laden / prüfen'))
|
|
with self.lock:self.job['current']={k:job[k] for k in ('bytes','total','bytes_per_second','eta_seconds') if k in job};self._save()
|
|
self.cancel.wait(1)
|
|
if job and job.get('state') not in ('complete',):raise ValueError('Download oder Laufzeitinstallation fehlgeschlagen. Zugang, Speicher und Voraussetzungen prüfen.')
|
|
with self.lock:self.job.pop('current',None);self._save()
|
|
return value
|
|
def _runtimes(self,doc):
|
|
s=self.server;r=doc['runtimes']
|
|
if r['image'] and not s.image_runtime.status()['installed']:
|
|
self._phase('Vorhandene Bild-/Videolaufzeit prüfen oder installieren');s.image_runtime.start();self._wait(s.image_runtime.status)
|
|
if r['tts'] and not s.tts_runtime.status()['installed']:
|
|
self._phase('TTS-Laufzeit und Zusatzdateien installieren');s.tts_runtime.start();self._wait(s.tts_runtime.status)
|
|
if r.get('audio_cpp') and not s.audio_cpp_runtime.status()['installed']:
|
|
self._phase('audio.cpp-Laufzeit installieren');s.audio_cpp_runtime.start();self._wait(s.audio_cpp_runtime.status)
|
|
if r.get('ltx_original') and not s.ltx_original_runtime.status()['installed']:
|
|
self._phase('Originale LTX-Laufzeit installieren');s.ltx_original_runtime.start();self._wait(s.ltx_original_runtime.status)
|
|
if r.get('separator',{}).get('installed'):
|
|
if not s.separator_runtime.status()['installed']:
|
|
self._phase('Audio Separator installieren');s.separator_runtime.start();self._wait(s.separator_runtime.status)
|
|
from separator_runtime import verify_package
|
|
for name in r['separator']['models']:
|
|
if not s.separator_runtime.model_ready(name) or not verify_package(s.separator_runtime.root/'models',name):
|
|
self._phase('Audio-Separator-Modellpaket laden');s.separator_runtime.download(name);self._wait(s.separator_runtime.status)
|
|
targets=r['llama_builds']+([r['llama']] if r['llama'] and r['llama'] not in r['llama_builds'] else [])
|
|
for target in targets:
|
|
state=s.runtime.status();matching=next((b for b in state['builds'] if b['commit']==target['commit'] and b['backend']==target['backend']),None)
|
|
if not matching:
|
|
self._phase('llama.cpp-Version aus Backup bauen');s.runtime.start(target['commit'],target['backend'],1);state=self._wait(s.runtime.status);matching=state['builds'][-1]
|
|
if target==r['llama']:s.runtime.activate(matching['id'])
|
|
for task,revision in r['enhancers'].items():
|
|
if not s.prompt_enhancer.installed(task):
|
|
s.prompt_enhancer.install(task,revision=revision);self._wait(s.prompt_enhancer.status)
|
|
self._item('Laufzeiten','complete','Vorhandene Installationen wiederverwendet; fehlende Laufzeiten eingerichtet.')
|
|
def _restore(self,doc,selected,restore_credentials):
|
|
s=self.server;errors=[];rollback=None;before=None
|
|
try:
|
|
# Preserve non-reproducible settings before touching anything; never overwrite old rollback files.
|
|
self._phase('Lokalen Rückfallstand sichern')
|
|
rollback=self.directory/('before-'+self.job['id']+'.adbackup')
|
|
before=self.snapshot_for_rollback()
|
|
key=secrets.token_urlsafe(32);rollback.write_bytes(seal(before,key));rollback.chmod(0o600)
|
|
# Recovery key stays on this server in a separate protected file, never in status responses.
|
|
(rollback.with_suffix('.key')).write_text(key);rollback.with_suffix('.key').chmod(0o600)
|
|
s.endpoint.close();s.video.close();s.tts_tests.stop();s.stt.stop()
|
|
until=time.monotonic()+30
|
|
while s.endpoint.inflight and time.monotonic()<until:time.sleep(.1)
|
|
if s.endpoint.inflight:raise ValueError('API-Aufträge wurden nicht rechtzeitig beendet. Restore erneut versuchen.')
|
|
with s.endpoint.lock:s.endpoint.http=None;s.endpoint.thread=None;s.endpoint.state='stopped'
|
|
with s.scheduler.cv:s.scheduler.gpu_mode='restoring';s.scheduler.cv.notify_all()
|
|
self._item('Rückfallstand','complete','Vorherige Einstellungen geschützt gesichert; nur Deck-eigene Worker beendet.')
|
|
hf=doc['settings'].get('models/huggingface.json')
|
|
if hf:atomic_write(self.root/'models/huggingface.json',hf)
|
|
self._runtimes(doc)
|
|
for m in doc['models']:
|
|
self._check();self._phase('Gewichte: '+m['file'])
|
|
try:
|
|
entry=s.catalog.entry(m['id']);path=s.catalog.root/m['id']/('model'+PurePosixPath(m['file']).suffix)
|
|
digest=hashlib.sha256()
|
|
with path.open('rb') as stream:
|
|
for block in iter(lambda:stream.read(4*1024**2),b''):self._check();digest.update(block)
|
|
if digest.hexdigest()!=m['sha256']:raise ValueError('Vorhandene Datei stimmt nicht mit Backup-Prüfsumme überein.')
|
|
self._item(m['file'],'reused','Vorhandene Datei per SHA-256 geprüft.')
|
|
except ValueError:
|
|
if (s.catalog.root/m['id']/'entry.json').exists():raise
|
|
source=m.get('download_source') or m
|
|
s.catalog.start(source['repo'],source['file'],source['revision'],m['kind']);self._wait(s.catalog.status)
|
|
source_id=hashlib.sha256((source['repo']+source['revision']+source['file']).encode()).hexdigest()
|
|
entry=s.catalog.entry(source_id)
|
|
if entry['sha256']!=m['sha256']:raise ValueError('Geladene Datei stimmt nicht mit Backup-Prüfsumme überein.')
|
|
if source_id!=m['id']:
|
|
target=s.catalog.root/m['id']
|
|
if target.exists():
|
|
if any(target.iterdir()):raise ValueError('Lokaler Modellordner ist anderweitig belegt.')
|
|
target.rmdir()
|
|
(s.catalog.root/source_id).rename(target)
|
|
old=target/('model'+PurePosixPath(source['file']).suffix);new=target/('model'+PurePosixPath(m['file']).suffix)
|
|
if old!=new:old.rename(new)
|
|
entry.update({k:m[k] for k in ('repo','revision','file','kind')});entry['download_source']=m['download_source'];entry.pop('id',None);atomic_write(target/'entry.json',entry)
|
|
self._item(m['file'],'complete','Feste Quellversion geladen und SHA-256 geprüft.')
|
|
for c in doc['services']:
|
|
self._check()
|
|
if c['name'] not in selected:self._item(c['name'],'skipped','Vom Restore ausgeschlossen.');continue
|
|
self._phase('Zusatzdienst: '+c['name'])
|
|
try:result=s.docker.call('backup-restore',values={'container':c});self._item(c['name'],result['state'],result['message'])
|
|
except (OSError,ValueError):errors.append(c['name']);self._item(c['name'],'failed','Container nicht wiederhergestellt; Registry, Quelle, Ports oder Systemhelfer prüfen.')
|
|
if doc['runtimes']['swarm_nodes'] and 'athena-swarm-ui' in selected:
|
|
self._phase('Swarm-Abhängigkeiten zur vorhandenen Video-Laufzeit ergänzen')
|
|
from image_runtime import ImageRuntime
|
|
python,_=s.image_runtime.paths()
|
|
import subprocess
|
|
subprocess.run([str(python),'-m','pip','install','--upgrade','--no-cache-dir','--no-deps','--target',str(self.root/'video/swarm-python'),'opencv-python-headless==4.12.0.88','imageio-ffmpeg==0.6.0'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True,timeout=300)
|
|
self._check();self._phase('Profile, Zuordnungen und Einstellungen anwenden')
|
|
self._apply_settings(doc)
|
|
if 'history' in doc:
|
|
self._restore_history(doc['history'])
|
|
self._item('Einstellungen','complete','Profile, API-Freigaben, Komponenten und Audio-Verhalten gesetzt.')
|
|
if doc['network'] and doc['network']['config']:
|
|
try:
|
|
result=s.network.call('backup-restore',{'backup':doc['network']})
|
|
if result.get('pending'):errors.append('WireGuard-Bestätigung')
|
|
self._item('WireGuard','pending' if result.get('pending') else 'complete','Deck-Konfiguration gesetzt; Handshake geprüft. Tunnelmodus gegebenenfalls im Netzwerkbereich bestätigen.')
|
|
except (OSError,ValueError):errors.append('WireGuard');self._item('WireGuard','failed','Netzwerk nicht vollständig wiederhergestellt. LAN-Zugang bleibt erhalten; Netzwerkbereich prüfen.')
|
|
else:self._item('WireGuard','skipped','Kein Deck-WireGuard im Backup; vorhandene Gateways bleiben unberührt.')
|
|
if restore_credentials:
|
|
current=s.credentials.read();s.credentials.write(doc['credentials'],current['revision'])
|
|
with s.auth_lock:
|
|
for session in s.sessions.values():session['revision']=doc['credentials']['password']['hash']
|
|
self._item('Zugang','complete','Oberflächenkennwort und API-Token-Prüfwert aus Backup wiederhergestellt.')
|
|
else:self._item('Zugang','skipped','Aktueller Oberflächenzugang und API-Token bleiben erhalten.')
|
|
with s.scheduler.cv:s.scheduler.gpu_mode='video' if s.video.foreign_blocked else 'llm';s.scheduler.cv.notify_all()
|
|
if s.endpoint.config.get('autostart'):
|
|
try:s.endpoint.start()
|
|
except (OSError,ValueError):errors.append('API-Endpunkt');self._item('API-Endpunkt','failed','Listener konnte nicht gestartet werden; Port prüfen.')
|
|
with self.lock:self.job.update(state='partial' if errors else 'complete',phase='Wiederherstellung mit offenen Punkten beendet.' if errors else 'Wiederherstellung abgeschlossen. Oberfläche ist bereit.',finished_at=time.time(),completed=self.job['total']);self._save()
|
|
except Exception as exc:
|
|
# Restore preexisting application settings; downloaded files/created services remain available.
|
|
if before is not None:
|
|
try:
|
|
self._apply_settings(before)
|
|
old=before['runtimes']['llama']
|
|
if old:
|
|
item=next((b for b in s.runtime.status()['builds'] if b['commit']==old['commit'] and b['backend']==old['backend']),None)
|
|
if item:s.runtime.activate(item['id'])
|
|
if s.endpoint.config.get('autostart'):s.endpoint.start()
|
|
except Exception:pass
|
|
|
|
with s.scheduler.cv:s.scheduler.gpu_mode='video' if s.video.foreign_blocked else 'llm';s.scheduler.cv.notify_all()
|
|
self._item('Restore','cancelled' if isinstance(exc,InterruptedError) else 'failed',str(exc) if isinstance(exc,ValueError) else 'Wiederherstellung unterbrochen. Rückfallstand vorhanden; Backup erneut prüfen.')
|
|
with self.lock:self.job.update(state='cancelled' if isinstance(exc,InterruptedError) else 'failed',phase='Restore nicht abgeschlossen. Rückfall auf vorherige Einstellungen versucht; geschützter Rückfallstand vorhanden.',finished_at=time.time());self._save()
|
|
def _apply_settings(self,doc):
|
|
s=self.server
|
|
for name in SETTINGS:
|
|
if name not in doc['settings']:(self.root/name).unlink(missing_ok=True)
|
|
for name,value in doc['settings'].items():
|
|
if name=='video/comfy-client-token':
|
|
path=self.root/name;path.parent.mkdir(parents=True,exist_ok=True);path.write_text(value+'\n');path.chmod(0o600)
|
|
else:atomic_write(self.root/name,value)
|
|
with s.profiles.lock:s.profiles.rows=copy.deepcopy(doc['settings'].get('profiles.json',[]))
|
|
with s.endpoint.lock:s.endpoint.config=copy.deepcopy(doc['settings'].get('endpoint.json',dict(port=8120,enabled_profiles=[],autostart=False)))
|
|
s.video.selection=copy.deepcopy(doc['settings'].get('video/comfy-selection.json',{}))
|
|
policy=doc['settings'].get('audio-policy.json',{k:{'mode':'auto','profile_id':None} for k in ('tts','stt')})
|
|
with s.audio_policy.lock:s.audio_policy.settings=policy
|
|
s.tts_tests.policy=policy.get('tts',{}).get('mode','auto');s.stt.policy=policy.get('stt',{}).get('mode','auto')
|
|
def snapshot_for_rollback(self):
|
|
return self.snapshot(include_history=False,_during_restore=True)
|
|
def _restore_history(self,data,validate_only=False):
|
|
raw=base64.b64decode(data,validate=True)
|
|
if len(raw)>MAX_HISTORY:raise ValueError('History im Backup zu groß.')
|
|
with tempfile.TemporaryDirectory(dir=self.directory) as work:
|
|
path=Path(work)/'history.sqlite3';path.write_bytes(raw)
|
|
with sqlite3.connect('file:'+str(path)+'?mode=ro',uri=True) as source:
|
|
if source.execute('PRAGMA quick_check').fetchone()[0]!='ok':raise ValueError('Ungültige Dashboard-History.')
|
|
expected={'meta','samples_raw','samples_hourly','slot_samples_raw','slot_samples_hourly','token_totals','token_hourly','model_events'}
|
|
if {r[0] for r in source.execute("SELECT name FROM sqlite_master WHERE type='table'")}!=expected or source.execute("SELECT COUNT(*) FROM sqlite_master WHERE type IN ('trigger','view')").fetchone()[0]:raise ValueError('Unbekanntes History-Schema.')
|
|
if not validate_only:
|
|
with self.server.dashboard.history._lock:source.backup(self.server.dashboard.history._db)
|
|
def stop(self):
|
|
with self.lock:
|
|
self.cancel.set()
|
|
if self.busy():
|
|
self.server.catalog.stop();self.server.runtime.stop();self.server.image_runtime.stop();self.server.tts_runtime.stop();self.server.ltx_original_runtime.stop();
|
|
if getattr(self.server,'separator_runtime',None):self.server.separator_runtime.stop()
|
|
if getattr(self.server,'separator_tests',None):self.server.separator_tests.stop()
|
|
if getattr(self.server,'music',None):self.server.music.stop()
|
|
if getattr(self.server,'audio_cpp_runtime',None):self.server.audio_cpp_runtime.stop()
|
|
self.server.prompt_enhancer.stop()
|
|
return {'cancellation_requested':True}
|