73 lines
3.9 KiB
Python
73 lines
3.9 KiB
Python
#!/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)
|