#!/usr/bin/env python3 """Own a disposable synthetic Grocy fixture. Never use an existing household.""" import argparse, json, os, pathlib, secrets, subprocess, time, urllib.request IMAGE = "lscr.io/linuxserver/grocy@sha256:3c1ce96cb2d0f0c7ea0c2e0b89f5769be7f2f102204fbeef87aeef56afd6036e" LABEL = "io.github.hermes-grocy-mcp.contract" def docker(*args, input=None): return subprocess.check_output(["docker", *args], input=input, stderr=subprocess.DEVNULL).decode().strip() def load(state): p = state / "fixture.json" if p.is_symlink() or p.stat().st_uid != os.getuid() or p.stat().st_mode & 0o077: raise RuntimeError("Unsafe fixture manifest") return json.loads(p.read_text()) def start(state): state.mkdir(mode=0o700, parents=True, exist_ok=False) ident = secrets.token_hex(6) name = "hermes-grocy-contract-" + ident data = {"name":name,"network":name,"volume":name,"image":IMAGE,"api_key":secrets.token_hex(32)} manifest=state / "fixture.json" fd=os.open(manifest, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd,"w") as f: json.dump(data,f) try: docker("network","create","--label",LABEL+"="+ident,name) docker("volume","create","--label",LABEL+"="+ident,name) docker("run","--detach","--name",name,"--label",LABEL+"="+ident,"--network",name,"-p","127.0.0.1::80","-v",name+":/config",IMAGE) port=docker("port",name,"80/tcp").rsplit(":",1)[1] data["url"]="http://127.0.0.1:"+port # Fixture bootstrap may visit the UI for migrations. The MCP never does. for _ in range(90): try: urllib.request.urlopen(data["url"]+"/",timeout=2).read(256) break except Exception: time.sleep(0.5) code = """$p=new PDO("sqlite:/config/data/grocy.db");$k=trim(stream_get_contents(STDIN));$s=$p->prepare("INSERT INTO api_keys (api_key,user_id,expires,key_type) VALUES (?,1,'2999-12-31 23:59:59','default')");$s->execute([$k]);echo "ready";""" docker("exec","-i",name,"php","-r",code,input=data["api_key"].encode()) req=urllib.request.Request(data["url"]+"/api/system/info",headers={"GROCY-API-KEY":data["api_key"]}) data["version"]=json.load(urllib.request.urlopen(req,timeout=5))["grocy_version"]["Version"] manifest.write_text(json.dumps(data)) print("Disposable fixture ready; Grocy",data["version"]) except Exception: stop(state) raise def stop(state): data=load(state) docker("info","--format","{{.ServerVersion}}") # Keep the manifest if the daemon is unavailable. ident=data["name"].removeprefix("hermes-grocy-contract-") for kind,name in [("container",data["name"]),("volume",data["volume"]),("network",data["network"])]: try: label=docker(kind,"inspect",name,"--format",'{{ index .Labels "'+LABEL+'" }}' if kind!="container" else '{{ index .Config.Labels "'+LABEL+'" }}') if label != ident: raise RuntimeError("Refusing to remove a resource not owned by this fixture") if kind=="container":docker("rm","-f",name) else:docker(kind,"rm",name) except subprocess.CalledProcessError: pass (state / "fixture.json").unlink() try: state.rmdir() except OSError: pass # Leave any unrelated files and their directory intact. print("Owned disposable fixture removed") def test(state): env=os.environ.copy() env["GROCY_CONTRACT_MANIFEST"]=str(state / "fixture.json") subprocess.run(["cargo","test","--test","docker_contract","--","--ignored","--nocapture"],env=env,check=True) if __name__=="__main__": ap=argparse.ArgumentParser(description=__doc__) ap.add_argument("action",choices=["start","test","stop"]) ap.add_argument("--state",type=pathlib.Path,required=True,help="Dedicated disposable state directory outside the repository") args=ap.parse_args() globals()[args.action](args.state)