Files
Athena-Deck/deploy/docker_backup.py
T

182 lines
13 KiB
Python

"""Configuration-only backup of labelled apps; no Docker socket exposed to the GUI.
Imported containers never get privileged mode, devices, Docker sockets or host filesystem mounts.
"""
import base64,json,os,re,shutil,subprocess,socket,time
from pathlib import Path,PurePosixPath
MAX_FILE=1024*1024
APP='io.athena-deck.application'
LABELS={'io.athena-deck.managed':'true','io.athena-deck.role':'application'}
def command(args,timeout=60):
r=subprocess.run(['/usr/bin/docker',*args],capture_output=True,text=True,timeout=timeout)
if r.returncode:raise ValueError('Docker-Schritt fehlgeschlagen; Image-Zugang, Port und Docker prüfen.')
return r.stdout.strip()
def blob(path):
if path.is_symlink() or not path.is_file() or path.stat().st_size>MAX_FILE:raise ValueError('Dienstkonfiguration nicht sicher lesbar oder größer als 1 MiB.')
return base64.b64encode(path.read_bytes()).decode()
def config_files(source,destination):
p=Path(source)
if destination.startswith('/run/secrets/'):
return {'file':blob(p)} if p.exists() else {}
if destination.endswith('/Data') and p.is_dir():
files={}
for name in ('Settings.fds','Backends.fds'):
if (p/name).is_file():files[name]=blob(p/name)
return files
return {}
def app_uid(container,user):
if not user:return 0
if user.split(':')[0].isdigit():return int(user.split(':')[0])
with __import__('tempfile').TemporaryDirectory() as d:
p=Path(d)/'passwd';command(['cp',container+':/etc/passwd',str(p)])
row=next((x.split(':') for x in p.read_text().splitlines() if x.split(':')[0]==user.split(':')[0]),None)
if not row:raise ValueError('Containerbenutzer nicht auflösbar.')
return int(row[2])
def export(manager):
policy=manager.state/'backup-policy.json'
deck=json.loads(policy.read_text())['deck_state'] if policy.exists() else None
result=[]
for row in manager.inventory():
x=json.loads(command(['inspect',row['id']]))[0];c=x['Config'];h=x['HostConfig'];image=json.loads(command(['image','inspect',x['Image']]))[0]
registry=next((d for d in image.get('RepoDigests',[]) if '/' in d.split('@')[0] and ('.' in d.split('/')[0] or ':' in d.split('/')[0])),None)
if not registry and row['name']!='athena-swarm-ui':registry=next(iter(image.get('RepoDigests',[])),None)
mounts=[]
for i,m in enumerate(x['Mounts']):
source=m['Source'];relative=None
if deck:
try:relative=str(Path(source).relative_to(deck))
except ValueError:pass
mounts.append(dict(index=i,target=m['Destination'],read_only=not m['RW'],deck_path=relative,files=config_files(source,m['Destination']),was_file=Path(source).is_file()))
result.append(dict(name=row['name'],image=c['Image'],registry=registry,application=c.get('Labels',{}).get(APP,''),labels={k:v for k,v in (c.get('Labels') or {}).items() if k.startswith('io.athena-deck.')},env=c.get('Env') or [],entrypoint=c.get('Entrypoint'),cmd=c.get('Cmd'),uid=app_uid(row['id'],c.get('User') or ''),user=c.get('User') or '',workdir=c.get('WorkingDir') or '',network=h['NetworkMode'],ports=h.get('PortBindings') or {},restart=h.get('RestartPolicy',{}).get('Name') or 'no',memory=h.get('Memory') or 0,nanocpus=h.get('NanoCpus') or 0,running=x['State']['Running'],mounts=mounts,build_recipe='swarm-ui' if row['name']=='athena-swarm-ui' else None,unsupported=bool(h.get('Privileged') or h.get('DeviceRequests') or h.get('Devices') or h.get('CapAdd'))))
return {'services':result,'configured':bool(deck)}
def validate(c):
if not isinstance(c,dict) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}',c.get('name','')):raise ValueError('Ungültiger Containername.')
if any(c.get('labels',{}).get(k)!=v for k,v in LABELS.items()):raise ValueError('Nur ausdrücklich Deck zugeordnete Anwendungscontainer erlaubt.')
if c.get('unsupported'):raise ValueError('GPU-/privilegierter Container benötigt manuelle Einrichtung.')
if c.get('network') not in ('bridge','default','host'):raise ValueError('Benutzerdefiniertes Docker-Netzwerk benötigt manuelle Einrichtung.')
if c.get('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.')
if c.get('build_recipe') not in (None,'swarm-ui') or c.get('build_recipe')=='swarm-ui' and (c['name']!='athena-swarm-ui' or c.get('image')!='athena-swarm-ui:de7b834'):raise ValueError('Unbekanntes Build-Rezept.')
ref=c.get('registry') or c.get('image','')
if not isinstance(ref,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/@:-]{0,300}',ref):raise ValueError('Ungültige Image-Referenz.')
if c.get('restart') not in ('no','always','unless-stopped','on-failure'):raise ValueError('Ungültige Neustartregel.')
for field in ('env','entrypoint','cmd'):
v=c.get(field)
if v is not None and (not isinstance(v,list) or len(v)>200 or any(not isinstance(t,str) or len(t)>16384 or '\x00' in t for t in v)):raise ValueError('Ungültige Containerparameter.')
for field in ('user','workdir'):
if not isinstance(c.get(field,''),str) or len(c.get(field,''))>512 or '\x00' in c.get(field,''):raise ValueError('Ungültige Containerparameter.')
if type(c.get('uid',0)) is not int or not 0<=c.get('uid',0)<=4294967294:raise ValueError('Ungültige Anwendungs-UID.')
if type(c.get('running')) is not bool or not isinstance(c.get('labels'),dict) or len(c['labels'])>30 or any(not isinstance(k,str) or not k.startswith('io.athena-deck.') or not isinstance(v,str) or len(v)>512 for k,v in c['labels'].items()):raise ValueError('Ungültige Container-Einstellungen.')
for field in ('memory','nanocpus'):
if type(c.get(field)) is not int or not 0<=c[field]<=1024**5:raise ValueError('Ungültige Ressourcenbegrenzung.')
ports=c.get('ports')
if not isinstance(ports,dict) or len(ports)>20:raise ValueError('Ungültige Ports.')
for container,bindings in ports.items():
if not re.fullmatch(r'\d{1,5}/(?:tcp|udp)',container) or not 1<=int(container.split('/')[0])<=65535 or not isinstance(bindings,list):raise ValueError('Ungültige Ports.')
for binding in bindings:
if binding.get('HostIp') not in ('127.0.0.1','::1'):raise ValueError('Restore veröffentlicht Containerports nur auf Loopback.')
if not str(binding.get('HostPort','')).isdigit() or not 1024<=int(binding['HostPort'])<=65535:raise ValueError('Ungültiger Host-Port.')
mounts=c.get('mounts')
if not isinstance(mounts,list) or len(mounts)>30:raise ValueError('Ungültige Volumes.')
targets=set()
for i,m in enumerate(mounts):
target=m.get('target','');parts=PurePosixPath(target).parts
if not target.startswith('/') or '..' in parts or ',' in target or ':' in target or target in targets or target.startswith(('/proc','/sys','/dev','/etc','/var/run','/run/athena')) or 'docker.sock' in target:raise ValueError('Unsicheres Volume-Ziel.')
targets.add(target)
if m.get('index')!=i or type(m.get('read_only')) is not bool or type(m.get('was_file')) is not bool:raise ValueError('Ungültige Volume-Einstellung.')
rel=m.get('deck_path')
media={'video/original-work/remote-inputs/deskweb':('/data/inputs',False),'video/original-work/outputs':('/data/outputs',True)}
native_media=rel in media and c['name']=='ltx-deskweb' and (target,m['read_only'])==media[rel] and not m['was_file']
if rel is not None and not native_media and (rel not in ('models','video/comfy-work/models','video/comfy-client-token') or not m['read_only']):raise ValueError('Deck-Volume nicht freigegeben; nur bekannte Modell-/Tokenpfade und LTX-Medienordner erlaubt.')
for name,data in m.get('files',{}).items():
if name not in ('file','Settings.fds','Backends.fds'):raise ValueError('Unbekannte Konfigurationsdatei.')
if not isinstance(data,str) or len(base64.b64decode(data,validate=True))>MAX_FILE:raise ValueError('Ungültige Konfigurationsdatei.')
return c
def restore(manager,c):
validate(c)
policy=manager.state/'backup-policy.json'
if not policy.is_file():raise ValueError('Deck-Zustandsverzeichnis muss im Systemhelfer registriert sein.')
if policy.is_symlink() or policy.stat().st_uid!=0 or policy.stat().st_mode&0o022:raise ValueError('Systemhelfer-Registrierung nicht vertrauenswürdig.')
deck=Path(json.loads(policy.read_text())['deck_state']).resolve();name=c['name']
existing=subprocess.run(['/usr/bin/docker','inspect',name],capture_output=True,text=True)
if existing.returncode==0:
x=json.loads(existing.stdout)[0]
if any(x['Config'].get('Labels',{}).get(k)!=v for k,v in LABELS.items()):raise ValueError('Containername ist durch einen fremden Dienst belegt.')
# Never replace or restart an existing service during a restore.
if x['Config']['Image'] not in (c['image'],c.get('registry')):raise ValueError('Vorhandener Container verwendet ein anderes Image; manuell prüfen.')
return {'state':'reused','message':'Vorhandener markierter Container unverändert übernommen.'}
if c.get('build_recipe')=='swarm-ui':
source=Path(__file__).parent/'swarm-ui'
if not (source/'Dockerfile').is_file():raise ValueError('Gepinntes Swarm-Build-Rezept fehlt im Systemhelfer.')
command(['build','-t',c['image'],str(source)],1800)
else:
if not c.get('registry'):raise ValueError('Lokales Image hat keine Registry-Quelle und kein bekanntes Build-Rezept.')
command(['pull',c['registry']],1800)
if c.get('build_recipe')=='swarm-ui':
nodes=deck/'video/swarm-comfy-nodes';nodes.mkdir(parents=True,exist_ok=True)
temporary=command(['create',c['image']])
try:command(['cp',temporary+':/swarm/src/BuiltinExtensions/ComfyUIBackend/ExtraNodes/.',str(nodes)])
finally:command(['rm',temporary])
for p in [nodes,*nodes.rglob('*')]:os.chown(p,65534,65534)
work=deck/'video/comfy-work/models'
for folder in ('diffusion_models','text_encoders','vae','latent_upscale_models','loras','Stable-Diffusion','Lora','VAE','Embeddings','controlnet','model_patches','clip','clip_vision','upscale_models','tensorrt','unet'):
(work/folder).mkdir(parents=True,exist_ok=True);os.chown(work/folder,65534,65534)
for meta in (deck/'models').glob('*/entry.json'):
item=json.loads(meta.read_text());f=PurePosixPath(item['file']);source=meta.parent/('model'+f.suffix)
if item.get('repo')=='Lightricks/LTX-2.5' and f.parts[0] in ('diffusion_models','text_encoders','vae','latent_upscale_models') and source.is_file():
link=work/f.parts[0]/f.name
if not link.exists() and not link.is_symlink():link.symlink_to('/var/lib/deck/models/'+meta.parent.name+'/model'+f.suffix)
base=manager.state/'services'/name
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['memory']:args+=['--memory',str(c['memory'])]
if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)]
for k,v in c['labels'].items():
if not isinstance(k,str) or not k.startswith('io.athena-deck.') or not isinstance(v,str) or len(v)>512:raise ValueError('Ungültige Labels.')
args+=['--label',k+'='+v]
for p,bindings in c['ports'].items():
for b in bindings:args+=['-p',b['HostIp']+':'+str(b['HostPort'])+':'+p]
for value in c['env']:args+=['-e',value]
if c['user']:args+=['--user',c['user']]
if c['workdir']:args+=['--workdir',c['workdir']]
for i,m in enumerate(c['mounts']):
source=deck/m['deck_path'] if m['deck_path'] is not None else base/str(i)
if source.is_symlink():raise ValueError('Unsicheres Volume-Verzeichnis.')
if m['deck_path']:
if m['deck_path']=='video/comfy-client-token' and not source.exists():
import secrets
source.parent.mkdir(parents=True,exist_ok=True);source.write_text(secrets.token_urlsafe(48)+'\n');source.chmod(0o600);os.chown(source,65534,65534)
elif m['deck_path']!='video/comfy-client-token':
source.mkdir(parents=True,exist_ok=True)
if m['deck_path'].startswith('video/original-work/'):
is_input=m['deck_path'].endswith('/deskweb');os.chown(source,1000 if is_input else 65534,65534);source.chmod(0o2770 if is_input else 0o2750)
elif m['was_file']:
if 'file' not in m['files']:raise ValueError('Benötigte Volume-Datei fehlt im Backup.')
source.write_bytes(base64.b64decode(m['files']['file']));source.chmod(0o600)
else:
source.mkdir(exist_ok=True)
for filename,data in m['files'].items():
target=source/filename;target.write_bytes(base64.b64decode(data));target.chmod(0o600)
# Application UIDs commonly used by these rootless interfaces.
uid=c.get('uid',int(c['user'].split(':')[0]) if c['user'].split(':')[0].isdigit() else 0)
if m['deck_path'] is None:
os.chown(source,uid,uid)
if source.is_dir():
for p in source.iterdir():os.chown(p,uid,uid)
args+=['--mount',f'type=bind,src={source},dst={m["target"]}'+(',readonly' if m['read_only'] else '')]
if c.get('entrypoint'):
args+=['--entrypoint',c['entrypoint'][0]];entry_tail=c['entrypoint'][1:]
else:entry_tail=[]
args+=[c['image'] if c.get('build_recipe') else c['registry'],*entry_tail,*(c.get('cmd') or [])]
command(args)
if c['running']:
command(['start',name]);time.sleep(1)
if command(['inspect','--format','{{.State.Running}}',name])!='true':raise ValueError('Wiederhergestellter Container ist nicht gestartet geblieben.')
return {'state':'complete','message':'Image bereit, Konfiguration und Container wiederhergestellt.'}