#!/usr/bin/env python3 """Root-owned local helper. Only inventory and fresh Docker install; no generic RPC.""" import argparse import json import os from pathlib import Path import shutil import socket import socketserver import struct import subprocess import threading import time MANAGED_LABEL='io.athena-deck.managed=true' ROLE_LABEL='io.athena-deck.role=application' PACKAGES=['docker-ce','docker-ce-cli','containerd.io','docker-buildx-plugin','docker-compose-plugin'] CONFLICTS=['docker.io','docker-compose','docker-doc','docker-buildx','podman-docker','containerd','runc'] ENV={'PATH':'/usr/sbin:/usr/bin:/sbin:/bin','HOME':'/var/lib/athena-deck-docker','LANG':'C.UTF-8','DEBIAN_FRONTEND':'noninteractive','NEEDRESTART_MODE':'l'} def run(args,timeout=15): return subprocess.run(args,capture_output=True,text=True,timeout=timeout,env=ENV) def release(): return dict(line.split('=',1) for line in Path('/etc/os-release').read_text().replace('"','').splitlines() if '=' in line) class Manager: def __init__(self,state,allow_install=False): self.state=Path(state);self.allow_install=allow_install;self.lock=threading.RLock();self.job=None if (self.state/'job.json').exists(): self.job=json.loads((self.state/'job.json').read_text()) if self.job['state']=='running':self.job.update(state='interrupted',phase='Installation unterbrochen. Hostzustand prüfen; vorhandene Pakete werden nicht automatisch ersetzt.') def inventory(self): # Filter at the daemon before retrieving rows. No inspect, env, mounts or logs. result=run(['/usr/bin/docker','ps','-a','--no-trunc','--filter','label='+MANAGED_LABEL,'--filter','label='+ROLE_LABEL,'--format','{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}']) if result.returncode:raise ValueError('Docker-Daemon nicht erreichbar.') rows=[] for line in result.stdout.splitlines()[:500]: fields=line.split('\t') if len(fields)==5:rows.append(dict(zip(('id','name','image','state','status'),fields))) return rows def preflight(self): info=release() if info.get('ID')!='debian' or info.get('VERSION_ID') not in ('12','13'):return False,'Unterstützt werden Debian 12 und 13.' if Path('/usr/bin/docker').exists() or Path('/var/run/docker.sock').exists():return False,'Docker ist vorhanden. Keine automatische Neuinstallation oder Aktualisierung.' if not self.allow_install:return False,'Docker-Erstinstallation ist für diesen Helfer nicht freigegeben.' if any(Path(p).exists() for p in ('/var/lib/docker','/var/lib/containerd','/etc/docker')):return False,'Vorhandene Containerdaten oder Konfiguration erkannt. Manuelle Prüfung erforderlich.' for package in CONFLICTS+PACKAGES: value=run(['/usr/bin/dpkg-query','-W','-f=${Status}',package]) if value.returncode==0 and 'install ok installed' in value.stdout:return False,'Vorhandene Containerpakete erkannt. Keine automatische Entfernung oder Ersetzung.' if shutil.disk_usage('/var').free<3*1024**3:return False,'Mindestens 3 GiB freier Speicher erforderlich.' return True,'Docker Engine, Compose und Buildx aus dem offiziellen Docker-Repository installieren. Docker richtet einen Systemdienst sowie eigene Netzwerk-/Firewallregeln ein. Keine NVIDIA-Treiberinstallation und kein Host-Neustart.' def status(self): installed=Path('/usr/bin/docker').exists();reachable=False;version=None;services=[] if installed: try: version_result=run(['/usr/bin/docker','version','--format','{{.Server.Version}}']) if version_result.returncode==0:version=version_result.stdout.strip();services=self.inventory();reachable=True except (OSError,ValueError,subprocess.SubprocessError):pass supported,message=self.preflight() with self.lock:job=dict(self.job) if self.job else None return dict(installed=installed,reachable=reachable,version=version,services=services,install_supported=supported,message=message,job=job,labels=[MANAGED_LABEL,ROLE_LABEL]) def _phase(self,text,state='running'): with self.lock: self.job.update(state=state,phase=text);self.state.mkdir(parents=True,exist_ok=True,mode=0o700) temp=self.state/'job.tmp';temp.write_text(json.dumps(self.job));temp.replace(self.state/'job.json') def install(self): with self.lock: if self.job and self.job['state']=='running':raise ValueError('Docker-Installation läuft bereits.') allowed,message=self.preflight() if not allowed:raise ValueError(message) self.job=dict(state='running',phase='Docker-Erstinstallation wird vorbereitet',started_at=time.time()) self._phase(self.job['phase']);threading.Thread(target=self._install,daemon=True).start() return {'started':True} def _checked(self,args): result=run(args,timeout=1200) if result.returncode:raise ValueError('Installationsschritt fehlgeschlagen. Paketverwaltung und Internetverbindung auf dem Host prüfen.') def _install(self): try: info=release();codename={'12':'bookworm','13':'trixie'}[info['VERSION_ID']] arch=run(['/usr/bin/dpkg','--print-architecture']).stdout.strip() if arch not in ('amd64','arm64'):raise ValueError('Diese Architektur wird vom Deck-Installer noch nicht unterstützt.') self._phase('Paketquellen prüfen und Download-Werkzeuge installieren') self._checked(['/usr/bin/apt-get','update']) self._checked(['/usr/bin/apt-get','install','-y','--no-remove','ca-certificates','curl']) key=Path('/etc/apt/keyrings/athena-deck-docker.asc');source=Path('/etc/apt/sources.list.d/athena-deck-docker.sources') key.parent.mkdir(mode=0o755,exist_ok=True) self._checked(['/usr/bin/curl','--fail','--silent','--show-error','--proto','=https','--tlsv1.2','--max-time','60','https://download.docker.com/linux/debian/gpg','-o',str(key)]) key.chmod(0o644) source.write_text(f'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: {codename}\nComponents: stable\nArchitectures: {arch}\nSigned-By: {key}\n');source.chmod(0o644) self._phase('Docker Engine, Compose und Buildx installieren') self._checked(['/usr/bin/apt-get','update']) self._checked(['/usr/bin/apt-get','install','-y','--no-remove',*PACKAGES]) self._phase('Docker-Systemdienst starten und Erreichbarkeit prüfen') self._checked(['/usr/bin/systemctl','enable','--now','docker.service']) self._checked(['/usr/bin/docker','version','--format','{{.Server.Version}}']) self._checked(['/usr/bin/docker','compose','version','--short']) self._phase('Docker bereit. Keine Anwendungen oder Modelle installiert.','complete') except Exception as exc:self._phase(str(exc) if isinstance(exc,ValueError) else 'Docker-Installation fehlgeschlagen. Hostzustand vor erneutem Versuch prüfen.','failed') def dispatch(self,data): if not isinstance(data,dict) or set(data)!={'action'}:raise ValueError('Ungültige Helferanfrage.') if data['action']=='status':return self.status() if data['action']=='install':return self.install() raise ValueError('Aktion nicht erlaubt.') class Handler(socketserver.StreamRequestHandler): def handle(self): self.connection.settimeout(20) try: _,uid,_=struct.unpack('3i',self.connection.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12)) if uid not in (0,self.server.client_uid):raise ValueError('Client nicht erlaubt.') raw=self.rfile.readline(4097) if len(raw)>4096:raise ValueError('Anfrage zu groß.') result=self.server.manager.dispatch(json.loads(raw)) except Exception as exc:result={'error':str(exc) if isinstance(exc,ValueError) else 'Docker-Systemhelfer nicht verfügbar.'} self.wfile.write(json.dumps(result).encode()+b'\n') class Server(socketserver.ThreadingUnixStreamServer): daemon_threads=True def main(): parser=argparse.ArgumentParser();parser.add_argument('--client-uid',type=int,required=True);parser.add_argument('--client-gid',type=int,required=True);parser.add_argument('--allow-install',action='store_true');parser.add_argument('--state',default='/var/lib/athena-deck-docker');args=parser.parse_args() socket_path=Path('/run/athena-deck-docker/control.sock');socket_path.parent.mkdir(mode=0o755,exist_ok=True);socket_path.unlink(missing_ok=True) with Server(str(socket_path),Handler) as server: os.chown(socket_path,0,args.client_gid);socket_path.chmod(0o660);server.client_uid=args.client_uid;server.manager=Manager(args.state,args.allow_install);server.serve_forever() if __name__=='__main__':main()