diff --git a/backup.py b/backup.py index 92bff04..c64ccfe 100644 --- a/backup.py +++ b/backup.py @@ -192,7 +192,7 @@ class Backup: 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 c.get('build_recipe')!='swarm-ui':warnings.append(c['name']+': Image nur lokal vorhanden; Registry-/Buildquelle fehlt.') + 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')) diff --git a/deploy/docker_backup.py b/deploy/docker_backup.py index e2791e7..ee20cbe 100644 --- a/deploy/docker_backup.py +++ b/deploy/docker_backup.py @@ -142,7 +142,8 @@ def restore(manager,c): if base.is_symlink():raise ValueError('Unsicheres Dienstverzeichnis.') base.mkdir(parents=True,exist_ok=True,mode=0o700) args=['create','--name',name,'--network',c['network'],'--restart',c['restart'],'--cap-drop','ALL','--security-opt','no-new-privileges:true','--pids-limit','256'] - if c['name']=='athena-ladypoly':args+=['--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size=128m,uid=65534,gid=65534'] + if c['name'] in ('athena-ladypoly','athena-separator-web'): + args+=['--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size='+('1g' if c['name']=='athena-separator-web' else '128m')+',uid=65534,gid=65534'] if c['memory']:args+=['--memory',str(c['memory'])] if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)] for k,v in c['labels'].items(): diff --git a/docs/BACKUP_CHECK.md b/docs/BACKUP_CHECK.md new file mode 100644 index 0000000..ffa0f48 --- /dev/null +++ b/docs/BACKUP_CHECK.md @@ -0,0 +1,68 @@ +# Backup-/Restore-Prüfung — 2026-10-01 + +## Ergebnis am laufenden Athena + +Lesende Prüfung der realen Metadaten: 30 Bibliotheksdateien, 10 Profile, +keine lokale Datei ohne belegte Downloadquelle. Alle vorhandenen Einstellungen, +Profilparameter und Komponentenreferenzen bestehen die Backup-Schemavalidierung. +Keine Chats, Prompts, Nutzmedien oder Anwendungslogs wurden gelesen. + +Die drei markierten Zusatzdienste bestehen die Container-Validierung: + +| Dienst | Wiederbeschaffung | Gesicherte Verbindung | +|---|---|---| +| Athena Separator Web | Build-Rezept im Deck-Repository | deck-connection.json | +| Athena LadyPoly | Build-Rezept im Deck-Repository | deck-connection.json | +| LTX DeskWEB | Registry-Image mit Digest | Token-/Kennwortdateien, Umgebungsvariablen, Mount-Ziele | + +## Umfang + +- Profile aller Kategorien einschließlich Komponenten, GPU-Zuordnung, Sampling, + MTP, KV-Cache und API-Freigaben. +- API-Port und Autostart; TTS/STT-Ladepolitik, sofern eingestellt. +- Administratorzugang und API-Token; Hugging-Face-Token; Deck-WireGuard-Konfiguration, + sofern das Deck-Netzwerkmodul angebunden ist. +- Modellkatalog mit Revision und SHA-256; lokale Herkunft zusätzlich mit belegter + Originalquelle. Restore erhält bestehende IDs und damit Profilreferenzen. +- llama.cpp-Builds, Bild-, TTS-, audio.cpp-, LTX- und Separator-Laufzeitrezepte, + Prompt-Enhancer und unterstützte Zusatznodes. Laufzeiten werden installiert/gebaut; + ihre Binärdateien gehören nicht in den Export. +- Separator-Pakete einschließlich YAML/Ensemble-Dateien aus geprüften Quellen. +- Markierte Anwendungscontainer einschließlich Konfiguration, Ports, Labels, + Ressourcenlimits und Startzustand. Bilder werden gepullt oder nach Rezept gebaut. +- Dashboard-History und das beim Export ausgewählte Theme. + +Exportdateien sind passwortverschlüsselt. Das Exportkennwort separat aufbewahren. +Gewichte, Image-Layer, Projekte, Ergebnisse, Uploads, Chats und Logs sind ausgeschlossen. +Download-Verläufe, Warteschlangen und laufende Jobs werden nicht wieder aufgenommen. +Die zuletzt aktive GPU-Betriebsart wird nicht automatisch wieder gestartet. + +## Grenzen eines neuen Servers + +Der alte WireGuard-Gateway und unmarkierte alte Router-Container sind ausdrücklich +kein Teil des Deck-Backups. Docker-Registry-Anmeldungen des Hosts sind ebenfalls +nicht enthalten. Private Images können eine erneute Anmeldung benötigen. +Debian, Treiber, Build-Voraussetzungen, Systemhelfer und veröffentlichte API-Ports +müssen zunächst über den passenden Deck-Installer eingerichtet sein. + +Die Restore-Vorschau prüft Quellen, Referenzen und Laufzeitversionen. Sie ist kein +Nachweis, dass externe Quellen in Zukunft erreichbar bleiben oder Zugänge weiterhin +berechtigt sind. Der Platzbedarf der Vorschau zählt fehlende Kataloggewichte; +zusätzliche Laufzeit-/Build- und Separator-Pakete brauchen weiteren Speicher. +Versionabweichungen verlangen eine passende Deck-Version oder ein geprüftes Upgrade. +Vorhandene Container werden nicht überschrieben; ihre Einstellungen bleiben erhalten. + +## Prüfung und Korrekturen + +Isolierte Tests mit temporären Zustandsverzeichnissen prüfen verschlüsselten Export, +Manipulationserkennung, Restore auf ein leeres Ziel mit simuliertem Download, +Prüfsummenfehler, lokale Originalquelle bei unveränderten Profil-IDs, alle vier +Separator-Pakete, Container-Schutz und nachbaubare lokale Images. +Live wurden die tatsächlichen Profil-/Quellenmetadaten und Container-Exports geprüft. +Ein vollständiger Restore mit echten Downloads auf einem frisch installierten Server +wurde nicht durchgeführt. Der laufende Server wurde nicht neu gestartet/restauriert. + +Korrigiert: falsche Warnung über fehlende Quellen bei lokalen Build-Rezepten; +Separator-Web erhält beim Restore wieder eine schreibgeschützte Rootfs und seine +beschreibbare 1-GiB-Tmpfs. Änderungen benötigen zur Live-Aktivierung das nächste +Deck-App-Update und die Aktualisierung des Docker-Systemhelfers. diff --git a/test_backup.py b/test_backup.py index 7ad692a..4106461 100644 --- a/test_backup.py +++ b/test_backup.py @@ -73,6 +73,43 @@ class RestoreTests(unittest.TestCase): with patch.object(self.server.catalog,'start',side_effect=download): summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password');self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=True));job=self.wait() self.assertEqual(job['state'],'complete',job);self.assertEqual(self.server.profiles.rows[0]['model_id'],ident) + def test_all_separator_packages_have_pinned_sources_and_restore(self): + from separator_runtime import SOURCES + doc=self.server.backup.snapshot(include_history=False) + doc['runtimes']['separator']={'installed':True,'models':list(SOURCES),'sources':copy.deepcopy(SOURCES)} + validate(doc) + runtime=Mock();runtime.job=None;runtime.status.return_value={'installed':True,'version':'0.47.0','job':{'state':'complete'},'models':[]};runtime.model_ready.return_value=False + self.server.separator_runtime=runtime + summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password') + self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait() + self.assertEqual(job['state'],'complete',job) + self.assertEqual([c.args[0] for c in runtime.download.call_args_list],list(SOURCES)) + bad=copy.deepcopy(doc);name=next(iter(SOURCES));bad['runtimes']['separator']['sources'][name]['recipe_version']=999 + with self.assertRaises(ValueError):validate(bad) + def test_local_source_restore_preserves_profile_reference(self): + ident=self.model();self.profile(ident);doc=self.server.backup.snapshot(include_history=False) + model=doc['models'][0];data=(self.root/'models'/ident/'model.gguf').read_bytes() + model.update(repo='local/athena-router',revision=model['sha256'],source='local',download_source={'repo':'test/original','revision':'b'*40,'file':'original.gguf'}) + import shutil + shutil.rmtree(self.root/'models'/ident) + source_id=hashlib.sha256(('test/original'+'b'*40+'original.gguf').encode()).hexdigest() + def download(repo,filename,revision,kind): + self.assertEqual((repo,filename,revision),('test/original','original.gguf','b'*40)) + p=self.root/'models'/source_id;p.mkdir();(p/'model.gguf').write_bytes(data) + entry=dict(repo=repo,revision=revision,file=filename,size=len(data),sha256=model['sha256'],kind=kind,state='downloaded');(p/'entry.json').write_text(json.dumps(entry));self.server.catalog.job={'state':'complete'} + with patch.object(self.server.catalog,'start',side_effect=download): + summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password');self.assertFalse(summary['blockers']) + self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait() + self.assertEqual(job['state'],'complete',job) + self.assertEqual(self.server.profiles.rows[0]['model_id'],ident) + self.assertEqual(json.loads((self.root/'models'/ident/'entry.json').read_text())['download_source'],model['download_source']) + def test_buildable_local_container_does_not_warn_about_missing_source(self): + doc=self.server.backup.snapshot(include_history=False);c=ContainerTests().config() + c.update(name='athena-ladypoly',image='athena-ladypoly:3960afc-deck1',registry=None,network='host',build_recipe='ladypoly') + doc['services']=[c] + summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password') + self.assertTrue(summary['services'][0]['restorable']) + self.assertFalse(any('Buildquelle fehlt' in w for w in summary['warnings'])) def test_rejects_unsafe_paths_and_bad_references_before_changes(self): ident=self.model();self.profile(ident);doc=self.server.backup.snapshot(include_history=False) for change in (lambda d:d['models'][0].update(file='../secret'),lambda d:d['settings']['profiles.json'][0].update(model_id='f'*64),lambda d:d.update(version=99)): @@ -100,6 +137,16 @@ class ContainerTests(unittest.TestCase): for change in (lambda x:x.update(name='other-ui'),lambda x:x.update(image='untrusted:1'),lambda x:x['mounts'][0].update(target='/other')): bad=copy.deepcopy(c);change(bad) with self.assertRaises(ValueError):validate_container(bad) + def test_separator_restore_keeps_readonly_root_and_writable_tmpfs(self): + c=self.config();c.update(name='athena-separator-web',image='athena-separator-web:0.1.0',registry=None,network='host',ports={},build_recipe='separator-web',running=False) + with tempfile.TemporaryDirectory() as d: + root=Path(d);policy=root/'backup-policy.json';policy.write_text(json.dumps({'deck_state':d})) + calls=[] + with patch('deploy.docker_backup.subprocess.run',return_value=Mock(returncode=1)),patch('deploy.docker_backup.command',side_effect=lambda args,*extra,**kw:calls.append(args) or ''),patch('deploy.docker_backup.Path.stat',return_value=Mock(st_uid=0,st_mode=0o100600)): + result=restore_container(Mock(state=root),c) + self.assertEqual(result['state'],'complete') + create=next(args for args in calls if args[0]=='create') + self.assertIn('--read-only',create);self.assertIn('/tmp:rw,noexec,nosuid,size=1g,uid=65534,gid=65534',create) def test_never_replaces_existing_foreign_container(self): with tempfile.TemporaryDirectory() as d: manager=Mock(state=Path(d));(Path(d)/'backup-policy.json').write_text(json.dumps({'deck_state':d}))