import json, os, re, subprocess, sys from pathlib import Path PROTO='jarvis.skill.invoke.v1' NAMES=('compose.yaml','compose.yml','docker-compose.yaml','docker-compose.yml') def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}} def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut} def root(): p=Path(os.getenv('DOCKGE_STACKS_DIR','/opt/stacks')).resolve() if not p.exists() or not p.is_dir(): raise RuntimeError(f'Dockge-Stacks-Verzeichnis fehlt: {p}') return p def stack_dir(name): if not re.fullmatch(r'[A-Za-z0-9_.-]+',name or ''): raise RuntimeError('Ungültiger Stack-Name') p=(root()/name).resolve(); r=root() if p.parent!=r or not p.is_dir(): raise RuntimeError('Stack nicht gefunden') if not any((p/n).is_file() for n in NAMES): raise RuntimeError('Keine Compose-Datei im Stack gefunden') return p def allowed(name): vals=[x.strip() for x in os.getenv('DOCKGE_ALLOWED_STACKS','').split(',') if x.strip()] return '*' in vals or name in vals def require_allowed(name): if not allowed(name): raise RuntimeError('Mutation verweigert: Stack ist nicht in DOCKGE_ALLOWED_STACKS freigegeben') def compose_cmd(): # Prefer modern plugin; fall back to legacy docker-compose. try: subprocess.run(['docker','compose','version'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True,timeout=5) return ['docker','compose'] except Exception: return ['docker-compose'] def run(name,args,timeout=90): p=stack_dir(name); cmd=compose_cmd()+args cp=subprocess.run(cmd,cwd=p,text=True,capture_output=True,timeout=timeout) if cp.returncode!=0: raise RuntimeError((cp.stderr or cp.stdout or f'Exit {cp.returncode}')[-1800:]) return (cp.stdout or '').strip() def main(inv): a=inv.get('action'); x=inv.get('input') or {} if a=='list_stacks': out=[] for p in sorted(root().iterdir()): if not p.is_dir(): continue cf=next((n for n in NAMES if (p/n).is_file()),None) if cf: out.append({'name':p.name,'compose_file':cf,'mutation_allowed':allowed(p.name)}) return ok({'stacks':out,'count':len(out)},'Dockge-Stacks geladen.') name=x['stack'] if a=='stack_status': raw=run(name,['ps','--format','json'],30); items=[] for line in raw.splitlines(): try: v=json.loads(line) items.extend(v if isinstance(v,list) else [v]) except Exception: pass return ok({'stack':name,'services':items,'count':len(items)},'Stack-Status geladen.') require_allowed(name) if a=='start_stack': run(name,['up','-d','--remove-orphans']); return ok({'stack':name,'action':'start'},'Stack gestartet.',True) if a=='stop_stack': run(name,['stop']); return ok({'stack':name,'action':'stop'},'Stack gestoppt.',True) if a=='restart_stack': run(name,['restart']); return ok({'stack':name,'action':'restart'},'Stack neu gestartet.',True) if a=='update_stack': run(name,['pull'],120); run(name,['up','-d','--remove-orphans'],120); return ok({'stack':name,'action':'update'},'Stack aktualisiert.',True) return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}') try: res=main(json.load(sys.stdin)) except subprocess.TimeoutExpired: res=fail('DOCKGE_TIMEOUT','Docker-Compose-Aktion hat das Zeitlimit überschritten') except Exception as e: res=fail('DOCKGE_ERROR',str(e)) json.dump(res,sys.stdout,ensure_ascii=False)