Files
SocialPub/tools/pasture/town/core/podman.py
T

99 lines
3.1 KiB
Python
Raw Normal View History

"""The pasture's containers from the workstation: commands, Postgres rows as JSON, Mongo documents as JSON, a copy of
GoToSocial's sqlite. Only reads go through here; nothing a peer does is faked in its database."""
import json
import os
import shutil
import sqlite3
import subprocess
import tempfile
def run(*args, input=None, check=True, timeout=300):
proc = subprocess.run(["podman", *args], input=input, capture_output=True, timeout=timeout,
text=isinstance(input, str) or input is None)
if check and proc.returncode != 0:
raise RuntimeError(f"podman {' '.join(args[:3])}… failed: {proc.stderr.strip()[:500]}")
return proc.stdout
def exec_(container, *cmd, input=None, check=True, timeout=300, workdir=None):
args = ["exec"]
if input is not None:
args.append("-i")
if workdir:
args += ["-w", workdir]
return run(*args, container, *cmd, input=input, check=check, timeout=timeout)
def exists(container):
return subprocess.run(["podman", "container", "exists", container]).returncode == 0
def running():
out = run("ps", "--format", "{{.Names}}")
return [n for n in out.split() if n.startswith("pasture-")]
def psql(db, sql, *params):
"""Rows of `sql` as a list of dicts. Parameters are bound by psql (:'p1', :'p2'...), never pasted into the SQL."""
args = ["exec", "-i", "pasture-postgres", "psql", "-U", "pasture", "-d", db, "-tAX", "-v", "ON_ERROR_STOP=1"]
for i, p in enumerate(params, 1):
args += ["-v", f"p{i}={p}"]
wrapped = f"select coalesce(json_agg(t), '[]'::json) from ({sql.rstrip().rstrip(';')}) t;\n"
out = run(*args, input=wrapped)
return json.loads(out.strip() or "[]")
def psql_value(db, sql, *params):
rows = psql(db, sql, *params)
if not rows:
return None
return next(iter(rows[0].values()))
def mongo(js, db="PrivaPub"):
"""The JSON value of a mongosh expression, e.g. `db.Post.find({...}).toArray()`."""
script = f"print(EJSON.stringify(({js}), {{relaxed: true}}))"
out = run("exec", "pasture-mongo", "mongosh", "--quiet", db, "--eval", script, timeout=120)
return json.loads(out.strip().splitlines()[-1]) if out.strip() else None
class SqliteCopy:
"""A consistent copy of a container's sqlite database (with its WAL), opened read-only on the workstation."""
def __init__(self, container, path):
self.dir = tempfile.mkdtemp(prefix="town-sqlite-")
local = os.path.join(self.dir, os.path.basename(path))
run("cp", f"{container}:{path}", local)
for suffix in ("-wal", "-shm"):
subprocess.run(["podman", "cp", f"{container}:{path}{suffix}", local + suffix], capture_output=True)
self.db = sqlite3.connect(local)
self.db.row_factory = sqlite3.Row
def rows(self, sql, *params):
return [dict(r) for r in self.db.execute(sql, params)]
def close(self):
self.db.close()
shutil.rmtree(self.dir, ignore_errors=True)
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
def logs(container, since=None):
args = ["logs"]
if since:
args += ["--since", since]
return run(*args, container, check=False)
def stats():
out = run("stats", "--no-stream", "--format", "json", *running(), check=False)
try:
return json.loads(out)
except ValueError:
return []