From 1081b26c735043611b9f08ea15267992d43f12c3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:30:39 +0000 Subject: [PATCH 001/104] dev2: fleet kit to move Railway sites onto dev2 and deploy them on merge dev2-site reads the truth from Railway (services, domains, volumes, database variables), renders the box files from templates, provisions ~/www/ over ssh, copies Postgres through Railway's ssh endpoint into the shared cluster, issues the certificate over Porkbun DNS-01 before DNS moves, flips Porkbun, stops and disconnects the Railway service, and merges the per-repo deploy-dev2.yml. First site through it: bg0ne.com. Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 0 -> 89070 bytes dev2/dev2-site | 794 ++++++ dev2/sites.json | 2753 +++++++++++++++++++++ dev2/sites.overrides.json | 1 + dev2/templates/deploy-app.sh | 110 + dev2/templates/deploy-dev2.yml | 60 + dev2/templates/nginx-vhost.conf | 45 + 7 files changed, 3763 insertions(+) create mode 100644 dev2/__pycache__/dev2-sitecpython-312.pyc create mode 100755 dev2/dev2-site create mode 100644 dev2/sites.json create mode 100644 dev2/sites.overrides.json create mode 100755 dev2/templates/deploy-app.sh create mode 100644 dev2/templates/deploy-dev2.yml create mode 100644 dev2/templates/nginx-vhost.conf diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..763b89e4ec7d78c02e20df28700254f27900c1fc GIT binary patch literal 89070 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
  • {8+1+Op!@j|%q#)yZXgPU#Gd4TYpN_j!;;>@8J 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 QS4qA*jAve?YCcT<2J4o&C;$Ke literal 0 HcmV?d00001 diff --git a/dev2/dev2-site b/dev2/dev2-site new file mode 100755 index 0000000..18af391 --- /dev/null +++ b/dev2/dev2-site @@ -0,0 +1,794 @@ +#!/usr/bin/env python3 +""" +dev2-site: move a Railway-hosted site onto dev2 and keep it deploying on merge. + + dev2-site registry rebuild sites.json from Railway (the truth) + dev2-site list [--kind pg|none|supabase|turso] + dev2-site show + dev2-site scaffold deploy-dev2.yml (+ .nixpacks Dockerfile) in the repo, as a PR + dev2-site provision ~/www/ on dev2: env, compose, deploy script, deploy key, secrets + dev2-site db database in the shared cluster + copy from Railway Postgres + dev2-site volumes copy Railway volume contents into ~/www//volumes + dev2-site deploy [ref] build + start on dev2 (as the deploy account) + dev2-site cert Let's Encrypt via Porkbun DNS-01, before DNS moves + dev2-site vhost nginx server block -> 127.0.0.1: + dev2-site verify [--public] curl the site through nginx (pinned to dev2, or via DNS) + dev2-site dns flip every domain at Porkbun to dev2 + dev2-site railway-stop remove Railway deployments + disconnect the repo watch + dev2-site merge merge the scaffold PR (CI then deploys the default branch) + dev2-site migrate all of the above, in order, resumable + dev2-site retire --yes delete the Railway services (only after migrate) + +Run from dev1. dev2 is production: this tool is the only thing that writes to it, +and everything it writes is derived from Railway + these templates, so a re-run +reproduces the box. State per site lives in ~/.local/state/dev2-fleet/.json. +""" +import argparse +import datetime as dt +import json +import os +import re +import secrets +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request + +HERE = os.path.dirname(os.path.realpath(__file__)) +TEMPLATES = os.path.join(HERE, "templates") +REGISTRY = os.path.join(HERE, "sites.json") +OVERRIDES = os.path.join(HERE, "sites.overrides.json") +STATE_DIR = os.path.expanduser(os.environ.get("DEV2_STATE_DIR", "~/.local/state/dev2-fleet")) + +DEV2_HOST = os.environ.get("DEV2_HOST", "dev2.profullstack.com") +DEV2_IP = os.environ.get("DEV2_IP", "23.95.228.174") +DEPLOY_USER = "anthony" +WWW = f"/home/{DEPLOY_USER}/www" +SRC_ROOT = os.path.expanduser("~/src/profullstack") +CLUSTER_ENV = "/home/anthony/www/crawlproof.com/supabase/.env" # the shared Supabase cluster +PG_CONTAINER = "supabase-db" +WORKSPACE_ID = "50db2758-e580-4af6-b904-366b6bb2a71b" +FIRST_PORT = 3200 +RAILWAY_SSH_KEY = os.path.expanduser("~/.ssh/id_ed25519") +ACME_EMAIL = "anthony@profullstack.com" + +# ------------------------------------------------------------------ utils + +def log(msg): print(f"\n===> {msg}", file=sys.stderr, flush=True) +def note(msg): print(f" {msg}", file=sys.stderr, flush=True) +def warn(msg): print(f"WARNING: {msg}", file=sys.stderr, flush=True) +def die(msg, code=1): + print(f"ERROR: {msg}", file=sys.stderr, flush=True); sys.exit(code) + +def sh(cmd, check=True, input=None, capture=True, env=None, timeout=None): + """Run a command (list or string). Returns stdout (str) when capture.""" + if isinstance(cmd, str): + cmd = ["bash", "-o", "pipefail", "-c", cmd] + p = subprocess.run(cmd, input=input, text=True, capture_output=capture, env=env, timeout=timeout) + if check and p.returncode != 0: + raise RuntimeError(f"command failed ({p.returncode}): {cmd if isinstance(cmd, str) else ' '.join(map(shlex.quote, cmd))}\n{(p.stdout or '')[-1500:]}\n{(p.stderr or '')[-2000:]}") + return p.stdout if capture else "" + +def ssh(user, cmd, input=None, check=True, timeout=None): + return sh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20", f"{user}@{DEV2_HOST}", cmd], + input=input, check=check, timeout=timeout) + +def ssh_put(content, path, mode="0644", owner=None, user="root"): + """Write a file on dev2 from stdin (no scp, no temp file on the box).""" + own = f"-o {owner.split(':')[0]} -g {owner.split(':')[-1]} " if owner else "" + ssh(user, f"install -m {mode} {own}/dev/stdin {shlex.quote(path)}", input=content) + +def state_path(site): return os.path.join(STATE_DIR, f"{site}.json") +def load_state(site): + p = state_path(site) + return json.load(open(p)) if os.path.exists(p) else {"site": site, "steps": {}} +def save_state(site, st): + os.makedirs(STATE_DIR, mode=0o700, exist_ok=True) + tmp = state_path(site) + ".tmp" + with open(tmp, "w") as f: json.dump(st, f, indent=1); f.write("\n") + os.chmod(tmp, 0o600); os.replace(tmp, state_path(site)) +def mark(site, step, **extra): + st = load_state(site); st["steps"][step] = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + st.update(extra); save_state(site, st) + +def tmpl(name): + with open(os.path.join(TEMPLATES, name)) as f: return f.read() + +# ---------------------------------------------------------------- railway + +def rw_token(): + cfg = json.load(open(os.path.expanduser("~/.railway/config.json"))) + exp = cfg.get("user", {}).get("tokenExpiresAt", 0) + if isinstance(exp, str): + exp = int(dt.datetime.fromisoformat(exp.replace("Z", "+00:00")).timestamp()) + if exp < time.time() + 300: + subprocess.run(["railway", "whoami"], capture_output=True) + cfg = json.load(open(os.path.expanduser("~/.railway/config.json"))) + tok = cfg.get("user", {}).get("accessToken") + if not tok: die("no Railway token: run `railway login`") + return tok + +def gql(query, variables=None, retries=4): + body = json.dumps({"query": query, "variables": variables or {}}).encode() + for attempt in range(retries): + req = urllib.request.Request("https://backboard.railway.com/graphql/v2", data=body, headers={ + "Authorization": f"Bearer {rw_token()}", "Content-Type": "application/json", "User-Agent": "dev2-site"}) + try: + with urllib.request.urlopen(req, timeout=120) as r: out = json.load(r) + except urllib.error.HTTPError as e: + out = {"errors": [{"message": f"http {e.code}: {e.read()[:300]!r}"}]} + if "errors" in out: + msg = json.dumps(out["errors"]) + if "Too many" in msg or "Not Authorized" in msg and attempt < retries - 1: + time.sleep(5 * (attempt + 1)); subprocess.run(["railway", "whoami"], capture_output=True); continue + raise RuntimeError(f"railway api: {msg[:800]}") + return out["data"] + raise RuntimeError("railway api: gave up") + +def rw_variables(pid, eid, sid): + return gql("query($p:String!,$e:String!,$s:String!){variables(projectId:$p,environmentId:$e,serviceId:$s)}", + {"p": pid, "e": eid, "s": sid})["variables"] or {} + +def rw_ssh_user(pid, eid, service_name): + out = sh(["railway", "ssh", "config", "--dry-run", "-i", RAILWAY_SSH_KEY, "--project", pid, + "--environment", eid, "--service", service_name]) + m = re.search(r"^\s*User\s+(\S+)", out, re.M) + if not m: die(f"railway ssh config gave no User for {service_name}") + return m.group(1) + +def rw_ssh_cmd(user, remote_cmd): + """The ssh argv that runs remote_cmd inside a Railway container.""" + return ["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", f"UserKnownHostsFile={os.path.expanduser('~/.ssh/known_hosts_railway')}", + "-o", "ServerAliveInterval=30", "-o", "LogLevel=ERROR", "-i", RAILWAY_SSH_KEY, "-l", user, "ssh.railway.com", remote_cmd] + +def rw_deployments(pid, eid, sid, first=10): + d = gql("query($p:String!,$e:String!,$s:String!,$n:Int!){deployments(first:$n,input:{projectId:$p,environmentId:$e,serviceId:$s}){edges{node{id status createdAt}}}}", + {"p": pid, "e": eid, "s": sid, "n": first}) + return [e["node"] for e in d["deployments"]["edges"]] + +# --------------------------------------------------------------- registry + +DB_HOST_KINDS = ( + (re.compile(r"postgres(ql)?://[^\s]*railway\.internal"), "pg"), + (re.compile(r"postgres(ql)?://[^\s]*\.rlwy\.net"), "pg"), + (re.compile(r"\.supabase\.co"), "supabase"), + (re.compile(r"libsql://|\.turso\.io"), "turso"), + (re.compile(r"postgres(ql)?://"), "pg-ext"), +) + +def classify_db(vars_): + kinds = set() + for k, v in vars_.items(): + if not isinstance(v, str): continue + if re.search(r"REDIS", k): continue + for rx, kind in DB_HOST_KINDS: + if rx.search(v): + kinds.add(kind); break + for pref in ("pg", "supabase", "turso", "pg-ext"): + if pref in kinds: return pref, sorted(kinds) + return "none", [] + +def uses_redis(vars_): + return any("redis" in str(v).lower() and "railway.internal" in str(v) for v in vars_.values()) + +def primary_domain(domains): + customs = [d for d in domains if not d.endswith(".up.railway.app")] + if not customs: return None + apex = [d for d in customs if d.count(".") == 1 and not d.startswith("www.")] + if apex: return apex[0] + nonwww = [d for d in customs if not d.startswith("www.") and not d.startswith("*.")] + return (nonwww or customs)[0] + +def slug(name): + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + +def infra_role(src): + img = (src or {}).get("image") or "" + repo = (src or {}).get("repo") or "" + if re.match(r"(ghcr\.io/railwayapp-templates/postgres|postgres:|.*supabase-postgres)", img): return "pg" + if re.match(r"redis", img): return "redis" + if repo == "6ixfalls/supabase" or "postgres-meta" in img: return "supabase-infra" + return "app" + +def cmd_registry(args): + log("Reading every project, service, domain, volume and database variable from Railway") + ws = gql("query($w:String!){workspace(workspaceId:$w){projects{edges{node{id name environments{edges{node{id name}}} services{edges{node{id name serviceInstances{edges{node{environmentId source{repo image} domains{serviceDomains{domain} customDomains{domain}}}}}}}} volumes{edges{node{id name volumeInstances{edges{node{serviceId environmentId mountPath currentSizeMB}}}}}}}}}}}", + {"w": WORKSPACE_ID})["workspace"]["projects"]["edges"] + overrides = json.load(open(OVERRIDES)) if os.path.exists(OVERRIDES) else {} + old = json.load(open(REGISTRY)) if os.path.exists(REGISTRY) else {"sites": {}} + used_ports = {s["port"] for s in old["sites"].values() if s.get("port")} + used_ports |= {3010, 3020, 3100, 5432, 6379, 6543, 8000} + sites = {} + for pe in ws: + p = pe["node"] + envs = {e["node"]["id"]: e["node"]["name"] for e in p["environments"]["edges"]} + prod = [k for k, v in envs.items() if v == "production"] or list(envs) + if not prod: continue + eid = prod[0] + mounts = {} + for ve in p["volumes"]["edges"]: + for vi in ve["node"]["volumeInstances"]["edges"]: + n = vi["node"] + if n["environmentId"] != eid: continue + mounts.setdefault(n["serviceId"], []).append({"name": ve["node"]["name"], "id": ve["node"]["id"], "mountPath": n["mountPath"], "sizeMB": round(n["currentSizeMB"] or 0)}) + svcs = [] + for se in p["services"]["edges"]: + s = se["node"] + inst = [i["node"] for i in s["serviceInstances"]["edges"] if i["node"]["environmentId"] == eid] + if not inst: continue + i = inst[0] + doms = [d["domain"] for d in (i["domains"] or {}).get("customDomains", [])] + [d["domain"] for d in (i["domains"] or {}).get("serviceDomains", [])] + try: vars_ = rw_variables(p["id"], eid, s["id"]) + except RuntimeError as e: warn(f"{p['name']}/{s['name']}: variables: {e}"); vars_ = {} + kind, kinds = classify_db(vars_) + db_host = redis_host = None + for v in vars_.values(): + if not isinstance(v, str): continue + m = re.search(r"postgres(?:ql)?://[^@\s]*@([a-z0-9.-]+\.railway\.internal)", v) + if m and not db_host: db_host = m.group(1) + m = re.search(r"redis://[^@\s]*@?([a-z0-9.-]+\.railway\.internal)", v) + if m and not redis_host: redis_host = m.group(1) + svcs.append({"db_host": db_host, "redis_host": redis_host, "id": s["id"], "name": s["name"], "role": infra_role(i["source"]), "repo": (i["source"] or {}).get("repo"), + "image": (i["source"] or {}).get("image"), "domains": doms, "volumes": mounts.get(s["id"], []), + "db": kind, "db_kinds": kinds, "redis": uses_redis(vars_), "nvars": len(vars_), + "port_var": vars_.get("PORT") if isinstance(vars_.get("PORT"), str) and vars_.get("PORT", "").isdigit() else None}) + apps = [s for s in svcs if s["role"] == "app"] + infra = [s for s in svcs if s["role"] != "app"] + # One site per app service that has a custom domain; same-repo, domain-less + # app services in the same project ride along as companions (workers). + fronts = [s for s in apps if primary_domain(s["domains"])] + orphans = [s for s in apps if not primary_domain(s["domains"])] + for s in fronts: + site = primary_domain(s["domains"]) + comps = [o for o in orphans if o["repo"] and o["repo"] == s["repo"]] + shared_repo = sum(1 for f in fronts if f["repo"] == s["repo"]) > 1 + entry = { + "site": site, "project": p["name"], "project_id": p["id"], "environment_id": eid, + "service": s["name"], "service_id": s["id"], "repo": s["repo"], "image": s["image"], + "domains": [d for d in s["domains"] if not d.endswith(".up.railway.app")], + "railway_domains": [d for d in s["domains"] if d.endswith(".up.railway.app")], + "db": s["db"], "db_kinds": s["db_kinds"], "redis": s["redis"], "volumes": s["volumes"], "port_var": s["port_var"], + "companions": [{"name": c["name"], "id": c["id"], "volumes": c["volumes"], "db": c["db"], "redis": c["redis"]} for c in comps], + "db_host": s["db_host"], "redis_host": s["redis_host"], + # Data services that belong to this site: in a one-app project all of them, + # otherwise the ones whose internal hostname (service name slug) the app points at. + "infra": [{"name": i["name"], "id": i["id"], "role": i["role"], "image": i["image"], "volumes": i["volumes"]} for i in infra + if len(fronts) == 1 or slug(i["name"]) in {(s["db_host"] or "").split(".")[0], (s["redis_host"] or "").split(".")[0]}], + "shared_repo": shared_repo, + } + entry.update(overrides.get(site, {})) + prev = old["sites"].get(site, {}) + port = entry.get("port") or prev.get("port") + if not port: + port = FIRST_PORT + while port in used_ports: port += 1 + used_ports.add(port); entry["port"] = port + for k in ("status", "notes", "moved_at"): + if k in prev and k not in entry: entry[k] = prev[k] + sites[site] = entry + for o in orphans: + if not any(o["repo"] and o["repo"] == f["repo"] for f in fronts): + note(f"{p['name']}/{o['name']}: app service with no custom domain and no same-repo front; skipped (add to sites.overrides.json to include)") + reg = {"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "dev2": {"host": DEV2_HOST, "ip": DEV2_IP}, + "sites": dict(sorted(sites.items()))} + with open(REGISTRY, "w") as f: json.dump(reg, f, indent=1); f.write("\n") + log(f"{len(sites)} sites -> {REGISTRY}") + cmd_list(argparse.Namespace(kind=None, status=None)) + +def registry(): + if not os.path.exists(REGISTRY): die("no sites.json: run `dev2-site registry`") + return json.load(open(REGISTRY)) + +SITES_D = os.path.join(HERE, "sites.d") + +def with_overrides(s): + """sites.d/.json is merged over the registry entry: one small file per site, + so parallel migrations never edit a shared file. Keys are the entry's keys + (port, health_path, start_command, companion_commands, env_overrides, pg_service, + db, db_host, infra, branch, max_body, notes).""" + p = os.path.join(SITES_D, f"{s['site']}.json") + if os.path.exists(p): + s = dict(s); s.update(json.load(open(p))) + return s + +def site(name): + reg = registry() + if name in reg["sites"]: return with_overrides(reg["sites"][name]) + # allow a service name or a repo tail + for s in reg["sites"].values(): + if s["service"] == name or (s["repo"] or "").split("/")[-1] == name: return with_overrides(s) + die(f"unknown site {name!r}; `dev2-site list`") + +def cmd_list(args): + reg = registry() + rows = [with_overrides(s) for s in reg["sites"].values() if (not args.kind or with_overrides(s)["db"] == args.kind)] + def status(s): + st = load_state(s["site"])["steps"] + if "retire" in st: return "retired" + if "verify-public" in st: return "on-dev2" + if "dns" in st: return "dns-flipped" + if st: return f"{len(st)} steps" + return "-" + rows = [s for s in rows if not getattr(args, "status", None) or status(s) == args.status] + print(f"{'site':<34} {'port':>5} {'db':<9} {'redis':<5} {'vols':<4} {'comp':<4} {'status':<12} repo") + for s in rows: + print(f"{s['site']:<34} {s['port']:>5} {s['db']:<9} {'y' if s['redis'] else '-':<5} {len(s['volumes']) or '-':<4} {len(s['companions']) or '-':<4} {status(s):<12} {s['repo'] or s['image']}") + print(f"{len(rows)} sites") + +def cmd_show(args): + print(json.dumps(site(args.site), indent=1)); print(json.dumps(load_state(args.site), indent=1)) + +# ------------------------------------------------------------ local repo + +def checkout_dir(s): + repo = s["repo"] + if not repo: die(f"{s['site']} is image-only ({s['image']}); nothing to scaffold") + org, name = repo.split("/") + cands = [f"{SRC_ROOT}/{name}", f"{SRC_ROOT}/{name}.com", f"{SRC_ROOT}/{name.removesuffix('.com')}", + os.path.expanduser(f"~/src/{org}/{name}"), os.path.expanduser(f"~/src/{name}")] + for c in cands: + if os.path.isdir(os.path.join(c, ".git")): return c + dest = f"{SRC_ROOT}/{name}" if org == "profullstack" else os.path.expanduser(f"~/src/{org}/{name}") + log(f"Cloning {repo} -> {dest}") + os.makedirs(os.path.dirname(dest), exist_ok=True) + sh(["gh", "repo", "clone", repo, dest, "--", "-q"]) + return dest + +def default_branch(repo): + return sh(["gh", "api", f"repos/{repo}", "--jq", ".default_branch"]).strip() + +def repo_config(d): + """rootDirectory-free reading of railway.json/toml for healthcheckPath, dockerfilePath, startCommand.""" + cfg = {} + for fn in ("railway.json", "railway.toml"): + p = os.path.join(d, fn) + if not os.path.exists(p): continue + txt = open(p).read() + for key in ("healthcheckPath", "dockerfilePath", "startCommand"): + m = re.search(rf'"?{key}"?\s*[:=]\s*"([^"]+)"', txt) + if m: cfg[key] = m.group(1) + return cfg + +def find_dockerfile(d, cfg): + if cfg.get("dockerfilePath") and os.path.exists(os.path.join(d, cfg["dockerfilePath"])): return cfg["dockerfilePath"] + for c in ("Dockerfile", "docker/Dockerfile", "apps/web/Dockerfile"): + if os.path.exists(os.path.join(d, c)): return c + if os.path.exists(os.path.join(d, ".nixpacks", "Dockerfile")): return ".nixpacks/Dockerfile" + return None + +def workflow_name(s): + return "deploy-dev2.yml" if not s.get("shared_repo") else f"deploy-dev2-{s['site'].replace('.', '-')}.yml" + +def health_url(s, cfg): + hp = s.get("health_path") or cfg.get("healthcheckPath") or "/" + return f"https://{s['site']}{hp}", hp + +def cmd_scaffold(args): + s = site(args.site) + d = checkout_dir(s) + branch = s.get("branch") or default_branch(s["repo"]) + log(f"Scaffold {s['site']}: {s['repo']}@{branch} in {d}") + sh(["git", "-C", d, "fetch", "-q", "origin", branch]) + wt = tempfile.mkdtemp(prefix=f"dev2-{s['site']}-", dir=os.path.join(d, ".claude", "worktrees") if os.path.isdir(os.path.join(d, ".claude", "worktrees")) else os.environ.get("CLAUDE_JOB_DIR", tempfile.gettempdir())) + os.rmdir(wt) + wbranch = f"ops/dev2-deploy" if not s.get("shared_repo") else f"ops/dev2-deploy-{s['site'].replace('.', '-')}" + sh(["git", "-C", d, "worktree", "add", "-q", "-B", wbranch, wt, f"origin/{branch}"]) + try: + cfg = repo_config(wt) + df = find_dockerfile(wt, cfg) + if not df: + log("No Dockerfile: generating one with nixpacks (the same builder Railway used)") + nix = shutil.which("nixpacks") or os.path.expanduser("~/.local/bin/nixpacks") + env = dict(os.environ) + if cfg.get("startCommand"): env["NIXPACKS_START_CMD"] = cfg["startCommand"] + sh([nix, "build", wt, "--out", wt], env=env) + gi = os.path.join(wt, ".gitignore") + if os.path.exists(gi) and re.search(r"^\.?nixpacks", open(gi).read(), re.M): + with open(gi, "a") as f: f.write("\n!.nixpacks\n") + df = ".nixpacks/Dockerfile" + if not os.path.exists(os.path.join(wt, df)): die("nixpacks produced no .nixpacks/Dockerfile") + hurl, hp = health_url(s, cfg) + wf = tmpl("deploy-dev2.yml").replace("__SITE__", s["site"]).replace("__BRANCH__", branch).replace("__HEALTH_URL__", hurl) + os.makedirs(os.path.join(wt, ".github", "workflows"), exist_ok=True) + with open(os.path.join(wt, ".github", "workflows", workflow_name(s)), "w") as f: f.write(wf) + for path in (".github/workflows", ".nixpacks", ".gitignore"): + if os.path.exists(os.path.join(wt, path)): sh(["git", "-C", wt, "add", "-A", path]) + if not sh(["git", "-C", wt, "status", "--porcelain"]).strip(): + note("nothing to commit: scaffold already on the branch") + else: + sh(["git", "-C", wt, "commit", "-q", "--no-verify", "-m", f"ops: deploy {s['site']} to dev2 on merge\n\nRailway deployed this on push; dev2 does not by itself. The workflow ssh's\nto the box and runs /home/anthony/www/{s['site']}/deploy-app.sh, which builds\nthe image from this checkout and restarts the compose stack. Generated by\ncli-tools dev2/dev2-site scaffold.\n\nCo-Authored-By: Claude Fable 5.1 "]) + sh(["git", "-C", wt, "push", "-q", "-u", "origin", f"+{wbranch}"]) + pr = sh(["gh", "pr", "list", "--repo", s["repo"], "--head", wbranch, "--json", "url,number", "--jq", ".[0].url"]).strip() + if not pr: + pr = sh(["gh", "pr", "create", "--repo", s["repo"], "--head", wbranch, "--base", branch, "--title", f"ops: deploy {s['site']} to dev2 on merge", + "--body", f"Moves {s['site']} off Railway onto dev2 (`/home/anthony/www/{s['site']}`). Adds the merge-to-deploy workflow" + (" and a nixpacks-generated Dockerfile" if df == ".nixpacks/Dockerfile" else "") + ".\n\nGenerated by cli-tools `dev2/dev2-site scaffold`.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)"]).strip() + note(f"PR {pr}") + mark(s["site"], "scaffold", pr=pr, scaffold_branch=wbranch, branch=branch, dockerfile=df, health_path=hp, checkout=d) + finally: + sh(["git", "-C", d, "worktree", "remove", "--force", wt], check=False) + +# --------------------------------------------------------------- render + +def env_quote(v): + s = "" if v is None else str(v) + if not re.search(r'[\n\r"#]|^\s|\s$', s): return s + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + +def render_env(vars_, s, st, service_name=None): + """Railway's rendered variables, minus Railway, with the data services re-pointed.""" + out = {} + for k, v in sorted(vars_.items()): + if k.startswith("RAILWAY_"): continue + if k in ("PORT",) and not s.get("port_var"): continue + out[k] = v + if s["db"] == "pg" and st.get("database_url"): + for k, v in list(out.items()): + if isinstance(v, str) and re.search(r"postgres(ql)?://", v) and ("railway.internal" in v or ".rlwy.net" in v): + out[k] = st["database_url"] + for k in ("PGHOST", "PGPORT", "PGUSER", "PGPASSWORD", "PGDATABASE"): + if k in out: + out[k] = {"PGHOST": DEV2_HOST, "PGPORT": "5432", "PGUSER": st["db_user"], "PGPASSWORD": st["db_password"], "PGDATABASE": st["db_name"]}[k] + if s["redis"]: + for k, v in list(out.items()): + if isinstance(v, str) and "redis" in v.lower() and "railway.internal" in v: + out[k] = "redis://redis:6379" + if k in ("REDISHOST",): out[k] = "redis" + if k in ("REDISPORT",): out[k] = "6379" + if k in ("REDISPASSWORD", "REDIS_PASSWORD"): out[k] = "" + if k in ("REDISUSER",): out[k] = "default" + for k, v in (s.get("env_overrides") or {}).items(): out[k] = v + lines = [f"# {s['site']}{' ' + service_name if service_name else ''}: rendered by dev2-site provision from Railway on {dt.datetime.now(dt.timezone.utc).isoformat(timespec='seconds')}"] + lines += [f"{k}={env_quote(v)}" for k, v in out.items()] + return "\n".join(lines) + "\n", out + +def render_compose(s, st, app_port_inside): + df = st.get("dockerfile") or "Dockerfile" + img = s["site"].replace(".", "-") + y = [f"# {s['site']} on dev2: rendered by cli-tools dev2/dev2-site provision. Re-run provision to change.", + "# nginx on the host terminates TLS and proxies to 127.0.0.1:${APP_PORT}.", "services:"] + def service(name, envfile, command=None, ports=True, vols=()): + y.append(f" {name}:") + y.append(f" build:\n context: ./app\n dockerfile: {df}") + y.append(f" image: {img}-{name}:latest\n restart: unless-stopped\n env_file: {envfile}") + y.append(f" environment:\n PORT: \"{app_port_inside}\"\n HOST: \"0.0.0.0\"\n NODE_ENV: production") + if command: y.append(f" command: {json.dumps(shlex.split(command))}") + if ports: y.append(f" ports:\n - \"127.0.0.1:${{APP_PORT}}:{app_port_inside}\"") + if vols: + y.append(" volumes:") + for v in vols: y.append(f" - ./volumes/{v['name']}:{v['mountPath']}") + if s["redis"]: + y.append(" depends_on:\n redis:\n condition: service_started") + y.append(" logging:\n driver: json-file\n options: { max-size: \"50m\", max-file: \"3\" }") + service("app", "app.env", command=s.get("start_command"), vols=s["volumes"]) + for c in s["companions"]: + service(c["name"], f"{c['name']}.env", command=(s.get("companion_commands") or {}).get(c["name"]), ports=False, vols=c.get("volumes", [])) + if s["redis"]: + y.append(" redis:\n image: redis:8-alpine\n restart: unless-stopped\n command: [\"redis-server\", \"--appendonly\", \"yes\", \"--maxmemory\", \"1gb\", \"--maxmemory-policy\", \"noeviction\"]\n volumes:\n - redisdata:/data\n logging:\n driver: json-file\n options: { max-size: \"20m\", max-file: \"2\" }") + y.append("volumes:\n redisdata:") + return "\n".join(y) + "\n" + +# -------------------------------------------------------------- provision + +def deploy_key(repo): + """One ed25519 key per repo (GitHub secrets are per repo). Private half stays in state.""" + slug = repo.replace("/", "__") + path = os.path.join(STATE_DIR, "deploy-keys", slug) + if not os.path.exists(path): + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + sh(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", f"github-actions-deploy@{repo}", "-f", path]) + return path + +def cmd_provision(args): + s = site(args.site); st = load_state(s["site"]) + root = f"{WWW}/{s['site']}" + log(f"Provision {s['site']} -> {DEV2_HOST}:{root} (port {s['port']})") + if s["db"] == "pg" and not st.get("database_url"): die("run `dev2-site db` first (the app env needs the new DATABASE_URL)") + if s["db"] in ("supabase", "turso") and not st.get("database_url") and not s.get("env_overrides"): + warn(f"db kind {s['db']}: app.env keeps pointing at the current {s['db']} until its own migration; set env_overrides in sites.overrides.json when ready") + vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + inside = s.get("port_var") or "3000" + app_env, merged = render_env(vars_, s, st) + cfg = repo_config(st.get("checkout") or checkout_dir(s)) + if not st.get("dockerfile"): + st["dockerfile"] = find_dockerfile(st.get("checkout") or checkout_dir(s), cfg) or ".nixpacks/Dockerfile" + branch = st.get("branch") or s.get("branch") or default_branch(s["repo"]) + hp = st.get("health_path") or health_url(s, cfg)[1] + compose = render_compose(s, st, inside) + build_services = " ".join(["app"] + [c["name"] for c in s["companions"]]) + deploy_env = f"SITE={s['site']}\nREPO=https://github.com/{s['repo']}.git\nBRANCH={branch}\nAPP_PORT={s['port']}\nBUILD_SERVICES={build_services}\nHEALTH_PATH={hp}\n" + ssh("root", f"install -d -m 2755 -o {DEPLOY_USER} -g {DEPLOY_USER} {root} {root}/volumes") + for v in s["volumes"] + [v for c in s["companions"] for v in c.get("volumes", [])]: + ssh("root", f"install -d -m 0777 {root}/volumes/{shlex.quote(v['name'])}") + own = f"{DEPLOY_USER}:{DEPLOY_USER}" + ssh_put(app_env, f"{root}/app.env", "0600", own) + for c in s["companions"]: + cv = rw_variables(s["project_id"], s["environment_id"], c["id"]) + cenv, _ = render_env(cv, s, st, c["name"]) + ssh_put(cenv, f"{root}/{c['name']}.env", "0600", own) + ssh_put(deploy_env, f"{root}/deploy.env", "0640", own) + ssh_put(compose, f"{root}/docker-compose.app.yml", "0640", own) + ssh_put(tmpl("deploy-app.sh"), f"{root}/deploy-app.sh", "0755", own) + note(f"{len(merged)} variables in app.env, {len(s['companions'])} companion env files, compose + deploy-app.sh installed") + # Deploy key + GitHub secrets + key = deploy_key(s["repo"]) + pub = open(key + ".pub").read().strip() + ssh("root", f"install -d -m 700 -o {DEPLOY_USER} -g {DEPLOY_USER} /home/{DEPLOY_USER}/.ssh; touch /home/{DEPLOY_USER}/.ssh/authorized_keys; chown {DEPLOY_USER}:{DEPLOY_USER} /home/{DEPLOY_USER}/.ssh/authorized_keys; chmod 600 /home/{DEPLOY_USER}/.ssh/authorized_keys; grep -qF {shlex.quote(pub)} /home/{DEPLOY_USER}/.ssh/authorized_keys || echo {shlex.quote(pub)} >> /home/{DEPLOY_USER}/.ssh/authorized_keys") + known = sh(["ssh-keyscan", "-t", "ed25519,rsa", DEV2_HOST], check=False) + for name, val in (("DEV2_SSH_KEY", open(key).read()), ("DEV2_KNOWN_HOSTS", known), ("DEV2_HOST", DEV2_HOST), ("DEV2_USER", DEPLOY_USER)): + sh(["gh", "secret", "set", name, "--repo", s["repo"]], input=val) + note(f"deploy key {os.path.basename(key)} authorized on dev2; DEV2_* secrets set on {s['repo']}") + mark(s["site"], "provision", root=root, branch=branch, health_path=hp, dockerfile=st["dockerfile"], checkout=st.get("checkout") or checkout_dir(s)) + +# ------------------------------------------------------------------- db + +def cluster_pg_password(): + return ssh("root", f"grep '^POSTGRES_PASSWORD=' {CLUSTER_ENV} | cut -d= -f2-").strip() + +def pg_admin(sql, db="postgres"): + return ssh("root", f"docker exec -i {PG_CONTAINER} psql -U supabase_admin -h localhost -d {shlex.quote(db)} -X -At -v ON_ERROR_STOP=1", input=sql) + +def db_name_for(s): + return s.get("db_name") or re.sub(r"[^a-z0-9]+", "_", s["site"].lower()).strip("_") + +def cmd_db(args): + s = site(args.site); st = load_state(s["site"]) + if s["db"] != "pg": die(f"{s['site']} db kind is {s['db']}; `db` copies Railway Postgres only (see README for supabase/turso)") + pgs = [i for i in s["infra"] if i["role"] == "pg"] + if not pgs: die(f"no Postgres service found beside {s['site']} in project {s['project']}; set infra in sites.overrides.json") + want = s.get("pg_service") or (s.get("db_host") or "").split(".")[0] + pg = next((p for p in pgs if p["name"] == want or slug(p["name"]) == want), None) + if pg is None: + if len(pgs) > 1: die(f"{len(pgs)} Postgres services in the project ({', '.join(p['name'] for p in pgs)}) and none matches {want!r}; set pg_service in sites.overrides.json") + pg = pgs[0] + db = db_name_for(s) + log(f"Database {db} in the shared cluster, copied from Railway {s['project']}/{pg['name']}") + pw = cluster_pg_password() + # The app connects as `postgres` (the only role pg_hba lets in from another compose + # network); isolation is one database per app, same as nichedb and rssamplifier. + exists = pg_admin(f"select count(*) from pg_database where datname='{db}'").strip() + if exists == "0": + pg_admin(f'create database "{db}" owner postgres'); note(f"created database {db}") + else: + note(f"database {db} exists") + if not args.force and pg_admin("select count(*) from pg_tables where schemaname='public'", db).strip() != "0": + die(f"{db} already has tables; re-run with --force to drop and reload") + if args.force: + pg_admin("drop schema public cascade; create schema public; grant all on schema public to postgres, public", db); note("dropped public schema") + st.update({"db_name": db, "db_user": "postgres", "db_password": pw, + "database_url": f"postgres://postgres:{pw}@{DEV2_HOST}:5432/{db}?sslmode=require"}) + save_state(s["site"], st) + # Dump inside the Railway container (pg_dump matches the server there) and stream to dev2. + user = rw_ssh_user(s["project_id"], s["environment_id"], pg["name"]) + pgvars = rw_variables(s["project_id"], s["environment_id"], pg["id"]) + src_db = pgvars.get("PGDATABASE") or pgvars.get("POSTGRES_DB") or "railway" + dump = f"/root/dumps/{db}-{dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.dump" + ssh("root", "install -d -m 700 /root/dumps") + log(f"pg_dump {src_db} via ssh.railway.com -> {DEV2_HOST}:{dump}") + remote = f"PGPASSWORD=\"$PGPASSWORD\" pg_dump -Fc --no-owner --no-privileges -h localhost -U \"${{PGUSER:-postgres}}\" -d {shlex.quote(src_db)}" + cmd = " ".join(map(shlex.quote, rw_ssh_cmd(user, remote))) + f" | ssh -o BatchMode=yes root@{DEV2_HOST} 'cat > {dump}'" + sh(cmd, capture=False, timeout=6 * 3600) + size = ssh("root", f"stat -c%s {dump}").strip() + note(f"dump {int(size)/1e6:.1f} MB") + if int(size) < 1000: die("dump is suspiciously small; check the Railway ssh user and PG variables") + log("pg_restore into the cluster") + toc = ssh("root", f"docker run --rm -i -v /root/dumps:/dumps:ro postgres:17 pg_restore -l /dumps/{os.path.basename(dump)}", check=False) + ext = sorted({m.group(1) for m in re.finditer(r"^\d+; \d+ \d+ EXTENSION - (\S+)", toc, re.M)}) + for e in ext: + if e in ("plpgsql",): continue + pg_admin(f'create extension if not exists "{e}" cascade', db); note(f"extension {e}") + out = ssh("root", f"docker exec -i {PG_CONTAINER} pg_restore -U postgres -h localhost -d {shlex.quote(db)} --no-owner --no-privileges --exit-on-error < {dump} 2>&1 | tail -n 20", check=False) + if out.strip(): note(out.strip()) + st["dump"] = dump; save_state(s["site"], st) + log("Verifying row counts (source vs target)") + count_sql = "select relname from pg_class c join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and c.relkind='r' order by 1" + tables = ssh("root", f"docker exec -i {PG_CONTAINER} psql -U postgres -h localhost -d {shlex.quote(db)} -X -At", input=count_sql).split() + q = " union all ".join(f"select '{t}', count(*) from \"{t}\"" for t in tables) if tables else "select 'none', 0" + tgt = ssh("root", f"docker exec -i {PG_CONTAINER} psql -U postgres -h localhost -d {shlex.quote(db)} -X -At -F' '", input=q) + src = sh(rw_ssh_cmd(user, f"PGPASSWORD=\"$PGPASSWORD\" psql -h localhost -U \"${{PGUSER:-postgres}}\" -d {shlex.quote(src_db)} -X -At -F' '"), input=q) + tgt_c = dict(l.split(" ", 1) for l in tgt.split("\n") if l.strip()); src_c = dict(l.split(" ", 1) for l in src.split("\n") if l.strip()) + bad = [(t, src_c.get(t), tgt_c.get(t)) for t in tables if src_c.get(t) != tgt_c.get(t)] + note(f"{len(tables)} tables, {sum(int(v) for v in tgt_c.values())} rows on dev2") + for t, a, b in bad: warn(f"{t}: source {a} vs dev2 {b} (writes continue on Railway until DNS moves; re-run db --force at cutover if the gap matters)") + mark(s["site"], "db", db_tables=len(tables), db_rows=sum(int(v) for v in tgt_c.values()), db_mismatch=[t for t, _, _ in bad]) + +def cmd_refresh_db(args): + """Re-copy the database seconds before DNS moves (writes kept landing on Railway + while the box was being set up), then restart the app so no connection holds + the old schema.""" + s = site(args.site) + cmd_db(argparse.Namespace(site=s["site"], force=True)) + ssh(DEPLOY_USER, f"cd {WWW}/{s['site']} && docker compose -f docker-compose.app.yml --env-file deploy.env restart", timeout=600) + note("app restarted on the refreshed database") + mark(s["site"], "refresh-db") + +def cmd_volumes(args): + s = site(args.site); st = load_state(s["site"]) + root = f"{WWW}/{s['site']}" + targets = [(s["service"], v) for v in s["volumes"]] + [(c["name"], v) for c in s["companions"] for v in c.get("volumes", [])] + if not targets: note("no volumes"); mark(s["site"], "volumes"); return + for svc, v in targets: + user = rw_ssh_user(s["project_id"], s["environment_id"], svc) + log(f"Volume {v['name']} ({v['sizeMB']} MB) from {svc}:{v['mountPath']} -> {root}/volumes/{v['name']}") + cmd = " ".join(map(shlex.quote, rw_ssh_cmd(user, f"tar -C {shlex.quote(v['mountPath'])} -cf - ."))) + \ + f" | ssh -o BatchMode=yes root@{DEV2_HOST} 'tar -C {root}/volumes/{shlex.quote(v['name'])} -xf - && chmod -R a+rwX {root}/volumes/{shlex.quote(v['name'])} && du -sh {root}/volumes/{shlex.quote(v['name'])}'" + note(sh(cmd, timeout=6 * 3600).strip()) + mark(s["site"], "volumes") + +# ----------------------------------------------------------- box steps + +def cmd_deploy(args): + s = site(args.site); st = load_state(s["site"]) + ref = args.ref or st.get("scaffold_branch") or st.get("branch") or s.get("branch") or "main" + log(f"Deploy {s['site']} @ {ref} on dev2 (as {DEPLOY_USER})") + out = ssh(DEPLOY_USER, f"{WWW}/{s['site']}/deploy-app.sh {shlex.quote(ref)}", check=False, timeout=3600) + print(out[-4000:]) + if "Healthy on" not in out: die("deploy did not report healthy; see output above") + mark(s["site"], "deploy", deployed_ref=ref) + +def porkbun_keys(): + f = os.path.expanduser("~/.config/cli-tools/credentials.json") + c = json.load(open(f)) + def get(*names): + for n in names: + v = c.get(n) + if isinstance(v, dict): v = v.get("value") + if v: return v + return os.environ.get(names[-1]) + k, sk = get("porkbun", "PORKBUN_API_KEY"), get("porkbun_secret", "PORKBUN_SECRET_API_KEY") + if not (k and sk): die("no Porkbun keys in ~/.config/cli-tools/credentials.json (cli-tools config pull)") + return k, sk + +def cmd_cert(args): + s = site(args.site) + domains = list(dict.fromkeys(s["domains"] + ([f"www.{s['site']}"] if s["site"].count(".") == 1 and f"www.{s['site']}" not in s["domains"] else []))) + k, sk = porkbun_keys() + cert_dir = f"/etc/nginx/ssl/{s['site']}" + log(f"Certificate for {', '.join(domains)} (acme.sh, Porkbun DNS-01, issued before DNS moves)") + ssh("root", "test -x /root/.acme.sh/acme.sh || curl -fsSL https://get.acme.sh | env HOME=/root sh -s email=" + ACME_EMAIL + " >/dev/null") + ds = " ".join(f"-d {shlex.quote(d)}" for d in domains) + out = ssh("root", f"install -d -m 0750 {cert_dir} && env HOME=/root PORKBUN_API_KEY={shlex.quote(k)} PORKBUN_SECRET_API_KEY={shlex.quote(sk)} /root/.acme.sh/acme.sh --issue --server letsencrypt --dns dns_porkbun {ds} --force >/tmp/acme-{s['site']}.log 2>&1 || {{ tail -n 25 /tmp/acme-{s['site']}.log; exit 1; }}", check=False, timeout=1800) + if out.strip() and "Cert success" not in out: die(out) + ssh("root", f"env HOME=/root /root/.acme.sh/acme.sh --install-cert -d {shlex.quote(domains[0])} --fullchain-file {cert_dir}/fullchain.pem --key-file {cert_dir}/privkey.pem --reloadcmd 'systemctl reload nginx' >/dev/null") + note(ssh("root", f"openssl x509 -in {cert_dir}/fullchain.pem -noout -subject -enddate | tr '\\n' ' '").strip()) + mark(s["site"], "cert", cert_domains=domains) + +def cmd_vhost(args): + s = site(args.site) + domains = list(dict.fromkeys(s["domains"] + ([f"www.{s['site']}"] if s["site"].count(".") == 1 else []))) + ssh("root", f"test -s /etc/nginx/ssl/{s['site']}/fullchain.pem") if not args.no_cert_check else None + conf = tmpl("nginx-vhost.conf").replace("__SITE__", s["site"]).replace("__APP_PORT__", str(s["port"])).replace("__SERVER_NAMES__", " ".join(domains)).replace("__MAX_BODY__", s.get("max_body", "64m")) + log(f"nginx vhost {s['site']} -> 127.0.0.1:{s['port']} ({', '.join(domains)})") + ssh_put(conf, f"/tmp/dev2-site-{s['site']}.conf", "0644") + ssh("root", f"f=/etc/nginx/sites-available/{s['site']}; if [ -e $f ]; then n=1; while [ -e $f.bak-$(printf %03d $n) ]; do n=$((n+1)); done; cp -a $f $f.bak-$(printf %03d $n); fi; install -m 0644 /tmp/dev2-site-{s['site']}.conf $f && ln -sf $f /etc/nginx/sites-enabled/{s['site']} && nginx -t 2>&1 | tail -n1 && systemctl reload nginx") + mark(s["site"], "vhost") + +def cmd_verify(args): + s = site(args.site); st = load_state(s["site"]) + hp = st.get("health_path") or "/" + ok = True + for d in s["domains"]: + resolve = [] if args.public else ["--resolve", f"{d}:443:{DEV2_IP}"] + code = sh(["curl", "-sk", "-o", "/dev/null", "-m", "30", "-w", "%{http_code}", *resolve, f"https://{d}{hp}"], check=False).strip() + good = code[:1] in ("2", "3") or code in ("401", "403") + ok &= good + note(f"{d}{hp} -> {code} {'ok' if good else 'FAIL'}{' (pinned to dev2)' if not args.public else ''}") + if not ok: die("verify failed") + mark(s["site"], "verify-public" if args.public else "verify") + +def cmd_dns(args): + s = site(args.site) + domains = list(dict.fromkeys(s["domains"] + ([f"www.{s['site']}"] if s["site"].count(".") == 1 else []))) + zones = {} + for d in domains: + zone = ".".join(d.split(".")[-2:]); zones.setdefault(zone, []).append(d) + os.makedirs(os.path.join(STATE_DIR, "dns"), exist_ok=True) + for zone, ds in zones.items(): + log(f"Porkbun {zone}: {', '.join(ds)} -> A {DEV2_IP}") + backup = os.path.join(STATE_DIR, "dns", f"{zone}.{dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.json") + with open(backup, "w") as f: f.write(sh(["porkbun", "ls", zone, "--json"])) + recs = json.load(open(backup)) + recs = recs.get("records", recs) if isinstance(recs, dict) else recs + for d in ds: + host = "@" if d == zone else d[: -len(zone) - 1] + if host.startswith("*"): note(f"{d}: wildcard; set A * manually if needed"); host = "*" + for r in recs: + if r.get("name") == d and r.get("type") in ("ALIAS", "CNAME", "A", "AAAA"): + sh(["porkbun", "rm", zone, host, "--type", r["type"], "--yes"], check=False) + sh(["porkbun", "set", zone, host, "A", DEV2_IP, "--ttl", "600"]) + for r in recs: + if r.get("name") == f"_railway-verify.{d}" and r.get("type") == "TXT": + sh(["porkbun", "rm", zone, f"_railway-verify.{host}" if host != "@" else "_railway-verify", "--type", "TXT", "--yes"], check=False) + note(f"{d} -> A {DEV2_IP}") + mark(s["site"], "dns", dns_flipped=dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")) + +def cmd_railway_stop(args): + s = site(args.site) + for svc in [{"name": s["service"], "id": s["service_id"]}] + s["companions"]: + log(f"Railway: stop {s['project']}/{svc['name']} and disconnect its repo watch") + for d in rw_deployments(s["project_id"], s["environment_id"], svc["id"]): + if d["status"] in ("SUCCESS", "BUILDING", "DEPLOYING", "INITIALIZING", "WAITING", "SLEEPING", "CRASHED", "NEEDS_APPROVAL"): + try: + gql("mutation($id:String!){deploymentRemove(id:$id)}", {"id": d["id"]}); note(f"removed deployment {d['id'][:8]} ({d['status']})") + except RuntimeError as e: + if "not stoppable" in str(e).lower() or "BUILDING" in d["status"]: + gql("mutation($id:String!){deploymentCancel(id:$id)}", {"id": d["id"]}); note(f"cancelled {d['id'][:8]}") + else: warn(str(e)[:200]) + try: + gql("mutation($id:String!){serviceDisconnect(id:$id){id}}", {"id": svc["id"]}); note("repo disconnected (a push no longer redeploys on Railway)") + except RuntimeError as e: warn(f"serviceDisconnect: {str(e)[:200]}") + mark(s["site"], "railway-stop") + +def cmd_merge(args): + s = site(args.site); st = load_state(s["site"]) + pr = st.get("pr") + if not pr: die("no scaffold PR in state") + log(f"Merging {pr}") + for _ in range(60): + state = sh(["gh", "pr", "view", pr, "--json", "state,mergeable,statusCheckRollup", "--jq", "[.state, .mergeable, ([.statusCheckRollup[]? | .conclusion // .status] | join(\",\"))] | join(\" \")"]).strip() + st_, mergeable, checks = (state.split(" ") + ["", ""])[:3] + if st_ == "MERGED": note("already merged"); break + pending = any(c in checks for c in ("IN_PROGRESS", "QUEUED", "PENDING", "EXPECTED")) + failed = any(c in checks for c in ("FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED")) + if failed: die(f"checks failed on {pr}: {checks}") + if not pending: + sh(["gh", "pr", "merge", pr, "--squash", "--delete-branch"], check=False) + if sh(["gh", "pr", "view", pr, "--json", "state", "--jq", ".state"]).strip() == "MERGED": note("merged"); break + sh(["gh", "pr", "merge", pr, "--squash", "--delete-branch", "--admin"], check=False) + time.sleep(20) + mark(s["site"], "merge") + +def cmd_migrate(args): + s = site(args.site); st = load_state(s["site"]) + steps = [("scaffold", cmd_scaffold), ("db", cmd_db) if s["db"] == "pg" else None, ("provision", cmd_provision), ("volumes", cmd_volumes), + ("deploy", cmd_deploy), ("cert", cmd_cert), ("vhost", cmd_vhost), ("verify", cmd_verify), + ("refresh-db", cmd_refresh_db) if s["db"] == "pg" else None, ("dns", cmd_dns), + ("railway-stop", cmd_railway_stop), ("merge", cmd_merge), ("verify-public", cmd_verify)] + for step in filter(None, steps): + name, fn = step + if name in st["steps"] and not args.redo: note(f"{name}: done {st['steps'][name]}"); continue + if name == "dns" and not args.yes and not args.__dict__.get("no_confirm"): + if not sys.stdin.isatty(): die("dns flip needs --yes when non-interactive") + if input(f"Flip DNS for {', '.join(s['domains'])} to dev2 now? [y/N] ").strip().lower() != "y": die("stopped before DNS") + ns = argparse.Namespace(site=s["site"], ref=None, force=(name == "refresh-db"), public=(name == "verify-public"), no_cert_check=False, redo=False, yes=True) + fn(ns) + st = load_state(s["site"]) + mark(s["site"], "migrated") + log(f"{s['site']} is on dev2. Railway services left for `dev2-site retire {s['site']} --yes`: {', '.join([s['service']] + [c['name'] for c in s['companions']] + [i['name'] for i in s['infra']])}") + +def cmd_retire(args): + s = site(args.site); st = load_state(s["site"]) + if "verify-public" not in st["steps"] and not args.force: die("site not verified public on dev2; refuse to delete Railway services (or --force)") + if not args.yes: die("--yes required") + targets = [{"name": s["service"], "id": s["service_id"]}] + s["companions"] + s["infra"] + for t in targets: + log(f"Railway: delete {s['project']}/{t['name']}") + try: gql("mutation($id:String!){serviceDelete(id:$id)}", {"id": t["id"]}); note("deleted") + except RuntimeError as e: warn(str(e)[:200]) + mark(s["site"], "retire") + +# ----------------------------------------------------------------- main + +def main(): + ap = argparse.ArgumentParser(prog="dev2-site", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("registry").set_defaults(fn=cmd_registry) + p = sub.add_parser("list"); p.add_argument("--kind"); p.add_argument("--status"); p.set_defaults(fn=cmd_list) + for name, fn in (("show", cmd_show), ("scaffold", cmd_scaffold), ("provision", cmd_provision), ("volumes", cmd_volumes), ("cert", cmd_cert), + ("dns", cmd_dns), ("railway-stop", cmd_railway_stop), ("merge", cmd_merge)): + p = sub.add_parser(name); p.add_argument("site"); p.set_defaults(fn=fn) + p = sub.add_parser("db"); p.add_argument("site"); p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_db) + p = sub.add_parser("deploy"); p.add_argument("site"); p.add_argument("ref", nargs="?"); p.set_defaults(fn=cmd_deploy) + p = sub.add_parser("vhost"); p.add_argument("site"); p.add_argument("--no-cert-check", action="store_true"); p.set_defaults(fn=cmd_vhost) + p = sub.add_parser("verify"); p.add_argument("site"); p.add_argument("--public", action="store_true"); p.set_defaults(fn=cmd_verify) + p = sub.add_parser("refresh-db"); p.add_argument("site"); p.set_defaults(fn=cmd_refresh_db) + p = sub.add_parser("migrate"); p.add_argument("site"); p.add_argument("--yes", action="store_true"); p.add_argument("--redo", action="store_true"); p.set_defaults(fn=cmd_migrate) + p = sub.add_parser("retire"); p.add_argument("site"); p.add_argument("--yes", action="store_true"); p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_retire) + args = ap.parse_args() + try: args.fn(args) + except RuntimeError as e: die(str(e)) + +if __name__ == "__main__": + main() diff --git a/dev2/sites.json b/dev2/sites.json new file mode 100644 index 0000000..1b24ac9 --- /dev/null +++ b/dev2/sites.json @@ -0,0 +1,2753 @@ +{ + "generated_at": "2026-09-25T09:22:59+00:00", + "dev2": { + "host": "dev2.profullstack.com", + "ip": "23.95.228.174" + }, + "sites": { + "advis0r.com": { + "site": "advis0r.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "advis0r.com", + "service_id": "ffa577ce-ae37-4fe8-8981-46e1a751119d", + "repo": "profullstack/advis0r.com", + "image": null, + "domains": [ + "advis0r.com", + "www.advis0r.com" + ], + "railway_domains": [ + "advis0r.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3277 + }, + "agenticjobs.work": { + "site": "agenticjobs.work", + "project": "agenticjobs", + "project_id": "a14390f1-b9be-42ed-9b1b-a190c951f42d", + "environment_id": "f6ccb1aa-844d-4ed6-8ad5-6051bfb21000", + "service": "web", + "service_id": "a63f1af8-014e-4e98-9a2f-39da42810d84", + "repo": "profullstack/agenticjobs", + "image": null, + "domains": [ + "www.agenticjobs.work", + "agenticjobs.work" + ], + "railway_domains": [], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres", + "id": "18bdb97c-3b41-4edd-a0c4-a319f9559f5f", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "6df559c8-3b43-46c8-8678-315b168bc80e", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 1146 + } + ] + } + ], + "shared_repo": false, + "port": 3214 + }, + "aiornot.vote": { + "site": "aiornot.vote", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "aiornot.vote", + "service_id": "db027ffb-534d-4247-b730-86ece253b1a7", + "repo": "profullstack/aiornot.vote", + "image": null, + "domains": [ + "aiornot.vote", + "www.aiornot.vote" + ], + "railway_domains": [ + "aiornot.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [ + { + "name": "aiornot.vote-volume", + "id": "7dc3bc20-a3cd-4f72-8697-d745f008127c", + "mountPath": "/data", + "sizeMB": 1510 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3269 + }, + "app.logicsrc.com": { + "site": "app.logicsrc.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "app.logicsrc.com", + "service_id": "262dd5a9-a0e2-4593-8971-f89ba9b8d463", + "repo": "profullstack/logicsrc", + "image": null, + "domains": [ + "app.logicsrc.com" + ], + "railway_domains": [ + "logicsrc-credentials-production.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": true, + "port": 3229 + }, + "app.moshcode.sh": { + "site": "app.moshcode.sh", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "moshcode", + "service_id": "a42fe297-0c08-44d6-b522-bb5144fdf3ef", + "repo": "moshcoder/moshcode", + "image": null, + "domains": [ + "app.moshcode.sh", + "pit.moshcode.sh" + ], + "railway_domains": [ + "app-moshcode.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3253 + }, + "avenasea.com": { + "site": "avenasea.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "avenasea.com", + "service_id": "10cad33c-4ea1-4985-bc4e-616077870184", + "repo": "avenasea/avenasea-web", + "image": null, + "domains": [ + "avenasea.com", + "www.avenasea.com" + ], + "railway_domains": [ + "avenaseacom-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3224 + }, + "b1dz.com": { + "site": "b1dz.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "b1dz.com", + "service_id": "5287943c-f7c4-4b03-94c3-ba87cd3e9ec3", + "repo": "profullstack/b1dz.com", + "image": null, + "domains": [ + "b1dz.com", + "www.b1dz.com" + ], + "railway_domains": [ + "web-production-2ff45.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3240 + }, + "bbs.hqtui.com": { + "site": "bbs.hqtui.com", + "project": "bbs.hqtui.com", + "project_id": "8b87e9fd-9fb5-4bdd-adbb-08e898013716", + "environment_id": "dac3c02b-909b-4e94-9738-36ff209111ab", + "service": "bbs", + "service_id": "ee0b6928-56e6-4e90-803d-6229fc35ba32", + "repo": "profullstack/tsbb", + "image": null, + "domains": [ + "bbs.hqtui.com" + ], + "railway_domains": [], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [ + { + "name": "bbs-volume", + "id": "51e0252d-3066-4fea-91fa-96eabab44cf8", + "mountPath": "/app/data", + "sizeMB": 1400 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3218 + }, + "bg0ne.com": { + "site": "bg0ne.com", + "project": "bg0ne.com", + "project_id": "c1682f92-c096-459b-b52d-420e31c1768f", + "environment_id": "0bf0cc6c-23ec-4f23-bed5-0072b6750ae9", + "service": "bg0ne", + "service_id": "4c9ccb38-21dd-4ae8-b384-64eb51958fa9", + "repo": "profullstack/bg0ne.com", + "image": null, + "domains": [ + "bg0ne.com", + "www.bg0ne.com" + ], + "railway_domains": [], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres", + "id": "fae6babd-240d-448e-98ff-5e73d1aef6b4", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "c0d89f21-22b5-479d-b653-de22b838595d", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 884 + } + ] + } + ], + "shared_repo": false, + "port": 3201 + }, + "bl0ggers.com": { + "site": "bl0ggers.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "bl0ggers.com", + "service_id": "ab99794c-c03e-4917-bc3f-87eff2551a5e", + "repo": "profullstack/bl0ggers", + "image": null, + "domains": [ + "bl0ggers.com", + "*.bl0ggers.com" + ], + "railway_domains": [ + "bl0ggers-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3257 + }, + "brisk.news": { + "site": "brisk.news", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "brisk.news", + "service_id": "5043992d-20e9-4ff6-8993-d65986a503cd", + "repo": "profullstack/brisk.news", + "image": null, + "domains": [ + "www.brisk.news", + "brisk.news" + ], + "railway_domains": [ + "brisknews-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": true, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": "redis.railway.internal", + "infra": [ + { + "name": "Redis", + "id": "b4cbc724-fa68-4513-acf6-c06f3cc9346f", + "role": "redis", + "image": "redis:8.2.9", + "volumes": [ + { + "name": "redis-volume", + "id": "39dde17d-9ffd-410c-91da-85d54cf6efd9", + "mountPath": "/data", + "sizeMB": 1591 + } + ] + } + ], + "shared_repo": false, + "port": 3238 + }, + "bufferoverride.com": { + "site": "bufferoverride.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "bufferoverride", + "service_id": "91b06d88-9d05-41a2-8c1f-a77402bc1f8f", + "repo": "profullstack/bufferoverride", + "image": null, + "domains": [ + "bufferoverride.com", + "www.bufferoverride.com" + ], + "railway_domains": [ + "bufferoverride.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [ + { + "name": "bufferoverride-volume", + "id": "3700e8a9-b9e3-4ec5-9c1d-3cfbb1751adc", + "mountPath": "/data", + "sizeMB": 791 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3251 + }, + "c0mpute.com": { + "site": "c0mpute.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "c0mpute.com", + "service_id": "a4c2fc8a-a78b-4bd9-967c-8137fca11904", + "repo": "profullstack/c0mpute", + "image": null, + "domains": [ + "c0mpute.com", + "www.c0mpute.com" + ], + "railway_domains": [ + "c0mpute.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3254 + }, + "c0ncerts.com": { + "site": "c0ncerts.com", + "project": "Concert Platform", + "project_id": "1805cbb5-ea36-49d0-955e-42a4c56b15c3", + "environment_id": "7035b33a-895d-439d-b00e-5b9efeb6c8fc", + "service": "c0ncerts.com", + "service_id": "93bbb1e2-ea95-4e1b-8f20-32e5aeee77e1", + "repo": "profullstack/c0ncerts.com", + "image": null, + "domains": [ + "c0ncerts.com", + "www.c0ncerts.com" + ], + "railway_domains": [ + "c0ncerts.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3278 + }, + "c0upons.com": { + "site": "c0upons.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "c0upons.com", + "service_id": "2aaba4f5-f5c8-41c3-8e01-efcacd14b604", + "repo": "profullstack/c0upons", + "image": null, + "domains": [ + "www.c0upons.com", + "c0upons.com" + ], + "railway_domains": [ + "c0upons.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3231 + }, + "chovy.com": { + "site": "chovy.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "chovy-ai", + "service_id": "4da42bd8-c973-4705-aae3-2a606430f8ac", + "repo": "profullstack/chovy-ai", + "image": null, + "domains": [ + "www.chovy.com", + "chovy.com" + ], + "railway_domains": [ + "chovy.up.railway.app" + ], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [ + { + "name": "chovy-ai-volume", + "id": "4b33b294-1dbe-4210-8503-eb57b1aff121", + "mountPath": "/data", + "sizeMB": 801 + } + ], + "port_var": null, + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres", + "id": "815793bb-9f7b-4703-9bc2-1f170dccb836", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "9fddd62a-78b2-4327-af9d-fe0ffcdc700d", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 1164 + } + ] + } + ], + "shared_repo": false, + "port": 3237 + }, + "cigarunderground.org": { + "site": "cigarunderground.org", + "project": "cigarunderground.org", + "project_id": "20ba700a-93f7-4c4a-a248-9a429b24201b", + "environment_id": "6beedd07-a61a-47e8-bee4-4d5ddff618c7", + "service": "cigarunderground.org", + "service_id": "97be88d1-56ba-4df0-9365-b066d1febdf6", + "repo": "profullstack/cigarunderground.org", + "image": null, + "domains": [ + "cigarunderground.org", + "www.cigarunderground.org" + ], + "railway_domains": [], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [], + "port_var": "3000", + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres", + "id": "5e383fc6-82db-4674-9801-f87c7ab9fa59", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "eaedafc5-a246-445f-8c89-a1f8b9795c08", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 866 + } + ] + } + ], + "shared_repo": false, + "port": 3203 + }, + "coinpayportal.com": { + "site": "coinpayportal.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "coinpayportal.com", + "service_id": "e8b5a637-b1de-4d54-a75c-e1c3e93e7bb4", + "repo": "profullstack/coinpayportal", + "image": null, + "domains": [ + "www.coinpayportal.com", + "mta-sts.coinpayportal.com", + "coinpayportal.com" + ], + "railway_domains": [ + "coinpayportal-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [ + { + "name": "coinpayportal-volume", + "id": "d30af3d8-6360-4826-97c3-3557c5701aaa", + "mountPath": "/mnt/files", + "sizeMB": 1120 + } + ], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3273 + }, + "communitygardensasappleseeds.info": { + "site": "communitygardensasappleseeds.info", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "communitygardensasappleseeds.info", + "service_id": "54672478-27cc-4d57-a176-79e381b9da09", + "repo": "profullstack/communitygardens-web", + "image": null, + "domains": [ + "www.communitygardensasappleseeds.info", + "communitygardensasappleseeds.info" + ], + "railway_domains": [ + "communitygardensasappleseedsinfo-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [ + { + "name": "communitygardensasappleseeds.info-volume", + "id": "3625af85-e519-42a8-a676-eaf604066568", + "mountPath": "/data/db", + "sizeMB": 181 + } + ], + "port_var": "9420", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3241 + }, + "cryptoshot.space": { + "site": "cryptoshot.space", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "cryptoshot.space", + "service_id": "c74cdf0b-bc45-4a8d-9611-e45fd363781c", + "repo": "profullstack/cryptoshot.space", + "image": null, + "domains": [ + "cryptoshot.space", + "www.cryptoshot.space" + ], + "railway_domains": [ + "places-production-758c.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3265 + }, + "d0rz.com": { + "site": "d0rz.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "d0rz.com", + "service_id": "25b2c41d-2973-4496-a39c-9a0537c6da76", + "repo": "profullstack/d0rz", + "image": null, + "domains": [ + "d0rz.com", + "www.d0rz.com" + ], + "railway_domains": [ + "d0rzcom-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3228 + }, + "d1sks.com": { + "site": "d1sks.com", + "project": "d1sks.com", + "project_id": "9f17fa49-126b-4248-acaf-1458b0f1ba7b", + "environment_id": "a04222ee-b94e-4348-b677-356d8f046939", + "service": "d1sks.com", + "service_id": "b3dd4d0e-f229-4271-ba40-a0f49e2a33c2", + "repo": "profullstack/d1sks.com", + "image": null, + "domains": [ + "d1sks.com", + "www.d1sks.com" + ], + "railway_domains": [], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [], + "port_var": "3000", + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres", + "id": "c82c3180-0cc4-4f96-a69a-48fc553d8c81", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "8130599b-09ed-497a-a5f5-71b314888c0c", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 861 + } + ] + } + ], + "shared_repo": false, + "port": 3207 + }, + "d3vices.com": { + "site": "d3vices.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "d3vices.com", + "service_id": "619d6906-9ea7-46c1-add6-7fffec3d6202", + "repo": "profullstack/d3vices.com", + "image": null, + "domains": [ + "d3vices.com", + "www.d3vices.com" + ], + "railway_domains": [ + "d3vicescom-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3242 + }, + "demo.movie": { + "site": "demo.movie", + "project": "demo.movie", + "project_id": "ecbe6ee4-1b5d-4c36-b7c1-18dd5d763505", + "environment_id": "1579b73a-a06b-4dd6-a40e-92c1d16cf243", + "service": "demo.movie", + "service_id": "e591be59-2fa5-43bd-8ef6-0c8b7b378838", + "repo": "profullstack/demo.movie", + "image": null, + "domains": [ + "demo.movie" + ], + "railway_domains": [], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [ + { + "name": "demo.movie-volume", + "id": "97e1dc15-f2f1-4f1a-abb5-cf3b0bffa1fd", + "mountPath": "/data", + "sizeMB": 1127 + } + ], + "port_var": "3000", + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres", + "id": "867f948b-0cc2-4a15-aecb-f3d54253fdd1", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "fc0bbef1-b92b-498c-aabc-f2157b8fbc70", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 872 + } + ] + } + ], + "shared_repo": false, + "port": 3200 + }, + "diskpush.com": { + "site": "diskpush.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "diskpush-web", + "service_id": "f956b101-4919-480c-bf30-51fe2e5b01e9", + "repo": "profullstack/diskpush", + "image": null, + "domains": [ + "diskpush.com", + "www.diskpush.com" + ], + "railway_domains": [ + "diskpush-web-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3275 + }, + "feedback.profullstack.com": { + "site": "feedback.profullstack.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "feedback.profullstack.com", + "service_id": "949f4453-745c-47e1-9294-65600f34aafa", + "repo": "profullstack/feedback.profullstack.com", + "image": null, + "domains": [ + "feedback.profullstack.com" + ], + "railway_domains": [ + "feedbackprofullstackcom-production.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3252 + }, + "fleetsysops.com": { + "site": "fleetsysops.com", + "project": "fleetsysops.com", + "project_id": "f76db785-d53c-40eb-b4b5-f9ac4176c2b9", + "environment_id": "c02ff655-fa72-4e10-a182-0f309f72d073", + "service": "fleetsysops.com", + "service_id": "bcd92cae-b3a9-4624-ae96-6fdf45f7a75a", + "repo": "profullstack/fleetsysops.com", + "image": null, + "domains": [ + "www.fleetsysops.com", + "fleetsysops.com" + ], + "railway_domains": [], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": "3000", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3206 + }, + "g1tz.hqtui.com": { + "site": "g1tz.hqtui.com", + "project": "g1tz", + "project_id": "0cb66bef-6093-4ed3-9934-b6a1ae86b2e9", + "environment_id": "892fcfb1-8a6a-42e1-8d24-d67d42a796aa", + "service": "g1tz", + "service_id": "6f2addf8-c4e4-41da-a35b-2efc629f4aa2", + "repo": "profullstack/g1tz", + "image": null, + "domains": [ + "g1tz.hqtui.com" + ], + "railway_domains": [], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [ + { + "name": "g1tz-volume", + "id": "8ba1417a-d840-437b-853b-9b591643179e", + "mountPath": "/data", + "sizeMB": 793 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3205 + }, + "genrewatch.com": { + "site": "genrewatch.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "genrewatch.com", + "service_id": "d500ee4f-8e1a-4e47-8121-bf1b3f21a10a", + "repo": "profullstack/genrewatch.com", + "image": null, + "domains": [ + "genrewatch.com", + "www.genrewatch.com" + ], + "railway_domains": [ + "genrewatchcom-production.up.railway.app" + ], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": true, + "volumes": [], + "port_var": "8080", + "companions": [ + { + "name": "genrewatch-worker", + "id": "920af9df-d6da-4b26-a9f6-4cf3aeac1935", + "volumes": [], + "db": "pg", + "redis": true + } + ], + "db_host": "postgres-ivty.railway.internal", + "redis_host": "redis-wssw.railway.internal", + "infra": [ + { + "name": "Redis-wSsW", + "id": "1517a765-b3e7-41b7-b57d-3eb29af2506d", + "role": "redis", + "image": "redis:8.2", + "volumes": [ + { + "name": "redis-volume-uVea", + "id": "6e4965b8-813e-485c-ae58-64cd17309369", + "mountPath": "/data", + "sizeMB": 1062 + } + ] + }, + { + "name": "Postgres-iVtY", + "id": "2683bc3d-9755-421e-950f-8de56c097528", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume-RlJf", + "id": "536c1ed5-73cd-44f6-be61-c8cdf3165d82", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 3410 + } + ] + } + ], + "shared_repo": false, + "port": 3267 + }, + "getprivacycode.com": { + "site": "getprivacycode.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "getprivacycode", + "service_id": "296a85da-6cf6-4928-9c07-bceb25c8440a", + "repo": "profullstack/getprivacycode", + "image": null, + "domains": [ + "www.getprivacycode.com", + "getprivacycode.com" + ], + "railway_domains": [ + "getprivacycode.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3230 + }, + "goingbroke.now": { + "site": "goingbroke.now", + "project": "goingbroke.now", + "project_id": "270a98e2-8b37-4448-8740-eb3f62c101cb", + "environment_id": "d24710e5-d75d-4ab3-a8d2-188c6e3b63b2", + "service": "goingbroke.now", + "service_id": "6ad8dd57-b92d-4c22-9bc5-fce5199a1a1f", + "repo": "profullstack/goingbroke.now", + "image": null, + "domains": [ + "www.goingbroke.now", + "goingbroke.now" + ], + "railway_domains": [ + "goingbroke-web-production.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3221 + }, + "hqtui.com": { + "site": "hqtui.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "hqtui", + "service_id": "041faa6b-9aae-4937-a847-e14f89cf8ef2", + "repo": "profullstack/hqtui", + "image": null, + "domains": [ + "hqtui.com" + ], + "railway_domains": [ + "hqtui-production.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3222 + }, + "icemap.app": { + "site": "icemap.app", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "icemap.app", + "service_id": "be75d792-eef1-4972-ac9e-1addba108ec7", + "repo": "profullstack/icemap.app", + "image": null, + "domains": [ + "www.icemap.app", + "icemap.app" + ], + "railway_domains": [ + "icemapapp-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3262 + }, + "infernetprotocol.com": { + "site": "infernetprotocol.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "infernetprotocol.com", + "service_id": "1f89ec5b-d30d-4808-be19-9ac10c188903", + "repo": "InfernetProtocol/infernet-protocol", + "image": null, + "domains": [ + "infernetprotocol.com", + "www.infernetprotocol.com" + ], + "railway_domains": [ + "infernet.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3227 + }, + "installer.pairux.com": { + "site": "installer.pairux.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "installer.pairux.com", + "service_id": "b115e7d5-bb2b-4d91-8c8c-f742080d7ca0", + "repo": "profullstack/pairux.com", + "image": null, + "domains": [ + "installer.pairux.com" + ], + "railway_domains": [], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": true, + "port": 3258 + }, + "launch.anapp.now": { + "site": "launch.anapp.now", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "launch.anapp.now", + "service_id": "7797ad26-923b-40d0-b4f4-3e521297742c", + "repo": "profullstack/launch.anapp.now", + "image": null, + "domains": [ + "launch.anapp.now" + ], + "railway_domains": [], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3245 + }, + "logicsrc.com": { + "site": "logicsrc.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "logicsrc", + "service_id": "e77a59ca-2ec7-4641-ae6b-b166db856291", + "repo": "profullstack/logicsrc", + "image": null, + "domains": [ + "logicsrc.com", + "www.logicsrc.com" + ], + "railway_domains": [ + "logicsrc.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": true, + "port": 3272 + }, + "makedemo.app": { + "site": "makedemo.app", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "makedemo.app", + "service_id": "4c954ea9-20ee-4ceb-8e7c-e69684f057ec", + "repo": "profullstack/makedemo", + "image": null, + "domains": [ + "www.makedemo.app", + "makedemo.app" + ], + "railway_domains": [ + "makedemo.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3236 + }, + "marksyncr.com": { + "site": "marksyncr.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "marksyncr.com", + "service_id": "b7bb1d44-e198-42b3-9ad6-7a2f87040a4d", + "repo": "profullstack/marksyncr.com", + "image": null, + "domains": [ + "www.marksyncr.com", + "marksyncr.com" + ], + "railway_domains": [ + "web-production-4f64.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3260 + }, + "media2markdown.com": { + "site": "media2markdown.com", + "project": "media2markdown", + "project_id": "f1f3621f-be27-43d8-9469-0bf337163cf8", + "environment_id": "8a9c1902-5193-45c2-8f85-e77a477d5bfb", + "service": "media2markdown", + "service_id": "c93bf09f-8ee3-47e4-a583-65a76e5cd4e1", + "repo": null, + "image": null, + "domains": [ + "media2markdown.com", + "www.media2markdown.com" + ], + "railway_domains": [ + "media2markdown-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3217 + }, + "mediaanalyzer.pro": { + "site": "mediaanalyzer.pro", + "project": "mediaanalyzer.pro", + "project_id": "995b8b85-2c92-4e68-a957-342b74522179", + "environment_id": "bcfb5ae7-88dd-4a2c-923d-2fe599c8b61b", + "service": "mediaanalyzer.pro", + "service_id": "9bfa5a89-f57f-4d75-b0ec-01d61090ae48", + "repo": "profullstack/mediaanalyzer.pro", + "image": null, + "domains": [ + "www.mediaanalyzer.pro", + "mediaanalyzer.pro" + ], + "railway_domains": [], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [], + "port_var": "3000", + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres", + "id": "001274d0-fccb-4f4e-b02d-ac366ac28f05", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "58b11232-a943-4943-86ff-bbfc920e1527", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 1017 + } + ] + } + ], + "shared_repo": false, + "port": 3202 + }, + "meshhook.com": { + "site": "meshhook.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "meshhook", + "service_id": "e44067bd-39b6-4a72-891e-b53c4fb6917c", + "repo": "profullstack/meshhook", + "image": null, + "domains": [ + "www.meshhook.com", + "meshhook.com" + ], + "railway_domains": [ + "meshhook.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3271 + }, + "moshcode.sh": { + "site": "moshcode.sh", + "project": "moshcode-sh", + "project_id": "340108c8-1509-43ae-af15-0cb1cb92201c", + "environment_id": "ef18b7c3-e566-4b15-a0cc-17c8fa04b26f", + "service": "moshcode-sh", + "service_id": "2897bb88-3553-4d67-9b26-54f9c92ef7ca", + "repo": "moshcoder/moshcode.sh", + "image": null, + "domains": [ + "moshcode.sh", + "www.moshcode.sh" + ], + "railway_domains": [ + "moshcode-sh-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": "3000", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3204 + }, + "moshcoding.com": { + "site": "moshcoding.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "moshcoding.com", + "service_id": "0aca36d5-e800-4099-ba55-10f89c65ce3a", + "repo": "moshcoder/moshcoding", + "image": null, + "domains": [ + "moshcoding.com", + "www.moshcoding.com", + "moshscript.com", + "www.moshscript.com", + "moshcoders.com", + "www.moshcoders.com" + ], + "railway_domains": [ + "moshcoding.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [ + { + "name": "moshcoding.com-volume", + "id": "1356dbe9-c42d-4593-a2da-739bedcb3b2f", + "mountPath": "/data", + "sizeMB": 836 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3223 + }, + "mynaposter.com": { + "site": "mynaposter.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "mynaposter.com", + "service_id": "b8230632-901f-4d2e-a259-a1dd929ab6b6", + "repo": "profullstack/mynaposter", + "image": null, + "domains": [ + "mynaposter.com", + "www.mynaposter.com" + ], + "railway_domains": [ + "mynaposter-web-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [ + { + "name": "mynaposter-api", + "id": "103a1a67-1f34-4d81-a0d1-3470a4266790", + "volumes": [], + "db": "pg", + "redis": false + } + ], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3261 + }, + "naallo.com": { + "site": "naallo.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "naallo.com", + "service_id": "b5a3e003-efa2-4abd-bf89-9726959273ab", + "repo": "profullstack/naallo", + "image": null, + "domains": [ + "www.naallo.com", + "naallo.com" + ], + "railway_domains": [ + "naallocom-production.up.railway.app", + "naallocom-production-4ec0.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3259 + }, + "nightcell7.com": { + "site": "nightcell7.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "nightcell7", + "service_id": "902a2a45-f1b6-4e5b-b0ee-7e4aa2aff895", + "repo": "profullstack/nightcell7", + "image": null, + "domains": [ + "www.nightcell7.com", + "nightcell7.com" + ], + "railway_domains": [ + "nightcell7.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": true, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": "redis.railway.internal", + "infra": [ + { + "name": "Redis", + "id": "b4cbc724-fa68-4513-acf6-c06f3cc9346f", + "role": "redis", + "image": "redis:8.2.9", + "volumes": [ + { + "name": "redis-volume", + "id": "39dde17d-9ffd-410c-91da-85d54cf6efd9", + "mountPath": "/data", + "sizeMB": 1591 + } + ] + } + ], + "shared_repo": false, + "port": 3250 + }, + "nixamp.com": { + "site": "nixamp.com", + "project": "nixamp", + "project_id": "2b76bfa5-97fd-4c14-8a9c-e5434ee41cd4", + "environment_id": "9b73cdb5-8338-4644-8786-34ce2396f65f", + "service": "nixamp-web", + "service_id": "4f0b85e7-41e4-485e-a114-631ae75c74a7", + "repo": "profullstack/nixamp", + "image": null, + "domains": [ + "www.nixamp.com", + "nixamp.com", + "www.backtoschool.help", + "backtoschool.help" + ], + "railway_domains": [ + "nixamp-web-production.up.railway.app" + ], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres-9wpC", + "id": "93ac8cea-8825-4a3a-b9ef-6723d92d4a9b", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume-ohPc", + "id": "dc0003af-aee0-4d37-a79d-bec7ed6586ec", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 845 + } + ] + }, + { + "name": "Postgres-ew1o", + "id": "b7c6bea5-d8e8-458a-91cc-5330e3b5df55", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume-654u", + "id": "18c24886-1c76-49fc-99fd-ac86681bc1e8", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 845 + } + ] + }, + { + "name": "Postgres", + "id": "c643a46e-bc18-4fd0-abfd-a67ee48eae1f", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "9ccd40d5-b7e0-4e8e-9ba1-29139b1835ae", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 1141 + } + ] + }, + { + "name": "Postgres-Cypz", + "id": "e8cf5d29-b3e9-4c46-bfcd-df30f2b8fccc", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume-Fpi-", + "id": "4af2c93a-2a8f-4718-9873-145d658dec37", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 1107 + } + ] + } + ], + "shared_repo": false, + "port": 3216 + }, + "obscura.openmcp.logicsrc.com": { + "site": "obscura.openmcp.logicsrc.com", + "project": "openmcp", + "project_id": "18ad173b-dc9c-402c-8b98-a4ed280c6cb0", + "environment_id": "a4164fa9-8459-4483-bc62-c52beeece729", + "service": "openmcp", + "service_id": "df82e9da-33c6-434f-b915-c5526b58ac16", + "repo": null, + "image": "node:24-bookworm-slim", + "domains": [ + "obscura.openmcp.logicsrc.com", + "openmcp.logicsrc.com" + ], + "railway_domains": [ + "openmcp-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [ + { + "name": "openmcp-volume", + "id": "9591caf5-71ae-4bbd-8c49-79d6f74e3208", + "mountPath": "/data", + "sizeMB": 1065 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3213 + }, + "openaccess.logicsrc.com": { + "site": "openaccess.logicsrc.com", + "project": "openaccess", + "project_id": "1f0f952f-1270-4480-a586-7980ae0d71ea", + "environment_id": "d6d01a2e-6a4c-4d0c-b73c-b6942aad0fc2", + "service": "openaccess", + "service_id": "3e3b78e2-f5a0-4994-a97c-2af1fd0a436b", + "repo": null, + "image": "node:24-alpine", + "domains": [ + "openaccess.logicsrc.com" + ], + "railway_domains": [], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [ + { + "name": "openaccess-volume", + "id": "bc9857ae-4d10-444a-a12d-f16f7e6d9e94", + "mountPath": "/data", + "sizeMB": 1065 + } + ], + "port_var": "8791", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3212 + }, + "opensocial.chat": { + "site": "opensocial.chat", + "project": "opensocial", + "project_id": "007ac72c-ab2e-4208-a409-203acbb5b36c", + "environment_id": "b2470c69-7308-411e-afa1-1e2fbdaf877c", + "service": "opensocial", + "service_id": "8c63f03e-6101-4300-a198-5ac1175ac627", + "repo": null, + "image": null, + "domains": [ + "opensocial.chat" + ], + "railway_domains": [ + "opensocial-production.up.railway.app" + ], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [], + "port_var": "3100", + "companions": [], + "db_host": "postgres-13bf.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "redis", + "id": "889a35dd-f168-47f1-b9c5-8a088c5c07a8", + "role": "redis", + "image": "redis:7-alpine", + "volumes": [] + }, + { + "name": "postgres", + "id": "a773b321-5782-4cd0-ab9d-80bad72846f0", + "role": "pg", + "image": "postgres:17-alpine", + "volumes": [] + } + ], + "shared_repo": false, + "port": 3210 + }, + "opensourcelegends.com": { + "site": "opensourcelegends.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "opensourcelegends.com", + "service_id": "3826f326-47fb-4ad5-aeb2-8fce6d7022b6", + "repo": "profullstack/opensourcelegends.com", + "image": null, + "domains": [ + "www.opensourcelegends.com", + "opensourcelegends.com" + ], + "railway_domains": [ + "opensourcelegends.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3232 + }, + "outreachgraph.com": { + "site": "outreachgraph.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "outreachgraph.com", + "service_id": "84358cda-e67c-4133-8275-40be706ff5f1", + "repo": "profullstack/outreachgraph", + "image": null, + "domains": [ + "outreachgraph.com", + "www.outreachgraph.com" + ], + "railway_domains": [ + "outreachgraph.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3248 + }, + "p0dcasters.com": { + "site": "p0dcasters.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "p0dcasters", + "service_id": "4c7743d5-d77a-4e36-a21e-96cb26a47bef", + "repo": "profullstack/p0dcasters", + "image": null, + "domains": [ + "p0dcasters.com" + ], + "railway_domains": [ + "p0dcasters-production.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3235 + }, + "pairux.com": { + "site": "pairux.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "pairux.com", + "service_id": "e992e4d9-ccc6-41e4-9964-c3743a060339", + "repo": "profullstack/pairux.com", + "image": null, + "domains": [ + "www.pairux.com", + "pairux.com" + ], + "railway_domains": [ + "pairuxcom-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": true, + "port": 3274 + }, + "phonenumbers.bot": { + "site": "phonenumbers.bot", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "phonenumbers.bot", + "service_id": "d788ac23-dcff-411d-a617-1821e7de26df", + "repo": "profullstack/phonenumbers.bot", + "image": null, + "domains": [ + "www.phonenumbers.bot", + "phonenumbers.bot" + ], + "railway_domains": [ + "phonenumbersbot-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3268 + }, + "postammo.com": { + "site": "postammo.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "postammo.com", + "service_id": "780f555f-3bd5-4aa9-b39e-741ada071cb1", + "repo": "profullstack/postammo.com", + "image": null, + "domains": [ + "www.postammo.com", + "postammo.com" + ], + "railway_domains": [ + "postammocom-production.up.railway.app", + "postammocom-production-cb78.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3246 + }, + "profullstack.com": { + "site": "profullstack.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "profullstack.com", + "service_id": "82d1ee66-dd81-4399-bb2b-73f862e3d63b", + "repo": "profullstack/profullstack-web", + "image": null, + "domains": [ + "profullstack.com", + "www.profullstack.com" + ], + "railway_domains": [ + "profullstack-web-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": "9450", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3247 + }, + "qaaas.dev": { + "site": "qaaas.dev", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "qaaas.dev", + "service_id": "d29ccdec-8a71-468a-a9aa-c3ca336e09a7", + "repo": "profullstack/qaaas.dev", + "image": null, + "domains": [ + "www.qaaas.dev", + "qaaas.dev" + ], + "railway_domains": [ + "qaaasdev-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3266 + }, + "qrypt.chat": { + "site": "qrypt.chat", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "qrypt.chat", + "service_id": "51064b81-862c-4807-8a98-b39920b07e94", + "repo": "profullstack/qryptchat-web", + "image": null, + "domains": [ + "qrypt.chat", + "www.qrypt.chat" + ], + "railway_domains": [], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [ + { + "name": "qrypt.chat-volume", + "id": "4686da3e-5f3e-4dc5-a978-7d29cb9b2655", + "mountPath": "/var/lib/tor/hidden_service", + "sizeMB": 149 + } + ], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3239 + }, + "r4ck.dev": { + "site": "r4ck.dev", + "project": "r4ck.dev", + "project_id": "1dc5c231-3265-4364-9af5-2cf78e49b5ba", + "environment_id": "ed427967-4a95-49be-99aa-df50d63396a4", + "service": "r4ck.dev", + "service_id": "f5403a69-0928-4b9a-a124-61992c4bba66", + "repo": "profullstack/r4ck.dev", + "image": null, + "domains": [ + "www.r4ck.dev", + "r4ck.dev" + ], + "railway_domains": [], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [], + "port_var": "3000", + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres", + "id": "a97aad71-cee6-4d22-a843-499fce794de0", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "3a1a006c-a5a7-4407-a675-204b9c12db32", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 1150 + } + ] + } + ], + "shared_repo": false, + "port": 3208 + }, + "readm3.com": { + "site": "readm3.com", + "project": "readm3", + "project_id": "d0006e18-8efd-414d-b892-49e790f412d6", + "environment_id": "15239234-70ef-4711-b772-599746e1bcd5", + "service": "readm3", + "service_id": "1c17f152-cbaa-4f7a-ab61-2886dbdd71b7", + "repo": "profullstack/readm3", + "image": null, + "domains": [ + "readm3.com", + "www.readm3.com" + ], + "railway_domains": [ + "readm3-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [ + { + "name": "readm3-volume", + "id": "1a841205-164f-4c51-8042-a53368016814", + "mountPath": "/data", + "sizeMB": 796 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3211 + }, + "recipepdfs.com": { + "site": "recipepdfs.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "recipepdfs.com", + "service_id": "c55ab9d4-2219-4114-9868-d484b95c5dd6", + "repo": "profullstack/recipepdfs.com", + "image": null, + "domains": [ + "www.recipepdfs.com", + "recipepdfs.com" + ], + "railway_domains": [ + "recipepdfscom-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3263 + }, + "reeleel.com": { + "site": "reeleel.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "reeleel", + "service_id": "1bdbef00-a8a1-4618-9f57-00d9b62622ba", + "repo": "profullstack/reeleel", + "image": null, + "domains": [ + "www.reeleel.com", + "reeleel.com" + ], + "railway_domains": [ + "reeleel.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [ + { + "name": "reeleel-volume", + "id": "4fd6fc14-bc49-4600-a067-58f7f8227ab7", + "mountPath": "/data", + "sizeMB": 6189 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3226 + }, + "ringhuman.com": { + "site": "ringhuman.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "ringhuman.com", + "service_id": "3a674a4e-a95d-4e2a-bc40-27c965c19ecc", + "repo": "profullstack/ringhuman.com", + "image": null, + "domains": [ + "ringhuman.com", + "www.ringhuman.com" + ], + "railway_domains": [ + "ringhumancom.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3233 + }, + "saasrow.com": { + "site": "saasrow.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "saasrow-web", + "service_id": "f9899f2e-493e-43b2-a1fe-c757d844e047", + "repo": "profullstack/saasrow-web", + "image": null, + "domains": [ + "www.saasrow.com", + "saasrow.com" + ], + "railway_domains": [ + "saasrow-web-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3276 + }, + "sh1pt.com": { + "site": "sh1pt.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "sh1pt.com", + "service_id": "72b41681-f139-45e3-a4b6-3972a628761b", + "repo": "profullstack/sh1pt", + "image": null, + "domains": [ + "www.sh1pt.com", + "sh1pt.com" + ], + "railway_domains": [ + "sh1ptcom-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3244 + }, + "sinkstate.com": { + "site": "sinkstate.com", + "project": "sinkstate", + "project_id": "56fbc164-6d3d-4e46-acea-6b4f332ec6af", + "environment_id": "f00b8b53-13d9-4526-bc16-22532abe33c8", + "service": "sinkstate", + "service_id": "3e298e05-2757-4d7c-92af-8eba08cf7a19", + "repo": "profullstack/sinkstate", + "image": null, + "domains": [ + "sinkstate.com" + ], + "railway_domains": [ + "sinkstate-production.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3219 + }, + "smshub.dev": { + "site": "smshub.dev", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "smshub.dev", + "service_id": "a9c818d5-2784-4137-8932-a33bcbd064cf", + "repo": "profullstack/smshub", + "image": null, + "domains": [ + "smshub.dev", + "www.smshub.dev" + ], + "railway_domains": [ + "smshub-production-65a6.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3256 + }, + "sorrycheck.com": { + "site": "sorrycheck.com", + "project": "sorrycheck", + "project_id": "a645aa8a-c5aa-4e37-bc40-c04ffeb7abd6", + "environment_id": "72061725-bbd5-4284-841d-a4ff1145e859", + "service": "sorrycheck", + "service_id": "2e48d286-0127-4530-8067-f00dd24395f1", + "repo": "profullstack/sorrycheck", + "image": null, + "domains": [ + "sorrycheck.com" + ], + "railway_domains": [ + "sorrycheck-production.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3220 + }, + "threatcrush.com": { + "site": "threatcrush.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "threatcrush.com", + "service_id": "dc38426b-e3db-4526-b0e4-913810a431bb", + "repo": "profullstack/threatcrush", + "image": null, + "domains": [ + "www.threatcrush.com", + "threatcrush.com" + ], + "railway_domains": [ + "threatcrush-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3270 + }, + "tipoffwatch.com": { + "site": "tipoffwatch.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "tipoffwatch.com", + "service_id": "1af0d67d-2204-4c26-99d8-e2765464bc8a", + "repo": "profullstack/tipoffwatch.com", + "image": null, + "domains": [ + "tipoffwatch.com" + ], + "railway_domains": [ + "tipoffwatch.up.railway.app" + ], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": true, + "volumes": [], + "port_var": "8080", + "companions": [], + "db_host": "postgres-q7ea.railway.internal", + "redis_host": "redis-g3yk.railway.internal", + "infra": [ + { + "name": "Postgres-q7ea", + "id": "19103667-0f74-4494-82f6-f1fc9db5d951", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume-iLQ4", + "id": "c308610d-58e4-4c8b-bef2-7a8173cf1a3f", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 3782 + } + ] + }, + { + "name": "Redis-g3Yk", + "id": "b0a4bdf4-582a-4daf-b268-51952fc255e9", + "role": "redis", + "image": "redis:8.10.1", + "volumes": [ + { + "name": "redis-volume-1uCi", + "id": "c6380555-a444-4ab0-b18e-0ec81349b94a", + "mountPath": "/data", + "sizeMB": 1210 + } + ] + } + ], + "shared_repo": false, + "port": 3225 + }, + "tronbrowser.dev": { + "site": "tronbrowser.dev", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "tronbrowser.dev", + "service_id": "c621b6d1-eb39-41ba-a3ef-303ba7de20d5", + "repo": "profullstack/tronbrowser.dev", + "image": null, + "domains": [ + "tronbrowser.dev", + "www.tronbrowser.dev" + ], + "railway_domains": [ + "tronbrowser.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [ + { + "name": "tronbrowser.dev-volume", + "id": "e45333b0-dc74-4ada-aa97-cc86b0254992", + "mountPath": "/var/lib/tor/hidden_service", + "sizeMB": 1060 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3264 + }, + "tsbb.dev": { + "site": "tsbb.dev", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "tsbb", + "service_id": "a5434b3a-b418-4a7d-82d9-c8cbfcf136a9", + "repo": "profullstack/tsbb", + "image": null, + "domains": [ + "tsbb.dev", + "www.tsbb.dev" + ], + "railway_domains": [ + "tsbb.up.railway.app" + ], + "db": "turso", + "db_kinds": [ + "turso" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3255 + }, + "ugig.net": { + "site": "ugig.net", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "ugig.net", + "service_id": "86b45c1d-4a7b-4063-8694-74e646a2ccb9", + "repo": "profullstack/ugig.net", + "image": null, + "domains": [ + "ugig.net", + "www.ugig.net" + ], + "railway_domains": [ + "ugignet-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3249 + }, + "vu1nz.com": { + "site": "vu1nz.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "vu1nz.com", + "service_id": "4169ee16-78e0-460d-8e06-a3feff6c2324", + "repo": "profullstack/vu1nz.com", + "image": null, + "domains": [ + "vu1nz.com", + "www.vu1nz.com" + ], + "railway_domains": [ + "vu1nzcom-production.up.railway.app" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [ + { + "name": "vu1nz.com-volume", + "id": "abfba90b-d5b0-4a1a-9c86-b71e41b3e88a", + "mountPath": "/app/data", + "sizeMB": 1067 + } + ], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3234 + }, + "w3bs.org": { + "site": "w3bs.org", + "project": "w3bs", + "project_id": "0e7a6bd2-79be-4a92-86d3-6a15ef3dec79", + "environment_id": "ea846dc0-96dd-43d6-be9f-cb00fb16eaf5", + "service": "w3bs", + "service_id": "571bb8cd-8ebc-4929-954d-97452e078a4c", + "repo": "profullstack/w3bs.org", + "image": null, + "domains": [ + "w3bs.org", + "www.w3bs.org", + "prompt.w3bs.org", + "browse.w3bs.org", + "specs.w3bs.org" + ], + "railway_domains": [], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": null, + "infra": [ + { + "name": "Postgres", + "id": "4350856e-bd50-49e1-8457-132d5db21030", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "dbf40f23-444c-4165-ba97-7d8a386c1f80", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 862 + } + ] + } + ], + "shared_repo": false, + "port": 3209 + }, + "watchnews.now": { + "site": "watchnews.now", + "project": "watchnews.now", + "project_id": "46002072-eb98-49cd-8eba-2d1d6b09d1a3", + "environment_id": "10459e11-1a4d-4d71-890a-f4a1173b6201", + "service": "watchnews.now", + "service_id": "f6196b0e-98ee-4b77-9d99-152832e4997b", + "repo": "profullstack/watchnews.now", + "image": null, + "domains": [ + "www.watchnews.now", + "watchnews.now" + ], + "railway_domains": [], + "db": "pg", + "db_kinds": [ + "pg" + ], + "redis": true, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": "postgres.railway.internal", + "redis_host": "redis.railway.internal", + "infra": [ + { + "name": "Postgres", + "id": "23bdde3e-9d16-4a45-b1b1-b3fc9fbafc0d", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [ + { + "name": "postgres-volume", + "id": "0283a6e2-aa2d-43e3-8ae9-a8f35aa0d347", + "mountPath": "/var/lib/postgresql/data", + "sizeMB": 3872 + } + ] + }, + { + "name": "Redis", + "id": "77628875-109a-4264-b55d-70eed867673c", + "role": "redis", + "image": "redis:8.2", + "volumes": [ + { + "name": "redis-volume", + "id": "24e869be-b04b-404a-8c4c-8c29531449c9", + "mountPath": "/data", + "sizeMB": 1060 + } + ] + } + ], + "shared_repo": false, + "port": 3215 + }, + "weedforcrypto.com": { + "site": "weedforcrypto.com", + "project": "Profullstack, Inc.", + "project_id": "e12258c5-050a-462d-a54c-b4c98939fe6f", + "environment_id": "1070bfbd-cea8-4c0b-ae98-338694fcb11e", + "service": "weedforcrypto", + "service_id": "63cf71c9-a1ee-47e8-8391-2c3e44390f0e", + "repo": "profullstack/weedforcrypto", + "image": null, + "domains": [ + "www.weedforcrypto.com", + "weedforcrypto.com" + ], + "railway_domains": [ + "weedforcrypto-production.up.railway.app" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "port_var": null, + "companions": [], + "db_host": null, + "redis_host": null, + "infra": [], + "shared_repo": false, + "port": 3243 + } + } +} diff --git a/dev2/sites.overrides.json b/dev2/sites.overrides.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/dev2/sites.overrides.json @@ -0,0 +1 @@ +{} diff --git a/dev2/templates/deploy-app.sh b/dev2/templates/deploy-app.sh new file mode 100755 index 0000000..05ac592 --- /dev/null +++ b/dev2/templates/deploy-app.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Deploy one site under /home/anthony/www/ on dev2. GitHub Actions calls +# this over ssh on every merge; you run it by hand to roll forward or back. +# +# deploy-app.sh build that revision and switch to it +# deploy-app.sh --rollback go back to the previously deployed sha +# deploy-app.sh --status what is deployed and healthy right now +# +# Everything site-specific comes from deploy.env beside this script (written by +# cli-tools/dev2/dev2-site provision): REPO, APP_PORT, BUILD_SERVICES, +# HEALTH_PATH. Builds happen on the box because NEXT_PUBLIC_*-style values are +# baked in at build time and live only in app.env here. +# +set -euo pipefail + +ROOT=${ROOT:-$(cd "$(dirname "$(readlink -f "$0")")" && pwd)} +APP_DIR="$ROOT/app" +STATE="$ROOT/.deploy-state" +# shellcheck disable=SC1091 +. "$ROOT/deploy.env" +: "${REPO:?deploy.env needs REPO}" +: "${APP_PORT:?deploy.env needs APP_PORT}" +BUILD_SERVICES=${BUILD_SERVICES:-app} +HEALTH_PATH=${HEALTH_PATH:-/} +HEALTH_TIMEOUT=${HEALTH_TIMEOUT:-300} + +log() { printf '\n===> %s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +compose() { (cd "$ROOT" && docker compose -f docker-compose.app.yml --env-file "$ROOT/deploy.env" "$@"); } + +health() { + local i + for i in $(seq 1 "$((HEALTH_TIMEOUT / 5))"); do + # Any HTTP answer counts: a 3xx redirect or a 401 from the app is alive. + if curl -sS -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${APP_PORT}${HEALTH_PATH}" | grep -Eq '^[1-4]'; then return 0; fi + sleep 5 + done + return 1 +} + +case "${1:-}" in + --status) + compose ps + curl -sS -m 5 -o /dev/null -w 'app http %{http_code} in %{time_total}s\n' "http://127.0.0.1:${APP_PORT}${HEALTH_PATH}" || echo "app not answering" + [ -f "$STATE" ] && cat "$STATE" + exit 0 + ;; + --rollback) + [ -f "$STATE" ] || die "no deploy state to roll back to" + # shellcheck disable=SC1090 + . "$STATE" + [ -n "${PREVIOUS_SHA:-}" ] || die "no PREVIOUS_SHA recorded" + log "Rolling back to $PREVIOUS_SHA" + exec "$0" "$PREVIOUS_SHA" + ;; +esac + +TARGET=${1:?usage: deploy-app.sh | --rollback | --status} + +[ -f "$ROOT/app.env" ] || die "missing $ROOT/app.env (the app's secrets)" + +CURRENT="" +[ -d "$APP_DIR/.git" ] && CURRENT=$(git -C "$APP_DIR" rev-parse HEAD 2>/dev/null || echo "") + +if [ ! -d "$APP_DIR/.git" ]; then + log "First deploy: cloning $REPO" + git clone --filter=blob:none "$REPO" "$APP_DIR" +fi + +log "Fetching $TARGET" +git -C "$APP_DIR" fetch --all --tags --prune -q + +# Resolve to a sha first: `checkout --detach ` gives a useless +# error, and a bare branch name only resolves as origin/. +SHA=$(git -C "$APP_DIR" rev-parse --verify --quiet "origin/$TARGET^{commit}" \ + || git -C "$APP_DIR" rev-parse --verify --quiet "$TARGET^{commit}" \ + || true) +[ -n "$SHA" ] || die "cannot resolve '$TARGET' to a commit" +git -C "$APP_DIR" checkout -q --detach "$SHA" +log "At $SHA" + +log "Building $BUILD_SERVICES" +# shellcheck disable=SC2086 +compose build $BUILD_SERVICES + +log "Starting" +compose up -d --remove-orphans + +if health; then + log "Healthy on 127.0.0.1:$APP_PORT$HEALTH_PATH" + { + echo "DEPLOYED_SHA=$SHA" + echo "PREVIOUS_SHA=$CURRENT" + echo "DEPLOYED_AT=$(date -u +%FT%TZ)" + } > "$STATE" + compose ps +else + log "UNHEALTHY after ${HEALTH_TIMEOUT}s - last 60 lines:" + compose logs --tail 60 || true + if [ -n "$CURRENT" ] && [ "$CURRENT" != "$SHA" ]; then + log "Restoring $CURRENT" + git -C "$APP_DIR" checkout -q --detach "$CURRENT" + # shellcheck disable=SC2086 + compose build $BUILD_SERVICES && compose up -d + health && log "Restored to $CURRENT" || log "ROLLBACK ALSO UNHEALTHY - site is down" + fi + die "deploy of $SHA failed health check" +fi diff --git a/dev2/templates/deploy-dev2.yml b/dev2/templates/deploy-dev2.yml new file mode 100644 index 0000000..1b19d01 --- /dev/null +++ b/dev2/templates/deploy-dev2.yml @@ -0,0 +1,60 @@ +# Ship every merge to dev2, where __SITE__ runs since it left Railway (which +# deployed on merge by itself). The box pulls the merged commit, rebuilds the +# image(s) from this repo and restarts the compose stack under +# /home/anthony/www/__SITE__. Generated by cli-tools/dev2/dev2-site scaffold. +name: Deploy to dev2 + +on: + push: + branches: [__BRANCH__] + workflow_dispatch: + inputs: + ref: + description: Git ref to deploy (defaults to the pushed commit) + required: false + type: string + +concurrency: + group: deploy-dev2 + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + # ${{ }} values go through env, never straight into a run: body. + - name: Resolve target revision + id: rev + env: + REF: ${{ inputs.ref || github.sha }} + run: echo "sha=$REF" >> "$GITHUB_OUTPUT" + + - name: Set up ssh + env: + SSH_KEY: ${{ secrets.DEV2_SSH_KEY }} + KNOWN_HOSTS: ${{ secrets.DEV2_KNOWN_HOSTS }} + run: | + install -d -m 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + - name: Deploy + env: + DEV2_USER: ${{ secrets.DEV2_USER }} + DEV2_HOST: ${{ secrets.DEV2_HOST }} + SHA: ${{ steps.rev.outputs.sha }} + run: | + ssh -o BatchMode=yes "$DEV2_USER@$DEV2_HOST" \ + /home/anthony/www/__SITE__/deploy-app.sh "$SHA" + + - name: Verify the site answers + run: | + for i in $(seq 1 10); do + code=$(curl -s -o /dev/null -w '%{http_code}' "__HEALTH_URL__" || true) + case "$code" in 2*|3*|401|403) echo "__SITE__ $code"; exit 0;; esac + echo "attempt $i: $code"; sleep 10 + done + echo "__SITE__ never answered"; exit 1 diff --git a/dev2/templates/nginx-vhost.conf b/dev2/templates/nginx-vhost.conf new file mode 100644 index 0000000..eef5994 --- /dev/null +++ b/dev2/templates/nginx-vhost.conf @@ -0,0 +1,45 @@ +# __SITE__: managed by cli-tools/dev2/dev2-site vhost. Do not hand-edit; re-run +# `dev2-site vhost __SITE__` after changing the template. +# The app runs in Docker on 127.0.0.1:__APP_PORT__; nginx is its only public face. + +server { + listen 80; + listen [::]:80; + server_name __SERVER_NAMES__; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/acme; + default_type "text/plain"; + } + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name __SERVER_NAMES__; + + ssl_certificate /etc/nginx/ssl/__SITE__/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/__SITE__/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + client_max_body_size __MAX_BODY__; + + location / { + proxy_pass http://127.0.0.1:__APP_PORT__; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 300s; + proxy_buffering off; + } +} From 971cbc789bfe9ca815a36fc233b0aa71adb5cd31 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:35:16 +0000 Subject: [PATCH 002/104] dev2: README for the fleet kit Co-Authored-By: Claude Fable 5.1 --- dev2/README.md | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 dev2/README.md diff --git a/dev2/README.md b/dev2/README.md new file mode 100644 index 0000000..b5c6159 --- /dev/null +++ b/dev2/README.md @@ -0,0 +1,94 @@ +# dev2-site: Railway -> dev2, and deploy-on-merge afterwards + +`dev2/dev2-site` moves one Railway-hosted site at a time onto **dev2** +(`dev2.profullstack.com`, 23.95.228.174), our production box, and leaves the +repo deploying there on every merge. It is the generalisation of the kits that +moved crawlproof.com (`crawlproof.com/ops/selfhost`), nichedb.dev +(`niche-db/ops/selfhost-supabase`) and rssamplifier.com. + +Run it from dev1. **dev2 is production: this tool is the only thing that should +write to it.** Everything it writes is derived from Railway plus the templates +here, so re-running a step reproduces the box instead of drifting from it. + +``` +dev2-site registry rebuild sites.json from Railway (projects, services, domains, volumes, DB variables) +dev2-site list [--kind pg|none|supabase|turso] [--status on-dev2] +dev2-site show registry entry + this site's state +dev2-site migrate --yes the whole move, resumable; each step below is also a subcommand +``` + +| step | what it does | +| --- | --- | +| `scaffold` | in the app repo: `.github/workflows/deploy-dev2.yml` (+ `.nixpacks/Dockerfile` from nixpacks when the repo has no Dockerfile, the same builder Railway used) on branch `ops/dev2-deploy`, pushed, PR opened | +| `db` | (kind `pg`) database in the shared Supabase cluster on dev2 (one database per app, role `postgres`, like nichedb and rssamplifier); `pg_dump -Fc` inside the Railway Postgres container over `ssh.railway.com`, streamed to dev2, `pg_restore`, extensions pre-created, row counts compared | +| `provision` | `/home/anthony/www//`: `app.env` rendered from Railway's variables (RAILWAY_* dropped, DATABASE_URL / PG* / REDIS_URL re-pointed), one `.env` per companion service, `deploy.env`, `docker-compose.app.yml`, `deploy-app.sh`; one deploy key per repo authorised for `anthony`; `DEV2_*` secrets set on the repo | +| `volumes` | Railway volume contents tar-streamed into `volumes/`, bind-mounted at the same path | +| `deploy` | `deploy-app.sh ` as `anthony`: build on the box, `compose up`, health on `127.0.0.1:` | +| `cert` | acme.sh, Let's Encrypt, **Porkbun DNS-01**, every custom domain (+ www), into `/etc/nginx/ssl//`; issued before DNS moves so there is no TLS gap | +| `vhost` | nginx server block -> `127.0.0.1:` | +| `verify` | curl each domain `--resolve`d to dev2; `--public` after the flip | +| `refresh-db` | (kind `pg`) re-copies the database seconds before the flip and restarts the app, so writes that landed on Railway during setup are not lost | +| `dns` | Porkbun: ALIAS/CNAME to Railway removed, `A -> 23.95.228.174` for every domain, `_railway-verify` TXT removed; the zone is backed up as JSON first | +| `railway-stop` | `deploymentRemove` + `serviceDisconnect` (or the next push silently redeploys on Railway) | +| `merge` | merges the scaffold PR; CI deploys the default branch to dev2 | +| `retire` | `--yes`: `serviceDelete` for the app, companions and data services. Only after `verify --public` | + +## Layout on the box (every site the same) + +``` +/home/anthony/www// +├── app/ git checkout the deploy builds from +├── app.env the app's environment (0600, anthony) +├── .env one per companion service +├── deploy.env SITE REPO BRANCH APP_PORT BUILD_SERVICES HEALTH_PATH +├── docker-compose.app.yml app (+ companions) (+ redis) +├── deploy-app.sh what CI calls over ssh; --status, --rollback +├── volumes// Railway volumes +└── .deploy-state +``` + +Ports are allocated once in `sites.json` from 3200 upwards (3010 nichedb, 3020 +rssamplifier, 3100 crawlproof predate the kit). nginx is the only public face. + +## Per-site overrides + +`sites.d/.json` is merged over the registry entry at load time. One +small file per site, so parallel migrations never edit a shared file: + +```json +{ "health_path": "/healthz", "port_var": "8080", "start_command": "bun run start", + "companion_commands": { "genrewatch-worker": "bun run worker" }, + "env_overrides": { "SITE_URL": "https://example.com" }, + "pg_service": "Postgres-iVtY", "max_body": "256m", "branch": "master" } +``` + +## Database kinds + +- `pg`: Railway Postgres -> shared cluster (above). Done by the kit. +- `none`: nothing to move. +- `supabase`: the app keeps talking to Supabase cloud after the move; the + database moves in a second pass onto a per-app self-hosted stack (see + `crawlproof.com/ops/selfhost` for the shape: `setup-supabase.sh`, + `pull-cloud.sh`, `load-selfhost.sh`, `sync-storage.mjs`). Then + `env_overrides` + `provision` + `deploy` re-point the app. +- `turso`: the app keeps talking to Turso after the move; the database moves + in a second pass with a Postgres port of the app's db layer (the pattern is + rssamplifier.com `packages/db/src/pg.js`: serve the libSQL surface over pg). + +## State + +`~/.local/state/dev2-fleet/.json` (steps done, database url, PR, dump +path), `deploy-keys/` (private halves; GitHub holds the copies), `dns/` (zone +backups before each flip). + +## Traps already met + +- Railway's `usage`/`variables` API rate-limits and tokens last an hour; the + kit refreshes through `railway whoami`. +- A repo that squash-merged a branch makes a new PR from the same branch name + CONFLICTING and CI silently never runs: rebase or use a fresh branch. +- `git add` with one missing pathspec stages nothing; the kit adds paths one + by one. +- A duplicate standalone project (`tipoffwatch`) exists beside the live + megaproject service (`tipoffwatch.com`); the registry keys by custom domain, + so the duplicate is not a site. From 840334cb976dd64262a27a9e9f70308fe2075a66 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:41:39 +0000 Subject: [PATCH 003/104] dev2-site: clone private repos over ssh with a read-only deploy key; show deploy stderr Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 89070 -> 90797 bytes dev2/dev2-site | 16 +- dev2/sites.d/agenticjobs.work.json | 5 + dev2/sites.d/cigarunderground.org.json | 3 + dev2/sites.d/genrewatch.com.json | 3 + dev2/templates/supabase-load.sh | 77 +++++++ dev2/templates/supabase-pull.sh | 57 ++++++ dev2/templates/supabase-stack.sh | 239 ++++++++++++++++++++++ dev2/templates/sync-storage.mjs | 143 +++++++++++++ 9 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 dev2/sites.d/agenticjobs.work.json create mode 100644 dev2/sites.d/cigarunderground.org.json create mode 100644 dev2/sites.d/genrewatch.com.json create mode 100755 dev2/templates/supabase-load.sh create mode 100755 dev2/templates/supabase-pull.sh create mode 100755 dev2/templates/supabase-stack.sh create mode 100644 dev2/templates/sync-storage.mjs diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 763b89e4ec7d78c02e20df28700254f27900c1fc..e493dd13b5ca67761db5a29ef0b4e5454e6e67a8 100644 GIT binary patch delta 15475 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 delta 13748 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 BDsTV* diff --git a/dev2/dev2-site b/dev2/dev2-site index 18af391..6df9e6b 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -498,7 +498,10 @@ def cmd_provision(args): hp = st.get("health_path") or health_url(s, cfg)[1] compose = render_compose(s, st, inside) build_services = " ".join(["app"] + [c["name"] for c in s["companions"]]) - deploy_env = f"SITE={s['site']}\nREPO=https://github.com/{s['repo']}.git\nBRANCH={branch}\nAPP_PORT={s['port']}\nBUILD_SERVICES={build_services}\nHEALTH_PATH={hp}\n" + # The box clones over ssh with a read-only GitHub deploy key (private repos have + # no https path for the deploy account); the alias keeps one key per repo. + key_alias = f"github.com-{s['repo'].replace('/', '__')}" + deploy_env = f"SITE={s['site']}\nREPO=git@{key_alias}:{s['repo']}.git\nBRANCH={branch}\nAPP_PORT={s['port']}\nBUILD_SERVICES={build_services}\nHEALTH_PATH={hp}\n" ssh("root", f"install -d -m 2755 -o {DEPLOY_USER} -g {DEPLOY_USER} {root} {root}/volumes") for v in s["volumes"] + [v for c in s["companions"] for v in c.get("volumes", [])]: ssh("root", f"install -d -m 0777 {root}/volumes/{shlex.quote(v['name'])}") @@ -516,6 +519,15 @@ def cmd_provision(args): key = deploy_key(s["repo"]) pub = open(key + ".pub").read().strip() ssh("root", f"install -d -m 700 -o {DEPLOY_USER} -g {DEPLOY_USER} /home/{DEPLOY_USER}/.ssh; touch /home/{DEPLOY_USER}/.ssh/authorized_keys; chown {DEPLOY_USER}:{DEPLOY_USER} /home/{DEPLOY_USER}/.ssh/authorized_keys; chmod 600 /home/{DEPLOY_USER}/.ssh/authorized_keys; grep -qF {shlex.quote(pub)} /home/{DEPLOY_USER}/.ssh/authorized_keys || echo {shlex.quote(pub)} >> /home/{DEPLOY_USER}/.ssh/authorized_keys") + # The same key pair, read-only on the repo, lets the box fetch it. + slug = s["repo"].replace("/", "__") + have = sh(["gh", "api", f"repos/{s['repo']}/keys", "--jq", ".[].key"], check=False) + if pub.split()[1] not in have: + sh(["gh", "repo", "deploy-key", "add", key + ".pub", "--repo", s["repo"], "--title", "dev2 deploy (dev2-site)"]) + note("read-only deploy key added to the GitHub repo") + ssh_put(open(key).read(), f"/home/{DEPLOY_USER}/.ssh/deploy-keys/{slug}", "0600", own) if ssh("root", f"install -d -m 700 -o {DEPLOY_USER} -g {DEPLOY_USER} /home/{DEPLOY_USER}/.ssh/deploy-keys && echo ok").strip() == "ok" else None + ssh("root", f"f=/home/{DEPLOY_USER}/.ssh/config; touch $f; chown {DEPLOY_USER}:{DEPLOY_USER} $f; chmod 600 $f; grep -q '^Host {key_alias}$' $f || printf 'Host {key_alias}\n HostName github.com\n User git\n IdentityFile ~/.ssh/deploy-keys/{slug}\n IdentitiesOnly yes\n StrictHostKeyChecking accept-new\n\n' >> $f") + ssh("root", f"k=/home/{DEPLOY_USER}/.ssh/known_hosts; grep -q '^github.com ' $k 2>/dev/null || ssh-keyscan github.com >> $k 2>/dev/null; chown {DEPLOY_USER}:{DEPLOY_USER} $k") known = sh(["ssh-keyscan", "-t", "ed25519,rsa", DEV2_HOST], check=False) for name, val in (("DEV2_SSH_KEY", open(key).read()), ("DEV2_KNOWN_HOSTS", known), ("DEV2_HOST", DEV2_HOST), ("DEV2_USER", DEPLOY_USER)): sh(["gh", "secret", "set", name, "--repo", s["repo"]], input=val) @@ -623,7 +635,7 @@ def cmd_deploy(args): s = site(args.site); st = load_state(s["site"]) ref = args.ref or st.get("scaffold_branch") or st.get("branch") or s.get("branch") or "main" log(f"Deploy {s['site']} @ {ref} on dev2 (as {DEPLOY_USER})") - out = ssh(DEPLOY_USER, f"{WWW}/{s['site']}/deploy-app.sh {shlex.quote(ref)}", check=False, timeout=3600) + out = ssh(DEPLOY_USER, f"{WWW}/{s['site']}/deploy-app.sh {shlex.quote(ref)} 2>&1", check=False, timeout=3600) print(out[-4000:]) if "Healthy on" not in out: die("deploy did not report healthy; see output above") mark(s["site"], "deploy", deployed_ref=ref) diff --git a/dev2/sites.d/agenticjobs.work.json b/dev2/sites.d/agenticjobs.work.json new file mode 100644 index 0000000..18ab30c --- /dev/null +++ b/dev2/sites.d/agenticjobs.work.json @@ -0,0 +1,5 @@ +{ + "env_overrides": { + "OBSCURA_MCP_URL": "" + } +} diff --git a/dev2/sites.d/cigarunderground.org.json b/dev2/sites.d/cigarunderground.org.json new file mode 100644 index 0000000..7b65c28 --- /dev/null +++ b/dev2/sites.d/cigarunderground.org.json @@ -0,0 +1,3 @@ +{ + "health_path": "/healthz" +} diff --git a/dev2/sites.d/genrewatch.com.json b/dev2/sites.d/genrewatch.com.json new file mode 100644 index 0000000..5121b1b --- /dev/null +++ b/dev2/sites.d/genrewatch.com.json @@ -0,0 +1,3 @@ +{ + "companion_commands": {"genrewatch-worker": "bun apps/web/src/main.js"} +} diff --git a/dev2/templates/supabase-load.sh b/dev2/templates/supabase-load.sh new file mode 100755 index 0000000..1208d5f --- /dev/null +++ b/dev2/templates/supabase-load.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Load a supabase-pull.sh dump into a site's self-hosted stack on dev2. Run as root on dev2. +# DUMP=/root/dumps/-supabase DBC=-supabase-db CLOUD_REF= NEW_HOST=supabase. +# From crawlproof.com/ops/selfhost/migrate/load-selfhost.sh, made generic: the +# absolute-storage-URL rewrite scans every text/json column instead of a fixed list. +set -euo pipefail +: "${DUMP:?}" "${DBC:?}" "${CLOUD_REF:?}" "${NEW_HOST:?}" +POST_ONLY=${POST_ONLY:-0} +log() { printf '\n===> %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +[ -d "$DUMP" ] || die "no dump at $DUMP" +psql_db() { docker exec -i "$DBC" psql -U postgres -h localhost -d postgres -X "$@"; } +psql_admin() { docker exec -i "$DBC" psql -U supabase_admin -h localhost -d postgres -X "$@"; } +strict() { psql_db -v ON_ERROR_STOP=1 "$@"; } +docker exec "$DBC" pg_isready -U postgres -h localhost >/dev/null || die "$DBC not ready" + +log "Snapshot BEFORE" +strict -At -c "select 'tables='||(select count(*) from information_schema.tables where table_schema='public')||' users='||(select count(*) from auth.users)" + +if [ "$POST_ONLY" = 0 ]; then + log "Extensions the cloud had" + while read -r e; do [ -n "$e" ] || continue; case $e in plpgsql|pg_graphql|pgsodium|supabase_vault|pg_stat_statements|pgjwt) continue;; esac + psql_admin -v ON_ERROR_STOP=1 -c "create extension if not exists \"$e\" cascade" >/dev/null 2>&1 && echo " $e" || echo " $e: NOT AVAILABLE (dump may fail on objects using it)"; done < "$DUMP/extensions.txt" + log "Schema" + grep -qE 'CREATE SCHEMA "?(auth|storage)"?' "$DUMP/schema.sql" && die "schema.sql contains auth/storage DDL" + psql_admin -f /dev/stdin < "$DUMP/schema.sql" > "$DUMP/schema.load.log" 2>&1 || true + echo " schema errors: $(grep -c '^ERROR' "$DUMP/schema.load.log" || true)"; grep '^ERROR' "$DUMP/schema.load.log" | sort | uniq -c | sort -rn | head -15 | sed 's/^/ /' || true + log "Data (auth before app schemas)" + a=$(grep -m1 -n 'COPY "auth"' "$DUMP/data.sql" | cut -d: -f1 || echo 0); p=$(grep -m1 -n 'COPY "public"' "$DUMP/data.sql" | cut -d: -f1 || echo 0); a=${a:-0}; p=${p:-0} + if [ "$a" -gt 0 ] && [ "$p" -gt 0 ] && [ "$a" -gt "$p" ]; then die "data.sql has public before auth"; fi + psql_admin -f /dev/stdin < "$DUMP/data.sql" > "$DUMP/data.load.log" 2>&1 || true + echo " data errors: $(grep -c '^ERROR' "$DUMP/data.load.log" || true)"; grep '^ERROR' "$DUMP/data.load.log" | sort | uniq -c | sort -rn | head -15 | sed 's/^/ /' || true + log "Grants for anon/authenticated/service_role" + psql_admin -f /dev/stdin < "$DUMP/grants.sql" > "$DUMP/grants.load.log" 2>&1 || true + echo " grant errors: $(grep -c '^ERROR' "$DUMP/grants.load.log" || true)" + log "Ownership: app schemas to postgres (they were created by supabase_admin)" + psql_admin -At -v ON_ERROR_STOP=1 <<'SQL' +do $$ declare r record; begin + for r in select nspname from pg_namespace where nspname not in ('pg_catalog','information_schema','pg_toast','auth','storage','realtime','_realtime','supabase_functions','supabase_migrations','graphql','graphql_public','extensions','vault','pgsodium','pgsodium_masks','cron','net','pgbouncer','_analytics','_supavisor') and nspname not like 'pg_%' loop + execute format('alter schema %I owner to postgres', r.nspname); + execute (select coalesce(string_agg(format('alter table %I.%I owner to postgres', schemaname, tablename), '; '), 'select 1') from pg_tables where schemaname=r.nspname); + execute (select coalesce(string_agg(format('alter sequence %I.%I owner to postgres', sequence_schema, sequence_name), '; '), 'select 1') from information_schema.sequences where sequence_schema=r.nspname); + execute (select coalesce(string_agg(format('alter view %I.%I owner to postgres', schemaname, viewname), '; '), 'select 1') from pg_views where schemaname=r.nspname); + execute (select coalesce(string_agg(format('alter function %s owner to postgres', p.oid::regprocedure), '; '), 'select 1') from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname=r.nspname and p.prokind in ('f','p')); + end loop; end $$; +SQL +fi + +log "Buckets" +while IFS=$'\t' read -r id name pub limit mimes; do [ -n "$id" ] || continue; case "$pub" in t|true) ps=true;; *) ps=false;; esac + strict -c "insert into storage.buckets (id, name, public) values ('$id','$name',$ps) on conflict (id) do update set public=excluded.public" >/dev/null; done < "$DUMP/buckets.tsv" + +log "Rewriting absolute https://$CLOUD_REF.supabase.co URLs to https://$NEW_HOST in every text/json column" +strict -At < 0 then raise notice '% rows in %.%.%', n, r.s, r.t, r.c; total := total + n; end if; + end loop; raise notice 'rewrote % rows', total; end \$\$; +SQL + +log "Realtime publication"; psql_db -f /dev/stdin < "$DUMP/realtime.sql" 2>&1 | sed 's/^/ /' || true +log "pg_cron jobs"; psql_db -f /dev/stdin < "$DUMP/cron-jobs.sql" > "$DUMP/cron.load.log" 2>&1 || true +strict -At -c "select count(*)||' cron jobs active' from cron.job where active" 2>/dev/null || true + +log "Snapshot AFTER (vs the cloud's estimates in counts-estimate.tsv)" +strict -At -F$'\t' -c "select n.nspname||'.'||c.relname, c.reltuples::bigint from pg_class c join pg_namespace n on n.oid=c.relnamespace where c.relkind='r' and n.nspname='public' order by 1" > "$DUMP/counts-after.tsv" || true +strict -At -c "select 'tables='||(select count(*) from information_schema.tables where table_schema='public')||' users='||(select count(*) from auth.users)||' identities='||(select count(*) from auth.identities)||' size='||pg_size_pretty(pg_database_size(current_database()))" +strict -c "analyze" >/dev/null 2>&1 || true +log "Loaded." diff --git a/dev2/templates/supabase-pull.sh b/dev2/templates/supabase-pull.sh new file mode 100755 index 0000000..0fb0886 --- /dev/null +++ b/dev2/templates/supabase-pull.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Dump a Supabase CLOUD project so it can be loaded into a self-hosted stack. +# Read-only against the cloud; run as root on dev2 (pg_dump through postgres:17). +# CLOUD_DB_URL=postgres://postgres.:@aws-x-.pooler.supabase.com:5432/postgres OUT=/root/dumps/-supabase +# From crawlproof.com/ops/selfhost/migrate/pull-cloud.sh, made generic: every +# non-system schema is dumped, not just public. +set -euo pipefail +: "${CLOUD_DB_URL:?}" "${OUT:?}" +PG_IMAGE=${PG_IMAGE:-postgres:17} +log() { printf '\n===> %s\n' "$*" >&2; } +mkdir -p "$OUT"; chmod 700 "$OUT" +pg() { docker run --rm -i --network host "$PG_IMAGE" "$@"; } +psqlc() { pg psql "$CLOUD_DB_URL" -At -X -v ON_ERROR_STOP=1 "$@"; } + +log "Schemas" +SYS="'pg_catalog','information_schema','pg_toast','auth','storage','realtime','_realtime','supabase_functions','supabase_migrations','graphql','graphql_public','extensions','vault','pgsodium','pgsodium_masks','cron','net','pgbouncer','_analytics','_supavisor','pgmq','pgtle','repack','topology','tiger','tiger_data'" +psqlc -c "select nspname from pg_namespace where nspname not in ($SYS) and nspname not like 'pg_%' order by 1" > "$OUT/schemas.txt" +tr '\n' ' ' < "$OUT/schemas.txt"; echo +SCHEMA_ARGS=(); while read -r s; do [ -n "$s" ] && SCHEMA_ARGS+=(--schema="$s"); done < "$OUT/schemas.txt" + +log "Extensions in use" +psqlc -c "select extname from pg_extension where extname not in ('plpgsql') order by 1" > "$OUT/extensions.txt" + +log "Schema DDL (app schemas only; auth/storage structure belongs to the stack's own services)" +pg pg_dump --dbname="$CLOUD_DB_URL" --schema-only --no-owner --no-privileges --quote-all-identifiers "${SCHEMA_ARGS[@]}" > "$OUT/schema.sql" + +log "Data (auth + app schemas + storage buckets)" +pg pg_dump --dbname="$CLOUD_DB_URL" --data-only --no-owner --no-privileges --quote-all-identifiers --disable-triggers \ + --schema=auth "${SCHEMA_ARGS[@]}" --schema=storage \ + --exclude-table-data='storage.objects' --exclude-table-data='storage.migrations' --exclude-table-data='storage.s3_multipart_uploads*' --exclude-table-data='storage.prefixes' \ + --exclude-table-data='auth.schema_migrations' --exclude-table-data='auth.audit_log_entries' --exclude-table-data='auth.refresh_tokens' --exclude-table-data='auth.sessions' --exclude-table-data='auth.flow_state' \ + ${EXCLUDE_TABLE_DATA:-} > "$OUT/data.sql" + +log "Grants and RLS policies (the schema dump drops privileges; PostgREST needs them back)" +psqlc -c "select 'grant '||privilege_type||' on '||quote_ident(table_schema)||'.'||quote_ident(table_name)||' to '||quote_ident(grantee)||';' from information_schema.role_table_grants where grantee in ('anon','authenticated','service_role') and table_schema not in ($SYS) order by 1" > "$OUT/grants.sql" +psqlc -c "select 'grant '||privilege_type||' on '||quote_ident(routine_schema)||'.'||quote_ident(routine_name)||' to '||quote_ident(grantee)||';' from information_schema.role_routine_grants where grantee in ('anon','authenticated','service_role') and routine_schema not in ($SYS) order by 1" >> "$OUT/grants.sql" || true +psqlc -c "select 'grant usage, select on all sequences in schema '||quote_ident(nspname)||' to anon, authenticated, service_role;' from pg_namespace where nspname not in ($SYS) and nspname not like 'pg_%'" >> "$OUT/grants.sql" + +log "pg_cron jobs" +psqlc -c "select 'select cron.schedule(' || quote_literal(jobname) || ', ' || quote_literal(schedule) || ', ' || quote_literal(command) || ');' from cron.job where active order by jobid" > "$OUT/cron-jobs.sql" 2>/dev/null || : > "$OUT/cron-jobs.sql" + +log "Realtime publication" +psqlc -c "select 'alter publication supabase_realtime add table ' || string_agg(format('%I.%I', schemaname, tablename), ', ') || ';' from pg_publication_tables where pubname='supabase_realtime'" > "$OUT/realtime.sql" || : > "$OUT/realtime.sql" + +log "Storage inventory + buckets" +psqlc -F$'\t' -c "select b.id, b.public, o.name, coalesce((o.metadata->>'size')::bigint,0), coalesce(o.metadata->>'mimetype','application/octet-stream') from storage.buckets b join storage.objects o on o.bucket_id=b.id order by b.id, o.name" > "$OUT/storage-inventory.tsv" +psqlc -F$'\t' -c "select id, name, public, coalesce(file_size_limit::text,''), coalesce(array_to_string(allowed_mime_types,','),'') from storage.buckets order by id" > "$OUT/buckets.tsv" + +log "Row counts (for the load to compare against)" +psqlc -F$'\t' -c "select n.nspname||'.'||c.relname, c.reltuples::bigint from pg_class c join pg_namespace n on n.oid=c.relnamespace where c.relkind='r' and (n.nspname in (select nspname from pg_namespace where nspname not in ($SYS) and nspname not like 'pg_%') or (n.nspname='auth' and c.relname in ('users','identities'))) order by 1" > "$OUT/counts-estimate.tsv" + +{ + echo "dumped_at=$(date -u +%FT%TZ)"; echo "schema_bytes=$(stat -c%s "$OUT/schema.sql")"; echo "data_bytes=$(stat -c%s "$OUT/data.sql")" + echo "storage_objects=$(wc -l < "$OUT/storage-inventory.tsv")"; echo "storage_bytes=$(awk -F'\t' '{s+=$4} END{print s+0}' "$OUT/storage-inventory.tsv")" + echo "cron_jobs=$(grep -c '^select cron.schedule' "$OUT/cron-jobs.sql" || true)"; echo "auth_users=$(grep -c '' <(psqlc -c 'select id from auth.users'))" +} > "$OUT/MANIFEST" +log "Done: $OUT"; cat "$OUT/MANIFEST" diff --git a/dev2/templates/supabase-stack.sh b/dev2/templates/supabase-stack.sh new file mode 100755 index 0000000..4bb9440 --- /dev/null +++ b/dev2/templates/supabase-stack.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# +# Stand up ONE self-hosted Supabase stack for one site on dev2, beside the +# others. Run as root ON dev2 by `dev2-site supabase-stack `, which passes +# everything below in the environment. Idempotent: re-running keeps the +# generated secrets and data and only rewrites the overlay before restarting. +# +# Derived from crawlproof.com/ops/selfhost/server/setup-supabase.sh. What differs: +# every stack gets its own compose project name, container names, ports and +# subnet, so twenty of them coexist on the box; Postgres is sized small (many +# stacks share the RAM); the system/backup/alarm parts are not here (the box is +# already set up). +# +# SITE example.com SLUG example-com +# ROOT /home/anthony/www/example.com +# STUDIO_DOMAIN supabase.example.com SITE_URL https://example.com +# API_PORT DB_PORT POOLER_PORT host ports (loopback for API/pooler, public+firewalled for DB) +# SUBNET GATEWAY 172.31.N.0/24 and .1 +# SMTP_PASS SMTP_SENDER SMTP_ADMIN optional (Resend) +# SUPABASE_REF self-hosted/v0.8.2 +set -euo pipefail + +: "${SITE:?}" "${SLUG:?}" "${ROOT:?}" "${STUDIO_DOMAIN:?}" "${SITE_URL:?}" "${API_PORT:?}" "${DB_PORT:?}" "${POOLER_PORT:?}" "${SUBNET:?}" "${GATEWAY:?}" +SUPABASE_REF=${SUPABASE_REF:-self-hosted/v0.8.2} +PROJECT=supabase +DIR="$ROOT/$PROJECT" +DB_DOMAIN=${DB_DOMAIN:-dev2.profullstack.com} +PG_UID=100; PG_GID=101 +ALLOW_IPS=${ALLOW_IPS:-67.205.189.229} + +log() { printf '\n===> %s\n' "$*" >&2; } +warn() { printf 'WARNING: %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +backup() { local f=$1 n=1; [ -e "$f" ] || return 0; while [ -e "$f.bak-$(printf %03d $n)" ]; do n=$((n+1)); done; cp -a "$f" "$f.bak-$(printf %03d $n)"; } +set_env() { local k=$1 v=$2; if grep -q "^$k=" "$DIR/.env"; then awk -v k="$k" -v v="$v" 'BEGIN{FS="="} $1==k {print k "=" v; next} {print}' "$DIR/.env" > "$DIR/.env.tmp" && mv "$DIR/.env.tmp" "$DIR/.env"; else printf '%s=%s\n' "$k" "$v" >> "$DIR/.env"; fi; } +get_env() { grep "^$1=" "$DIR/.env" | head -n1 | cut -d= -f2-; } +compose() { (cd "$DIR" && docker compose "$@"); } +DBC="$SLUG-supabase-db" +sql_admin() { docker exec -i "$DBC" psql -U supabase_admin -h localhost -d postgres -v ON_ERROR_STOP=1 -X -q -At "$@"; } + +# ------------------------------------------------------------ 1. the files +install -d -m 2750 -o root -g root "$ROOT" 2>/dev/null || true +if [ -f "$DIR/.env" ] && [ -f "$DIR/docker-compose.yml" ]; then + log "Supabase project already at $DIR; keeping its secrets" +else + log "Supabase $SUPABASE_REF into $DIR" + tmp=$(mktemp -d) + curl -fsSL "https://raw.githubusercontent.com/supabase/supabase/$SUPABASE_REF/docker/setup.sh" -o "$tmp/setup.sh" + slog="$ROOT/$PROJECT-setup.log"; (umask 077 && : > "$slog") + if ! (cd "$ROOT" && bash "$tmp/setup.sh" --ref "$SUPABASE_REF" -p "$PROJECT" -y --skip-deps) >> "$slog" 2>&1; then + grep -E '^(===>|ERROR|WARNING)' "$slog" | tail -n 20 >&2 + die "Supabase setup.sh failed; log (contains secrets): $slog" + fi + rm -rf "$tmp" +fi +chown root:root "$DIR"; chmod 2750 "$DIR" + +log "Configuring .env" +backup "$DIR/.env" +set_env SUPABASE_PUBLIC_URL "https://$STUDIO_DOMAIN" +set_env API_EXTERNAL_URL "https://$STUDIO_DOMAIN" +set_env SITE_URL "$SITE_URL" +set_env ADDITIONAL_REDIRECT_URLS "${SITE_URL}/**,https://www.${SITE}/**,http://localhost:3000/**" +set_env POOLER_TENANT_ID "$SLUG" +set_env STUDIO_DEFAULT_ORGANIZATION "Profullstack" +set_env STUDIO_DEFAULT_PROJECT "$SITE" +set_env API_GW_HTTP_PORT "$API_PORT" +set_env KONG_HTTP_PORT "$API_PORT" +set_env POSTGRES_PORT 5432 +set_env POOLER_PROXY_PORT_TRANSACTION "$POOLER_PORT" +set_env DISABLE_SIGNUP false +set_env ENABLE_EMAIL_SIGNUP true +set_env ENABLE_EMAIL_AUTOCONFIRM false +if [ -n "${SMTP_PASS:-}" ]; then + set_env SMTP_HOST smtp.resend.com; set_env SMTP_PORT 465; set_env SMTP_USER resend; set_env SMTP_PASS "$SMTP_PASS" + set_env SMTP_SENDER_NAME "${SMTP_SENDER:-$SITE}"; set_env SMTP_ADMIN_EMAIL "${SMTP_ADMIN:-noreply@$SITE}" +else + warn "SMTP_PASS not set; GoTrue cannot send mail until it is" +fi +set_env COMPOSE_FILE "docker-compose.yml:docker-compose.$SLUG.yml" +set_env COMPOSE_PROJECT_NAME "$SLUG-supabase" +chmod 600 "$DIR/.env" + +# ------------------------------------------------------------- 2. postgres +V="$DIR/volumes/$SLUG"; mkdir -p "$V/tls" +if [ ! -s "$V/tls/server.key" ]; then + log "Self-signed TLS for $DB_DOMAIN" + openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 3650 -subj "/CN=$DB_DOMAIN" -addext "subjectAltName=DNS:$DB_DOMAIN" -keyout "$V/tls/server.key" -out "$V/tls/server.crt" 2>/dev/null +fi +cat > "$V/pg_hba.conf" < "$V/$SLUG.conf" < "$DIR/docker-compose.$SLUG.yml" + +# ---------------------------------------------------------------- 3. start +log "Starting $SLUG-supabase" +compose up -d --wait 2>/dev/null || compose up -d +compose restart db >/dev/null +for i in $(seq 1 90); do docker exec "$DBC" pg_isready -U postgres -h localhost >/dev/null 2>&1 && break; sleep 2; done +docker exec "$DBC" pg_isready -U postgres -h localhost >/dev/null || die "$DBC never became ready" + +log "Repairing the v0.8.x bootstrap gaps (role passwords, auth/storage owners, graphql_public, _realtime, _supabase)" +pw=$(get_env POSTGRES_PASSWORD) +sql_admin </dev/null 2>&1 || true + +log "Extensions" +sql_admin <<'SQL' +create extension if not exists "uuid-ossp" with schema extensions; +create extension if not exists pgcrypto with schema extensions; +create extension if not exists pg_stat_statements with schema extensions; +create extension if not exists pg_net with schema extensions; +create extension if not exists pg_cron; +grant usage on schema cron to postgres; +grant all privileges on all tables in schema cron to postgres; +SQL +for e in ${EXTENSIONS:-}; do sql_admin -c "create extension if not exists \"$e\" cascade" 2>/dev/null || warn "extension $e not available in this image"; done + +log "Firewall: $DB_PORT reachable from $ALLOW_IPS, loopback and the docker networks only" +while iptables -D DOCKER-USER -p tcp --dport "$DB_PORT" -j DROP 2>/dev/null; do :; done +for ip in $ALLOW_IPS 127.0.0.1 172.16.0.0/12; do while iptables -D DOCKER-USER -s "$ip" -p tcp --dport "$DB_PORT" -j ACCEPT 2>/dev/null; do :; done; done +iptables -I DOCKER-USER 1 -p tcp --dport "$DB_PORT" -j DROP +iptables -I DOCKER-USER 1 -s 172.16.0.0/12 -p tcp --dport "$DB_PORT" -j ACCEPT +iptables -I DOCKER-USER 1 -s 127.0.0.1 -p tcp --dport "$DB_PORT" -j ACCEPT +for ip in $ALLOW_IPS; do iptables -I DOCKER-USER 1 -s "$ip" -p tcp --dport "$DB_PORT" -j ACCEPT; done +mkdir -p /etc/iptables && iptables-save > /etc/iptables/rules.v4 + +log "Checks" +sql_admin -c "select 'ssl='||current_setting('ssl')||' shared_buffers='||current_setting('shared_buffers')||' hba='||current_setting('hba_file')||' version='||current_setting('server_version')" +[ "$(sql_admin -c "select has_schema_privilege('anon','public','usage')")" = t ] || die "anon lost USAGE on public" +for i in $(seq 1 30); do code=$(curl -s -o /dev/null -w '%{http_code}' -H "apikey: $(get_env ANON_KEY)" "http://127.0.0.1:$API_PORT/rest/v1/" || true); [ "$code" = 200 ] && break; sleep 3; done +echo "gateway http://127.0.0.1:$API_PORT/rest/v1/ -> $code" +(umask 077; cat > "$DIR/$SLUG-connection.env" <.supabase.co \ +// CLOUD_SERVICE_KEY=... \ +// SELF_URL=https://supabase.crawlproof.com \ +// SELF_SERVICE_KEY=... \ +// node ops/selfhost/migrate/sync-storage.mjs [--concurrency 8] [--dry-run] +// +// Resumable: an object already present in the destination with the same size +// is skipped, so a re-run after a failure only moves what is missing. + +import { readFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const args = process.argv.slice(2); +const dump = args.find((a) => !a.startsWith('--')); +const dryRun = args.includes('--dry-run'); +const concurrency = Number( + (args.find((a) => a.startsWith('--concurrency')) || '--concurrency=8').split('=')[1] || + args[args.indexOf('--concurrency') + 1] || + 8, +); + +const CLOUD_URL = (process.env.CLOUD_URL || '').replace(/\/$/, ''); +const CLOUD_SERVICE_KEY = process.env.CLOUD_SERVICE_KEY || ''; +const SELF_URL = (process.env.SELF_URL || '').replace(/\/$/, ''); +const SELF_SERVICE_KEY = process.env.SELF_SERVICE_KEY || ''; + +if (!dump) die('usage: sync-storage.mjs '); +for (const [k, v] of Object.entries({ CLOUD_URL, CLOUD_SERVICE_KEY, SELF_URL, SELF_SERVICE_KEY })) { + if (!v) die(`missing env: ${k}`); +} + +function die(msg) { + console.error(`ERROR: ${msg}`); + process.exit(1); +} + +const inventory = readFileSync(join(dump, 'storage-inventory.tsv'), 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const [bucket, isPublic, name, size, mime] = line.split('\t'); + return { bucket, public: isPublic === 't', name, size: Number(size || 0), mime }; + }); + +const failLog = join(dump, 'storage-sync-failures.log'); +const totalBytes = inventory.reduce((n, o) => n + o.size, 0); +console.log( + `${inventory.length} objects, ${(totalBytes / 1024 ** 3).toFixed(2)} GB, concurrency ${concurrency}${dryRun ? ' (dry run)' : ''}`, +); + +let done = 0; +let skipped = 0; +let failed = 0; +let movedBytes = 0; +const started = Date.now(); + +async function headSelf(o) { + // A HEAD through the authenticated object path works for public and private + // buckets alike, so resume does not depend on the bucket being public. + const res = await fetch(`${SELF_URL}/storage/v1/object/${o.bucket}/${encodeURI(o.name)}`, { + method: 'HEAD', + headers: { Authorization: `Bearer ${SELF_SERVICE_KEY}` }, + }); + if (!res.ok) return null; + return Number(res.headers.get('content-length') || 0); +} + +async function downloadCloud(o) { + const res = await fetch(`${CLOUD_URL}/storage/v1/object/${o.bucket}/${encodeURI(o.name)}`, { + headers: { Authorization: `Bearer ${CLOUD_SERVICE_KEY}` }, + }); + if (!res.ok) throw new Error(`download ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +async function uploadSelf(o, body) { + // x-upsert makes a re-run idempotent instead of 409-ing on what is already there. + const res = await fetch(`${SELF_URL}/storage/v1/object/${o.bucket}/${encodeURI(o.name)}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${SELF_SERVICE_KEY}`, + 'Content-Type': o.mime || 'application/octet-stream', + 'x-upsert': 'true', + }, + body, + }); + if (!res.ok) throw new Error(`upload ${res.status} ${(await res.text()).slice(0, 200)}`); +} + +async function one(o) { + try { + const existing = await headSelf(o); + if (existing !== null && existing === o.size && o.size > 0) { + skipped += 1; + return; + } + if (dryRun) return; + const body = await downloadCloud(o); + await uploadSelf(o, body); + movedBytes += o.size; + } catch (err) { + failed += 1; + appendFileSync(failLog, `${o.bucket}\t${o.name}\t${err.message}\n`); + } finally { + done += 1; + if (done % 100 === 0 || done === inventory.length) { + const secs = (Date.now() - started) / 1000; + console.log( + `${done}/${inventory.length} moved=${(movedBytes / 1024 ** 3).toFixed(2)}GB skipped=${skipped} failed=${failed} ${(done / secs).toFixed(1)}/s`, + ); + } + } +} + +// Fixed pool of workers pulling from one shared cursor — a Promise.all over +// chunks would stall every worker on the slowest object in each chunk. +let cursor = 0; +async function worker() { + while (cursor < inventory.length) { + const o = inventory[cursor++]; + await one(o); + } +} +await Promise.all(Array.from({ length: concurrency }, worker)); + +console.log( + `\ndone: ${done} objects, ${(movedBytes / 1024 ** 3).toFixed(2)} GB moved, ${skipped} already present, ${failed} failed`, +); +if (failed) { + console.log(`failures logged to ${failLog} — re-run to retry just those`); + process.exit(1); +} From e194cad5c07df9f9c1f48afa3b512fe64d40fde0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:42:10 +0000 Subject: [PATCH 004/104] dev2-site: write remote files via temp + mv Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 90797 -> 90835 bytes dev2/dev2-site | 7 ++++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index e493dd13b5ca67761db5a29ef0b4e5454e6e67a8..ac7b24ff7f752aac6272899ed91dc0681cf67e6e 100644 GIT binary patch delta 9128 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 diff --git a/dev2/dev2-site b/dev2/dev2-site index 6df9e6b..395b898 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -77,9 +77,10 @@ def ssh(user, cmd, input=None, check=True, timeout=None): input=input, check=check, timeout=timeout) def ssh_put(content, path, mode="0644", owner=None, user="root"): - """Write a file on dev2 from stdin (no scp, no temp file on the box).""" - own = f"-o {owner.split(':')[0]} -g {owner.split(':')[-1]} " if owner else "" - ssh(user, f"install -m {mode} {own}/dev/stdin {shlex.quote(path)}", input=content) + """Write a file on dev2 from stdin: temp file beside the target, then an atomic move.""" + q = shlex.quote(path) + chown = f" && chown {shlex.quote(owner)} {q}.tmp" if owner else "" + ssh(user, f"umask 077 && cat > {q}.tmp && chmod {mode} {q}.tmp{chown} && mv -f {q}.tmp {q}", input=content) def state_path(site): return os.path.join(STATE_DIR, f"{site}.json") def load_state(site): From 56ee9c5505b744a3b83fa42fc2aa0a7151c36d03 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:43:33 +0000 Subject: [PATCH 005/104] dev2-site: per-site self-hosted Supabase (stack, pull, load, storage, cutover) + proxied gateway hosts Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 90835 -> 113530 bytes dev2/dev2-site | 169 +++++++++++++++++++++- 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index ac7b24ff7f752aac6272899ed91dc0681cf67e6e..7d734058185710e362d0aa581060280910afc11f 100644 GIT binary patch delta 31599 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 delta 13014 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 UXLIJiTWl9D`^;>zWKo;{2_x>1G5`Po diff --git a/dev2/dev2-site b/dev2/dev2-site index 395b898..014b55d 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -35,6 +35,7 @@ import subprocess import sys import tempfile import time +import urllib.parse import urllib.request HERE = os.path.dirname(os.path.realpath(__file__)) @@ -654,9 +655,16 @@ def porkbun_keys(): if not (k and sk): die("no Porkbun keys in ~/.config/cli-tools/credentials.json (cli-tools config pull)") return k, sk +def site_domains(s): + """Every hostname the site answers on: custom domains, www of an apex, and extra + proxied hosts (the Supabase gateway).""" + extra = [p["host"] for p in s.get("proxies") or []] + www = [f"www.{s['site']}"] if s["site"].count(".") == 1 else [] + return list(dict.fromkeys(s["domains"] + www + extra)) + def cmd_cert(args): s = site(args.site) - domains = list(dict.fromkeys(s["domains"] + ([f"www.{s['site']}"] if s["site"].count(".") == 1 and f"www.{s['site']}" not in s["domains"] else []))) + domains = site_domains(s) k, sk = porkbun_keys() cert_dir = f"/etc/nginx/ssl/{s['site']}" log(f"Certificate for {', '.join(domains)} (acme.sh, Porkbun DNS-01, issued before DNS moves)") @@ -670,9 +678,15 @@ def cmd_cert(args): def cmd_vhost(args): s = site(args.site) - domains = list(dict.fromkeys(s["domains"] + ([f"www.{s['site']}"] if s["site"].count(".") == 1 else []))) + proxies = s.get("proxies") or [] + domains = [d for d in site_domains(s) if d not in {p["host"] for p in proxies}] ssh("root", f"test -s /etc/nginx/ssl/{s['site']}/fullchain.pem") if not args.no_cert_check else None conf = tmpl("nginx-vhost.conf").replace("__SITE__", s["site"]).replace("__APP_PORT__", str(s["port"])).replace("__SERVER_NAMES__", " ".join(domains)).replace("__MAX_BODY__", s.get("max_body", "64m")) + for p in proxies: # one more server block per proxied host, same certificate + blk = tmpl("nginx-vhost.conf").split("server {", 2)[2] # the 443 block only + blk = "server {" + blk.replace("__SITE__", s["site"]).replace("__APP_PORT__", str(p["port"])).replace("__SERVER_NAMES__", p["host"]).replace("__MAX_BODY__", p.get("max_body", "512m")).replace("proxy_read_timeout 300s;", "proxy_read_timeout 3600s;\n proxy_send_timeout 3600s;\n large_client_header_buffers 8 64k;") + conf += "\n# " + p["host"] + " -> 127.0.0.1:" + str(p["port"]) + "\n" + blk + conf += "\nserver {\n listen 80;\n listen [::]:80;\n server_name " + p["host"] + ";\n return 301 https://$host$request_uri;\n}\n" log(f"nginx vhost {s['site']} -> 127.0.0.1:{s['port']} ({', '.join(domains)})") ssh_put(conf, f"/tmp/dev2-site-{s['site']}.conf", "0644") ssh("root", f"f=/etc/nginx/sites-available/{s['site']}; if [ -e $f ]; then n=1; while [ -e $f.bak-$(printf %03d $n) ]; do n=$((n+1)); done; cp -a $f $f.bak-$(printf %03d $n); fi; install -m 0644 /tmp/dev2-site-{s['site']}.conf $f && ln -sf $f /etc/nginx/sites-enabled/{s['site']} && nginx -t 2>&1 | tail -n1 && systemctl reload nginx") @@ -693,7 +707,7 @@ def cmd_verify(args): def cmd_dns(args): s = site(args.site) - domains = list(dict.fromkeys(s["domains"] + ([f"www.{s['site']}"] if s["site"].count(".") == 1 else []))) + domains = site_domains(s) zones = {} for d in domains: zone = ".".join(d.split(".")[-2:]); zones.setdefault(zone, []).append(d) @@ -782,6 +796,150 @@ def cmd_retire(args): except RuntimeError as e: warn(str(e)[:200]) mark(s["site"], "retire") +# --------------------------------------------------------------- supabase +# Second pass for db kind `supabase`: a self-hosted stack per site beside the app, +# the cloud project dumped into it, storage objects copied, the app re-pointed. + +SB_API_TOKEN = os.path.expanduser("~/.supabase/access-token") + +def sb_api(path, method="GET", body=None): + tok = open(SB_API_TOKEN).read().strip() + req = urllib.request.Request(f"https://api.supabase.com/v1/{path}", method=method, data=json.dumps(body).encode() if body is not None else None, + headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=60) as r: return json.load(r) + +def sb_ports(s): + n = s["port"] - FIRST_PORT + return {"api": 8200 + n, "db": 5500 + n, "pooler": 6600 + n, "subnet": f"172.31.{100 + n}.0/24", "gateway": f"172.31.{100 + n}.1"} + +def sb_slug(s): return re.sub(r"[^a-z0-9]+", "-", s["site"].lower()).strip("-") + +def sb_cloud(s, st): + """Cloud project ref + db password + pooler host, from the app's Railway variables and the management API.""" + vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + refs = {m.group(1) for v in vars_.values() if isinstance(v, str) for m in re.finditer(r"https?://([a-z]{20})\.supabase\.co", v)} + refs |= {v for k, v in vars_.items() if k in ("SUPABASE_DB_REF", "SUPABASE_PROJECT_REF", "SUPABASE_REF") and isinstance(v, str) and re.fullmatch(r"[a-z]{20}", v)} + ref = s.get("cloud_ref") or st.get("cloud_ref") + if not ref: + if len(refs) != 1: die(f"could not pick one Supabase project ref from the app's variables ({sorted(refs)}); set cloud_ref in sites.d/{s['site']}.json") + ref = refs.pop() + pw = st.get("cloud_db_password") or vars_.get("SUPABASE_DB_PASSWORD") + m = next((re.match(r"postgres(?:ql)?://([^:]+):([^@]+)@", v) for v in vars_.values() if isinstance(v, str) and "supabase.co" in v and "postgres" in v), None) + if not pw and m: pw = urllib.parse.unquote(m.group(2)) if hasattr(urllib, "parse") else m.group(2) + if not pw: + if not args_yes_global: die(f"no SUPABASE_DB_PASSWORD for {ref}; re-run with --reset-password to set a new one through the management API (apps use API keys, not this password, unless a variable carries it)") + pw = secrets.token_urlsafe(24) + sb_api(f"projects/{ref}/database/password", "PATCH", {"password": pw}); note("cloud database password reset through the management API") + pooler = sb_api(f"projects/{ref}/config/database/pooler") + prim = next((p for p in pooler if p.get("database_type") == "PRIMARY"), pooler[0]) + host = prim["db_host"] + keys = {k["id"]: k["api_key"] for k in sb_api(f"projects/{ref}/api-keys?reveal=true") if k.get("type") == "legacy"} + import urllib.parse as up + url = f"postgres://postgres.{ref}:{up.quote(pw, safe='')}@{host}:5432/postgres" + st.update({"cloud_ref": ref, "cloud_db_password": pw, "cloud_db_url": url, "cloud_anon_key": keys.get("anon"), "cloud_service_key": keys.get("service_role")}) + save_state(s["site"], st) + return vars_ + +args_yes_global = False + +def cmd_supabase_stack(args): + s = site(args.site); st = load_state(s["site"]) + if s["db"] != "supabase": die(f"{s['site']} db kind is {s['db']}") + slug, ports = sb_slug(s), sb_ports(s) + host = f"supabase.{s['site']}" + log(f"Self-hosted Supabase for {s['site']}: {slug}-supabase, api :{ports['api']}, db :{ports['db']}, pooler :{ports['pooler']}, {ports['subnet']}") + creds = json.load(open(os.path.expanduser("~/.config/cli-tools/credentials.json"))) + smtp = creds.get("RESEND_API_KEY"); smtp = smtp.get("value") if isinstance(smtp, dict) else smtp + vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + smtp = vars_.get("RESEND_API_KEY") or smtp or "" + ssh("root", "install -d -m 700 /root/dev2-kit") + ssh_put(tmpl("supabase-stack.sh"), "/root/dev2-kit/supabase-stack.sh", "0700") + env = f"SITE={s['site']} SLUG={slug} ROOT={WWW}/{s['site']} STUDIO_DOMAIN={host} SITE_URL=https://{s['site']} API_PORT={ports['api']} DB_PORT={ports['db']} POOLER_PORT={ports['pooler']} SUBNET={ports['subnet']} GATEWAY={ports['gateway']} SMTP_PASS={shlex.quote(smtp)} EXTENSIONS={shlex.quote(' '.join(s.get('extensions') or []))}" + out = ssh("root", f"env {env} bash /root/dev2-kit/supabase-stack.sh 2>&1", timeout=3600) + print("\n".join(l for l in out.splitlines() if l.startswith("===>") or "gateway" in l or "WARNING" in l or "ERROR" in l)) + conn = ssh("root", f"cat {WWW}/{s['site']}/supabase/{slug}-connection.env") + kv = dict(l.split("=", 1) for l in conn.splitlines() if "=" in l and not l.startswith("#")) + st.update({"sb_slug": slug, "sb_ports": ports, "sb_host": host, "sb_anon_key": kv["SELFHOST_ANON_KEY"], "sb_service_key": kv["SELFHOST_SERVICE_ROLE_KEY"], + "sb_jwt_secret": kv["SELFHOST_JWT_SECRET"], "sb_pg_password": kv["SELFHOST_POSTGRES_PASSWORD"], "sb_database_url": kv["SELFHOST_DATABASE_URL"]}) + save_state(s["site"], st) + # The gateway hostname joins the site: cert, vhost and dns pick it up from sites.d. + os.makedirs(SITES_D, exist_ok=True) + sd_path = os.path.join(SITES_D, f"{s['site']}.json") + sd = json.load(open(sd_path)) if os.path.exists(sd_path) else {} + sd["proxies"] = [p for p in sd.get("proxies", []) if p.get("host") != host] + [{"host": host, "port": ports["api"], "max_body": "512m"}] + with open(sd_path, "w") as f: json.dump(sd, f, indent=1); f.write("\n") + mark(s["site"], "supabase-stack") + log(f"Stack up. Next: `dev2-site cert {s['site']}` + `vhost` + `dns` (adds {host}), then supabase-pull / supabase-load / supabase-storage / supabase-cutover") + +def cmd_supabase_pull(args): + global args_yes_global; args_yes_global = getattr(args, "reset_password", False) + s = site(args.site); st = load_state(s["site"]) + sb_cloud(s, st); st = load_state(s["site"]) + out = f"/root/dumps/{sb_slug(s)}-supabase-{dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}" + log(f"Dumping cloud project {st['cloud_ref']} -> {DEV2_HOST}:{out}") + ssh("root", "install -d -m 700 /root/dev2-kit /root/dumps") + ssh_put(tmpl("supabase-pull.sh"), "/root/dev2-kit/supabase-pull.sh", "0700") + res = ssh("root", f"env CLOUD_DB_URL={shlex.quote(st['cloud_db_url'])} OUT={out} EXCLUDE_TABLE_DATA={shlex.quote(' '.join('--exclude-table-data=' + t for t in (s.get('exclude_table_data') or [])))} bash /root/dev2-kit/supabase-pull.sh 2>&1", timeout=6 * 3600) + print("\n".join(l for l in res.splitlines() if l.startswith("===>") or "=" in l[:20] or "ERROR" in l or "WARN" in l)[-3000:]) + mark(s["site"], "supabase-pull", sb_dump=out) + +def cmd_supabase_load(args): + s = site(args.site); st = load_state(s["site"]) + for k in ("sb_dump", "sb_slug", "cloud_ref", "sb_host"): + if not st.get(k): die(f"state has no {k}: run supabase-stack and supabase-pull first") + log(f"Loading {st['sb_dump']} into {st['sb_slug']}-supabase-db") + ssh_put(tmpl("supabase-load.sh"), "/root/dev2-kit/supabase-load.sh", "0700") + res = ssh("root", f"env DUMP={st['sb_dump']} DBC={st['sb_slug']}-supabase-db CLOUD_REF={st['cloud_ref']} NEW_HOST={st['sb_host']} POST_ONLY={'1' if args.post_only else '0'} bash /root/dev2-kit/supabase-load.sh 2>&1", timeout=6 * 3600) + print(res[-6000:]) + mark(s["site"], "supabase-load") + +def cmd_supabase_storage(args): + s = site(args.site); st = load_state(s["site"]) + for k in ("sb_dump", "cloud_ref", "cloud_service_key", "sb_service_key", "sb_ports"): + if not st.get(k): die(f"state has no {k}") + log(f"Copying storage objects from {st['cloud_ref']} into the {st['sb_slug']} stack (node in a container on dev2)") + ssh_put(tmpl("sync-storage.mjs"), "/root/dev2-kit/sync-storage.mjs", "0600") + # The self-hosted gateway is reached on loopback (no TLS needed from the box itself). + env = f"-e CLOUD_URL=https://{st['cloud_ref']}.supabase.co -e CLOUD_SERVICE_KEY={shlex.quote(st['cloud_service_key'])} -e SELF_URL=http://127.0.0.1:{st['sb_ports']['api']} -e SELF_SERVICE_KEY={shlex.quote(st['sb_service_key'])}" + res = ssh("root", f"docker run --rm --network host {env} -v {st['sb_dump']}:/dump -v /root/dev2-kit/sync-storage.mjs:/sync.mjs:ro node:22-alpine node /sync.mjs /dump --concurrency {args.concurrency} 2>&1 | tail -n 40", timeout=12 * 3600) + print(res) + mark(s["site"], "supabase-storage") + +def cmd_supabase_cutover(args): + """Re-point the app at the self-hosted stack: env overrides, provision, deploy, verify, + then unschedule the cloud project's cron jobs (they would otherwise keep calling the site).""" + s = site(args.site); st = load_state(s["site"]) + for k in ("cloud_ref", "sb_host", "sb_anon_key", "sb_service_key", "sb_database_url", "sb_pg_password"): + if not st.get(k): die(f"state has no {k}") + vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + ov = {} + for k, v in vars_.items(): + if not isinstance(v, str): continue + nv = v + if f"{st['cloud_ref']}.supabase.co" in nv: + nv = re.sub(rf"https?://{st['cloud_ref']}\.supabase\.co", f"https://{st['sb_host']}", nv).replace(f"{st['cloud_ref']}.supabase.co", st["sb_host"]) + if nv.startswith("postgres"): nv = st["sb_database_url"] + if st.get("cloud_anon_key") and v == st["cloud_anon_key"]: nv = st["sb_anon_key"] + if st.get("cloud_service_key") and v == st["cloud_service_key"]: nv = st["sb_service_key"] + if k == "SUPABASE_DB_PASSWORD": nv = st["sb_pg_password"] + if k in ("SUPABASE_JWT_SECRET", "JWT_SECRET") and st.get("sb_jwt_secret") and len(v) > 20: nv = st["sb_jwt_secret"] + if k in ("SUPABASE_DB_REF", "SUPABASE_PROJECT_REF") and v == st["cloud_ref"]: warn(f"{k}={v}: the app derives URLs from the ref; it needs a code change to use SUPABASE_URL") + if nv != v: ov[k] = nv + sd_path = os.path.join(SITES_D, f"{s['site']}.json") + sd = json.load(open(sd_path)) if os.path.exists(sd_path) else {} + sd["env_overrides"] = {**sd.get("env_overrides", {}), **ov} + with open(sd_path, "w") as f: json.dump(sd, f, indent=1); f.write("\n") + log(f"{len(ov)} variables re-pointed in sites.d/{s['site']}.json: {', '.join(sorted(ov))}") + cmd_provision(argparse.Namespace(site=s["site"])) + cmd_deploy(argparse.Namespace(site=s["site"], ref=None)) + cmd_verify(argparse.Namespace(site=s["site"], public=("dns" in load_state(s["site"])["steps"]))) + if st.get("cloud_db_url"): + log("Unscheduling the CLOUD project's cron jobs") + out = ssh("root", f"docker run --rm -i --network host postgres:17 psql {shlex.quote(st['cloud_db_url'])} -At -X -c \"select cron.unschedule(jobname) from cron.job where active\" 2>&1 | tail -n 5", check=False) + note(out.strip() or "none") + mark(s["site"], "supabase-cutover") + log(f"{s['site']} now runs on its own Supabase. Cloud project {st['cloud_ref']} stays until `retire` (delete it in the dashboard or with the management API).") + # ----------------------------------------------------------------- main def main(): @@ -798,6 +956,11 @@ def main(): p = sub.add_parser("verify"); p.add_argument("site"); p.add_argument("--public", action="store_true"); p.set_defaults(fn=cmd_verify) p = sub.add_parser("refresh-db"); p.add_argument("site"); p.set_defaults(fn=cmd_refresh_db) p = sub.add_parser("migrate"); p.add_argument("site"); p.add_argument("--yes", action="store_true"); p.add_argument("--redo", action="store_true"); p.set_defaults(fn=cmd_migrate) + for name, fn in (("supabase-stack", cmd_supabase_stack), ("supabase-cutover", cmd_supabase_cutover)): + p = sub.add_parser(name); p.add_argument("site"); p.set_defaults(fn=fn) + p = sub.add_parser("supabase-pull"); p.add_argument("site"); p.add_argument("--reset-password", action="store_true"); p.set_defaults(fn=cmd_supabase_pull) + p = sub.add_parser("supabase-load"); p.add_argument("site"); p.add_argument("--post-only", action="store_true"); p.set_defaults(fn=cmd_supabase_load) + p = sub.add_parser("supabase-storage"); p.add_argument("site"); p.add_argument("--concurrency", type=int, default=8); p.set_defaults(fn=cmd_supabase_storage) p = sub.add_parser("retire"); p.add_argument("site"); p.add_argument("--yes", action="store_true"); p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_retire) args = ap.parse_args() try: args.fn(args) From 599e60df850ecef16e54ed4748de57a1a317d325 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:45:27 +0000 Subject: [PATCH 006/104] dev2: README for the Supabase second pass and private repos Co-Authored-By: Claude Fable 5.1 --- dev2/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dev2/README.md b/dev2/README.md index b5c6159..158ea9e 100644 --- a/dev2/README.md +++ b/dev2/README.md @@ -32,6 +32,11 @@ dev2-site migrate --yes the whole move, resumable; each step below is a | `railway-stop` | `deploymentRemove` + `serviceDisconnect` (or the next push silently redeploys on Railway) | | `merge` | merges the scaffold PR; CI deploys the default branch to dev2 | | `retire` | `--yes`: `serviceDelete` for the app, companions and data services. Only after `verify --public` | +| `supabase-stack` | (kind `supabase`, second pass) a self-hosted Supabase stack of its own under `~/www//supabase` (compose project `-supabase`, api `8200+n`, db `5500+n`, pooler `6600+n`, subnet `172.31.(100+n).0/24`, `n = port-3200`), public host `supabase.` added to the site's cert/vhost/dns via `sites.d` | +| `supabase-pull` | dumps the cloud project through its session pooler (password from the app's `SUPABASE_DB_PASSWORD`, or `--reset-password` through the management API) into `/root/dumps/-supabase-` | +| `supabase-load` | loads it (extensions, schema, data, grants, buckets, absolute storage-URL rewrite over every text/json column, realtime, cron) | +| `supabase-storage` | copies every Storage object through the two Storage APIs (node in a container on dev2; resumable) | +| `supabase-cutover` | re-points the app (`env_overrides`: URLs, keys, db password), provision + deploy + verify, unschedules the cloud cron jobs | ## Layout on the box (every site the same) @@ -47,6 +52,11 @@ dev2-site migrate --yes the whole move, resumable; each step below is a └── .deploy-state ``` +The box clones over ssh with a **read-only GitHub deploy key** (the same per-repo +key CI uses to reach dev2), under an alias `github.com-__` in +`/home/anthony/.ssh/config`; https cloning fails for private repos because the +deploy account has no GitHub credential. + Ports are allocated once in `sites.json` from 3200 upwards (3010 nichedb, 3020 rssamplifier, 3100 crawlproof predate the kit). nginx is the only public face. From 177fc383d2ff83d952cdb9adf1ee0a115ee524cf Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:45:47 +0000 Subject: [PATCH 007/104] dev2-site: public verify waits out resolver caches Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 113530 -> 113742 bytes dev2/dev2-site | 7 +++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 7d734058185710e362d0aa581060280910afc11f..9ee4030ab345d5b64e14010faeb836b8b53e59f0 100644 GIT binary patch delta 2099 zcmZ8h4RBOf6@K5_-N*i9H$O`f5|XeKDr{gWkhSeJKN6C(G=xAx0+|*Gux|;GWS8Fk zfh=KCTPktVN>2y6kc?6Y{x3pI=byRRn|_O&ZxCBbsE4*1)-kvXheN8 z``vTT`Sv^a+D)$I}Jjx%5dMX6Ulzk_IV;%;+FpgVP!o~65WkDjgP z=(%0?e(famynfrb(r+18j$1~cd(X~2qskR)uxNq3->xgVqgc`Nlga_atWd~PkW>yi z`W>;X(Uy3DJ*Gm56vlH$TZT$TKbdxZ7Rs}`tV+Liz>)G$dTTs?HvMcGecph5T+xgA z9YWQMY02})M>oX_%VVluLbFm@$eh_CNbNONi@D;3vpRa2yf2?Bukxd;bio*c5!C@h%_zEpX#@@uVAwQm*PKlsSo-u&U@ zSYlE?wSIc;bo}MEOEu|&1L^?Mpp&4!SyPYa*@mVi7L}Y zC^r5YL|W4>kt=oI^HnI2dn@5I-l;@6Y;ThHUBg|C2@N;pxjLM%UMAP*l`UR*v<{{C zmwd7w3-JeevK}|_7kQ!q_aY--ZNOsN2gJN;{EbX4|18;}mmx{lU%bKApXFzoa2n@i zx(P2W=WP}d2411y%z_m%+V1Y2cvy%J*<&8*jC^qePT8)L!)9!1MyCzGkW-tn7in42 zh6R|BRc#2lZV;i+LZyD7@X7g%U$)_vir>mhTd|dv?(4ux+ifB(HQGDyeb}r-USYT& zLl8EN?A6A@k7L@78sn1?A}ShWWH&l+L0;UAUd=@ektHAM!t=Y6sT*LT*h?QCytsuuHQ=rCSJ z!dUza@=&^pH9w;LZx-+ILMmvUv2LwxZ5Ba`d?JY=dHXP(Us4|X68a17Vfx)(<6?wn zk+&+aP&ORFYPHTHUvbi(jvc{rTN{OYjh`Js!jjW^he~{NkE-OlA*}bXH>LTyBk`z@ zn)QVtG+>2%ZwSFWrlp*|=7z1E>o#s~s}@x<@GPdSo5?T7kD$!i6Wytc$7tGTi~l&1~^KVb1vNVH`)PTsMNnu*%*M_)sIiGJ=(8HGVn* z8?aga{20nz+eznX`o#v!mM=5rjiSv_aie7jdMR4};Y#5b_S zUuBYb%T0O-PfdxtOLvR delta 1932 zcmZ8heNa?Y6o0?R?qk`N#a zutAvV*dV4&O{R@$nUBeTv>f}xvdw177^`ute;QN#D)J+{=OJVDX72Bvd(OG%ciz3{ z+;jcfoj)|^+1OaSBELC>$Jd{|v(@>D6Sd>)4ylQfZ?Sb}kq-24Uu=}DpJE=3;^vI$im z>4To!rO@>(jLg+jRQdgSAV!L=!A}fjgWX%FuWqaqHS3ziVVRsEN5BJ02qB&J zjK{1kaS83yc2C_m_JHF^c~9=iVJA075`&S7)r{T^k=nY*24BSgV#FsRYXVZ)Cyx__ z>Mccye_H)h;(YbGi8m!KRKu1FaYNe++sncmcjraoGSA1&J}~S^TEsnDT5R^PzQZ0? zFWQE*BOGX-+&-4VImp%=iZkW<5ThptuSCNzrsrZ6te?qTPaAutqEEvaW9JN%!Wxmf zpNva0@s@^u+Bye&@jbbUFa>{6c@f6o2Wl+BUHn2~E;4a}cF)CF>u++`HRF^Nwc!V) zEb15<c zmI_stci>0WJXvFYDe|m1P20+`0q3Z%9O<}9@k`-#^h@7i?0ZFFUzf3WDekK{PkSn` z9Czqe1twbWNvBN1{Tx1ql>?7AF0DW=tQILxFj7{b$A;;~w>o?(3aPLP6*xv+RjAWq zq=&GRvl?$8mfo$#G~A<$)mWxJP${!>4j=^HzOZ|i_0y@fOVd<6dR5Z_Sz6b2e%=J97=D+2qe=J ztvCr`^tU1rV%L|s2h{h=Q!lG| zphY-;VTqz|axsRI!sTb2dgWYeDf}JV-%%!U@TNB=zt3| zXl(~3VzJTL0V_~OZ+0Tvu}a2yS&qP#hm==3Q64j8;J=t-I)h0$o26L{Pb z9HuY!VSzT6?a$DN{jvwVQf@Pr?Z@p{?P05xeTb!7xC>is9?!# {code} {'ok' if good else 'FAIL'}{' (pinned to dev2)' if not args.public else ''}") if not ok: die("verify failed") From b918330d083b75bc225d51ee05b426bf9cf6f29f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:50:39 +0000 Subject: [PATCH 008/104] dev2-site firewall: allow every Docker address pool on the data ports With dozens of compose networks Docker moves to 192.168.x/20 pools, which an allowlist of 172.16.0.0/12 alone drops (CONNECT_TIMEOUT to :5432 from the app). Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 113742 -> 115277 bytes dev2/dev2-site | 13 +++++++++++++ dev2/sites.d/coinpayportal.com.json | 1 + dev2/sites.d/threatcrush.com.json | 9 +++++++++ dev2/templates/firewall-data-ports.sh | 22 ++++++++++++++++++++++ dev2/templates/supabase-stack.sh | 4 +++- 6 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 dev2/sites.d/coinpayportal.com.json create mode 100644 dev2/sites.d/threatcrush.com.json create mode 100755 dev2/templates/firewall-data-ports.sh diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 9ee4030ab345d5b64e14010faeb836b8b53e59f0..2c1ea8a5b5ac33d44d5acd594a69da90ac09cee0 100644 GIT binary patch delta 3236 zcma)82~1qa8GiqJZ*T5nx#4kpF$5hYBfam< z{Qp1y|II)1=6~-$(Y`mN3;I=Hppm2P&z09)ga0@g6b%bkGPOdC6U7WcQ#NWxvGi?4 zE)+-KIp}~=Bdm<;K&f=J8l??!O>0m(#kDAd;yQHj>ZsrW=scxK0=FV*_GwWQRXCJ~ z{K^%3rhvubxTZGLg_@}gCwhTu+tFT%F4RKt0NQ6$?rW0Lkb{n}-GkZ=tl&7T=Q%r` zTAa=S*T?tqmKU#@wI?>ml_hc9qvJ6YerlrdE`vWsQ&<~$^=6&ppQQ9K7y{1l97i|= zP8a~?Y(QxVtCPw`5g)2tm3`UO{{ax*ww`OyakJ)Paw53VRBd38i15-3#; zi46Vjfg2qZIhl#nhBR`VzuymjtJB=sB|^Z-34RNtMLP29S0E`@?>9vTWSkNf4XA0V z-}FBwnUwdABnbhoaFRR;;gOalqpYP~obutZaMH~~uu|&O51!^w;8|gcrjj*(FuEN< zmSFFeI=j`@)z#4_$xge3Y_iST<;JpP>*#RzcC>qB$>GLQQDH&8Wa~tdy~mFGr26hI zTdU1umprm<|A9=8gjyvwD+hT_|*xpOsO72bx*?aO; z$Et;d11PyqCGz7TkOsYG$5Tt|5*<&D$LUSa#csEp%jQ}Sw9C2w+w6guwWl7(tLH`Ch;}w4|BhHNFPcWHW%z^|K9o?ugCvqG2?3 z#PNpld{OCm$ymvR=hC6cL)V4tl|M88QuB7g+}hf?+--BI+hfs^b63 zai(hiF~@0YcpOQW?~AR2-GYgu=}2WM`G_N-{UG_yH9&*F^8$Ii3vx9gRTcUyyI}%M z_-{0QDir%ZY=T12n5eeY7uyUodU)P9hoF;(?WCd&exVJaDjZ6JobWnCkhh$$nimD~ z8z(e?juf;*xh9WVl==GFVZvn6G3#RHUQe>SVYLQ-N(W@98;UgeGuHh{H@pM}Qqltp z^p)KUyTIie?uBgt9pr;Ua8t*~P1|0T18HO-OE@ECD(<8MN|AFyYL&p!qo#k~~@mi+ZLPFXgl9U~Rba;h0 zjzGUTQA_0^keTI#Fo8aGx}w`{83y&hMP)>DuNjXvcRXaIJSZz_;w zYNcV0Gctou$$X^14nx7>k{N|^1}d3q)~jOB#2}A-+N&58FbE-@vyf>iW2!Q?IjVUw z8>%jr7Zt{2Rpf1I9#+ZvY%eW54rKix^7pfFiyrm&&(Y6AyP3(hkf<>z*0j_9X&LrnAb4brl%2%OO;acGlx;8BKLND`*LnBL&bXLHc3Sc*k{ zFio>y`3iHgmgh8Mx|iyaS^4aVq1ke=pEt5j=S&G?Z~~Hu`6A8Q!$}I+@|PZn|C_O; zgOUuTYXk}K2)h2Hw zuU>>TkVk%g5o(PO^!}2Lw$-cCq~pK2y+RV`V?Zm2DO5Wdx`TJtpqE`G4{&x zUAzYG!g}^YKwVN07_e%$cp<9AB##Z%+g;_fOh;wXPQEt-*~X*Ph+a;d2l@Uu1E26( nBh>JJi6-+ugvR(N$Xdwf#DEd+Z6W5qs2{Fb5I8YbEx`W*7Xm}` delta 2007 zcma)7YfKbZ6rOKpcLY|b<*~|p{`yyPEP++hStz zQFT*~{-9cIjCR3dK*lE2=%1!7wMl8xM%$#?{;-b9_2KJ#8P$H&!cY{Qn>lB5O5QvK_`adQb$tK2H5 zZXJkG53ZDRBD`l^(F0$&*h77!0xK2seOZ#G>oM!luE_t$bgUY4%@6q6Z2f9A&S2X0 z0QdTutu30zt(g=_%FuV!A`51f&*$r()Zt^`7M!tIAhdHip(fKv8`o{KQGPdUq_$&$dfnv6b~tEB zJKCYpa688M(AR;rsMgg^tOjbSVh65F5%KT<_4u90$AnbB3kfj4#P?R~``*O+0kBY7 zH#~BNLLYR)ifFpnjmy}kx9&wKV&@4jfAGq$(r$^6GP!)+R8insrDZAJpT;7obT5*z zoo?+zM?`|qewo){Ry{<>}O9vq3&ySY7jQ!L-p77GvusFl-~N08`uZV-Dm)eG{6N6>><>LDa0 z7^(+&CE6lv_Ig{Xb7fIYsnbLNo)oP?nsBSgc+WKO|` z3N1T@l+b)J7YCOrYXM>&NS&wf4Wjju53y8Mmy5MxI(izp=6XJVSO5MrZY$VDS3bgR zQ>WY^dv9(Jr#n6bnIJuq4$MUm<@6$w>=_88&wZF0!YrQfM;A=mAX8N_?Bwmm0{V`F z_vh_lG7BSHABriy4}lY&Ls*#qUi;mvEP`2kr_+gk%tR7h_94yNfA%RRku0h=+AyJS z`;ZF|H%=!m?z~eEPK?`Hz>>d-t8ElqY4vDYC{Z>QkO# zk~(2Rzn|wB-aYYHhEGf=&6DE~l%@CDeG1(;hj`=~guxllP}l!0%H$V8$pf$|QbRr! z)FDdkG6bk((%L~}V=f&WL9H( zwwu#v;=XJ5W%_Lhb?P7&+&idL$7=mk9oZ(U>3Nq`8np(x@|4l|a955pnqzk5QP40- z6t|{P)iCeRiL_G;$@xkfmXkr+YLlkXDJgSxm9 tZy=JpTEBS-_hkCv3d&=wm^0>(OjGx?jVh7XOaZ%##uUjErA2Vf{{Y)f5DEYQ diff --git a/dev2/dev2-site b/dev2/dev2-site index 0a87145..f69b92d 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -788,6 +788,18 @@ def cmd_migrate(args): mark(s["site"], "migrated") log(f"{s['site']} is on dev2. Railway services left for `dev2-site retire {s['site']} --yes`: {', '.join([s['service']] + [c['name'] for c in s['companions']] + [i['name'] for i in s['infra']])}") +def cmd_firewall(args): + """Re-apply the data-port allowlist for 5432 and every Supabase stack's db port.""" + ports = {5432} + for f in os.listdir(STATE_DIR) if os.path.isdir(STATE_DIR) else []: + if f.endswith(".json"): + st = json.load(open(os.path.join(STATE_DIR, f))) + if st.get("sb_ports"): ports.add(st["sb_ports"]["db"]) + log(f"Firewall on dev2 for ports {sorted(ports)}") + ssh("root", "install -d -m 700 /root/dev2-kit") + ssh_put(tmpl("firewall-data-ports.sh"), "/root/dev2-kit/firewall-data-ports.sh", "0700") + print(ssh("root", f"env PORTS={shlex.quote(' '.join(map(str, sorted(ports))))} bash /root/dev2-kit/firewall-data-ports.sh 2>&1")) + def cmd_retire(args): s = site(args.site); st = load_state(s["site"]) if "verify-public" not in st["steps"] and not args.force: die("site not verified public on dev2; refuse to delete Railway services (or --force)") @@ -949,6 +961,7 @@ def main(): ap = argparse.ArgumentParser(prog="dev2-site", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("registry").set_defaults(fn=cmd_registry) + sub.add_parser("firewall").set_defaults(fn=cmd_firewall) p = sub.add_parser("list"); p.add_argument("--kind"); p.add_argument("--status"); p.set_defaults(fn=cmd_list) for name, fn in (("show", cmd_show), ("scaffold", cmd_scaffold), ("provision", cmd_provision), ("volumes", cmd_volumes), ("cert", cmd_cert), ("dns", cmd_dns), ("railway-stop", cmd_railway_stop), ("merge", cmd_merge)): diff --git a/dev2/sites.d/coinpayportal.com.json b/dev2/sites.d/coinpayportal.com.json new file mode 100644 index 0000000..8114183 --- /dev/null +++ b/dev2/sites.d/coinpayportal.com.json @@ -0,0 +1 @@ +{ "health_path": "/api/health" } diff --git a/dev2/sites.d/threatcrush.com.json b/dev2/sites.d/threatcrush.com.json new file mode 100644 index 0000000..5fcf90d --- /dev/null +++ b/dev2/sites.d/threatcrush.com.json @@ -0,0 +1,9 @@ +{ + "proxies": [ + { + "host": "supabase.threatcrush.com", + "port": 8270, + "max_body": "512m" + } + ] +} diff --git a/dev2/templates/firewall-data-ports.sh b/dev2/templates/firewall-data-ports.sh new file mode 100755 index 0000000..5fdd968 --- /dev/null +++ b/dev2/templates/firewall-data-ports.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Allowlist for the data ports Docker publishes on dev2 (the shared cluster's 5432 +# and every per-site Supabase stack's 55xx): reachable from dev1, loopback and the +# box's own Docker networks only. Docker publishes ports ahead of ufw, so this lives +# in the DOCKER-USER chain. Run as root on dev2 by `dev2-site firewall`; idempotent. +# +# Docker's default address pools are 172.17-172.31.0.0/16 AND 192.168.0.0/16 (in +# /20s). With dozens of compose networks the 172.16/12 range runs out and new +# projects land in 192.168.x, which an allowlist of 172.16.0.0/12 alone drops: +# the app then sees CONNECT_TIMEOUT to dev2.profullstack.com:5432. +set -euo pipefail +PORTS=${PORTS:-5432} +ALLOW=${ALLOW:-67.205.189.229} +SOURCES="$ALLOW 127.0.0.1 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8" +for port in $PORTS; do + while iptables -D DOCKER-USER -p tcp --dport "$port" -j DROP 2>/dev/null; do :; done + for src in $SOURCES; do while iptables -D DOCKER-USER -s "$src" -p tcp --dport "$port" -j ACCEPT 2>/dev/null; do :; done; done + iptables -I DOCKER-USER 1 -p tcp --dport "$port" -j DROP + for src in $SOURCES; do iptables -I DOCKER-USER 1 -s "$src" -p tcp --dport "$port" -j ACCEPT; done +done +mkdir -p /etc/iptables && iptables-save > /etc/iptables/rules.v4 +iptables -L DOCKER-USER -n --line-numbers | head -n 40 diff --git a/dev2/templates/supabase-stack.sh b/dev2/templates/supabase-stack.sh index 4bb9440..2ca5892 100755 --- a/dev2/templates/supabase-stack.sh +++ b/dev2/templates/supabase-stack.sh @@ -209,9 +209,11 @@ for e in ${EXTENSIONS:-}; do sql_admin -c "create extension if not exists \"$e\" log "Firewall: $DB_PORT reachable from $ALLOW_IPS, loopback and the docker networks only" while iptables -D DOCKER-USER -p tcp --dport "$DB_PORT" -j DROP 2>/dev/null; do :; done -for ip in $ALLOW_IPS 127.0.0.1 172.16.0.0/12; do while iptables -D DOCKER-USER -s "$ip" -p tcp --dport "$DB_PORT" -j ACCEPT 2>/dev/null; do :; done; done +for ip in $ALLOW_IPS 127.0.0.1 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8; do while iptables -D DOCKER-USER -s "$ip" -p tcp --dport "$DB_PORT" -j ACCEPT 2>/dev/null; do :; done; done iptables -I DOCKER-USER 1 -p tcp --dport "$DB_PORT" -j DROP iptables -I DOCKER-USER 1 -s 172.16.0.0/12 -p tcp --dport "$DB_PORT" -j ACCEPT +iptables -I DOCKER-USER 1 -s 192.168.0.0/16 -p tcp --dport "$DB_PORT" -j ACCEPT +iptables -I DOCKER-USER 1 -s 10.0.0.0/8 -p tcp --dport "$DB_PORT" -j ACCEPT iptables -I DOCKER-USER 1 -s 127.0.0.1 -p tcp --dport "$DB_PORT" -j ACCEPT for ip in $ALLOW_IPS; do iptables -I DOCKER-USER 1 -s "$ip" -p tcp --dport "$DB_PORT" -j ACCEPT; done mkdir -p /etc/iptables && iptables-save > /etc/iptables/rules.v4 From 15bb4fd84b388db822fd809d6cfeae11c207cc38 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:51:14 +0000 Subject: [PATCH 009/104] dev2-site: https clone for public repos; deploy key only when private Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 115277 -> 116043 bytes dev2/dev2-site | 17 ++++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 2c1ea8a5b5ac33d44d5acd594a69da90ac09cee0..f488971ed7c16296896bcb455bd271462d28dd37 100644 GIT binary patch delta 7003 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 $f") ssh("root", f"k=/home/{DEPLOY_USER}/.ssh/known_hosts; grep -q '^github.com ' $k 2>/dev/null || ssh-keyscan github.com >> $k 2>/dev/null; chown {DEPLOY_USER}:{DEPLOY_USER} $k") From f095e2017e417e8f4ab20b3745f671b444b10abe Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:52:26 +0000 Subject: [PATCH 010/104] dev2-site: quote BUILD_SERVICES; sslmode=no-verify for node-postgres apps Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 116043 -> 116218 bytes dev2/dev2-site | 11 +++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index f488971ed7c16296896bcb455bd271462d28dd37..b955ceb372b5e142f4c49156b4ecdfe21d4d57ee 100644 GIT binary patch delta 4544 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 8.x) treats sslmode=require as + # verify-full and rejects the cluster's self-signed certificate, while the health + # check stays green. postgres.js/Bun accept no-verify too. pg_hba still forces TLS. + "database_url": f"postgres://postgres:{pw}@{DEV2_HOST}:5432/{db}?sslmode={s.get('db_sslmode', 'no-verify')}"}) save_state(s["site"], st) # Dump inside the Railway container (pg_dump matches the server there) and stream to dev2. user = rw_ssh_user(s["project_id"], s["environment_id"], pg["name"]) From 888ac34ca6826e547b7a4ba9ca7280f7a6570063 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:53:09 +0000 Subject: [PATCH 011/104] dev2-site verify: fail on connection failures in recent logs Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 116218 -> 117469 bytes dev2/dev2-site | 8 +++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index b955ceb372b5e142f4c49156b4ecdfe21d4d57ee..55811c30226ea5e0a5d8fd16ed1fbb585a69f26d 100644 GIT binary patch delta 3368 zcmcIneQ;FO72n_6kB}_+00ImU5*`|kTx%jLor-E zBr3Y(^&?wpWh8RSvea_H`mkN*BGvhr4MTI^6 zSF_VOsT@(C$_Y(upHCB8Y_Pc1{wJ%GzHRkkw=vg+1}{A0i;q#_xWU$83zgj7^V}(4_TmkfK@!O?WJEz# zIucu_t6r}jjs`V#X4yufqcLwV7KsUU)_uh@)s(tUXmQmW4Exj)U5m7-dU#z_3#ma( z7~!^XFkomYzi-K+7JtnH4ZiA@DqmA`s;aS}!B^GX(p*#TYh2!(@-d^yw`jTFSDlIn zbbU)qgw#Od9%H>0HP}cv7FF8<;fNMWg<_hnMq`GmZ4K*2%Gc1?yr^+`Lv@LoN~wlO zXkrmZ@YVN>;QtbUk^X)Gs0PKTdZX%s97^RU9StE~w`4I8?prRwWr zTW)h_Wt!aS>YYOKibPc15;oS;+Gk9Atz8jrW1G6B254T|);P^n3wcC>_UNE10Z{%Nzhw>rKc>~vhW@H4^+ z=eDZ%vT}Cn-NBzEujCEeGrfPrVSm4GVA#OSffYx}2d0zyyf_}=VLXMpd}0NIu6L3a#T3qqR`$k8_V&IYwT9eM=n7!m$$TT#NzQlYeW;j zF)J!C0Z7WVl_+)oo8ZiL@OL@27E|#T zSyxL%^>flvs8s3Ou=|E88%vR89;(G(ol~~4-+c@}V|bMz!LXUuPYg2ksr>m;94y$* z^je0K3@^wZEkjquLrinc7QGBd7@RSA5V9?bTju@H{UowZwlei(LHDSPQ zqq&Xb`CU~qMgQw2Xjx7du}aLgRfyX0Yx%p?Xvcf9at)>9h77F10(8g&YcS27O>(nY z{cqpg8K17fzZG1Sp9HXy(&TGJnKPFJs?B68jv$9~AsA{+ZwPbXEF`+YY}0UR2v*4n zQKayk`Aihofq?9aW!!C_48;B4eI`TV|y>Xyo-H7#|%2gQ$hxQx?2lVLW4hm$mn2ki3Y zPL#tTKkh^)hRbjoL3GNu(s+`3tG3Y;ljezSSPG=1V>`~dI3}UUH@8D8>Sh!7vc>0# z^4K%PlwZ5j~?|#mZ|N47(xg2-KSvCJnFkJ8Xl>2 z;VgEU#ZO~6Cf&y_Z;;gnag9AzP=C;|va+c`RPs=>BI9FGxbJD&n!DwmZlp$)F~6R} zT+YBr6NUDkjbDtD2N%F4AJ~OiSSpjdu*eyp!6(cMyXf97XvqkYw6!EPvUV@(@|c@+ zQDHP&QkRR>EoZ&2%%uySBD_a&f`kU_+HL`RcPC3>RU-s?79r=-1ASBjN zcQ1XqDogqKF<+wCKGyPPIhsymqK?7ZL_xXb04CowZu#YsOuk<_fIdu@wY``EMaFue z(levG7iDNM&-KCytd+ld2Gerd$=E^mn9g_{+lS`t*_o)=Pc4Jxv4N?2h9JXt_R1J} z=mnH`YIx`Y&b}o+jk}hKTQeWWyn1<^#D_0onitEXh*3<=USE_gKRkiE<>FUp>sm5XS!OB;JVF`;p<-heZ3e5gDWMYC^9m03 z9J6QCXw$KYxm`I+|+y>-07 z^;)pqDx}FFUT-|n8VLuJx!<}otqMDswK2R-(6dNfAd^X#*DJJ8Of+&{V+?Buk_F7x zHNzVZ%*{N9gvNX~nU3j(Hx`X_h%j@=1w9mk2_dv-utU6?S<*A|&}k&;92LEW;uTi4 zwhHyPE3*(Johjqrso-3h1(yTCi!AsAT1Q0ep!5pfx*}Wde+>`jKg=c=E)i3I-!+NGqf6 z8RpNkUCzt)&$VA3UFa{fUoLa{=SuSo-FS!PduLFJZ8G~TCeaGs#XqxU<5@`Tmmi+R zmuc%VvVym^a2HS2`sZNf;#SDt$%+*`UqXU!7%`NAuaV5rx9V#-cdZ&`CFxdvtkhUJ ddsvZc&eJ~IZSFXa_Z3$met>`6B_kKm^&f5Rn&1Ec delta 2275 zcmZ8j3viUx72faP?1p5`<{^-R32cB6H?ScF25ccfFp-ca%@R>7#LZ>{Yr-ybcQX;-M;5y55R?HYD`l$6&7w zowU=5X?R2*b>f*M?9lbMB50sTzdjBQJ2vT|+b|vW9>Zc$JLvNqc<}^Peb;e+^4BI;bD%cu#r_tOJEVf5k(49)XC75T6NRTvAf*glt z8)tY#9smtGFfae_4u) z_K6#qR%r~0v`{$eibTWf*ZZ2=1L|EpSdPg+oJJ}z%k~kA{8eY(jea|9^lA;B#mBU~ zmNR0fEw#wQW%_9?{*6!RKppaNiQcTkG{={$^)-*k!|1u@(%JR+%rW;R*Carduy;g z8c^yV!pY{`sjKw-JvijJ&Wv;`Z*9aF&0c&Z0?D={X65NmRwHD^uc)CJZMaA$n>iC# z>G#dJ6F;PEKMGTmS&Ymrr-=T{!?8a>r>cCt$ z=wJtG9ApL4WFkA3vh1EqH=w7q{@%qPlo1mIEEs zxeaIR5{t4><#q&ey2VGSRPq2*ZtEz0i!l1>t?fAVjkMnRD5k>E$f~V8B7Cak5bttb zOV*bkgBpWuYV1LDg2Aha%s{BabyrP&V|i2glBULnno?CjmwRv;kL%}lAqzPRWDnQa zO`ZCiY+)hKhm%&8HPop^^!`&=#5Xvz7aPYHiD0dqghm0$naZ*fN|8tBdQpNpy4s7o zZB4w;rI+mHdz`UqREQcq5 zG`&UF2E?PgAA8}Z^Zne*EOezGE-a(8eJI8%y>K6Fz-lVrk3vU~jqTwP*=)9oNt*U! zMat~aV8l_Sfa$VEsA>V9K(Q3=4uRPM*)-`e3fxO&X{n^y6u(`jrimL{pCG(y3bA;W zoJq4PZbOX~dK^7}7{_wV18m;<R@+mM9da=SNF;Q|8xdv`nYDucIFYV&E>^PNzk9(Q&rv z9%U8ME64GD%rIw}Go#=8R=LGZvnl>> z7wi~>+h_JmN!2hGxiM@D`P7<-**g>I9#1Ij@ds4Y<6j%_x0%f}F~;LrA8T$8`pvdF zoV+f$M)|l2zQHyUc8^B|TEePU^0-dm0ZKoK@kM^76Ao4lN1~o^s6DRM2q(?s@rOhH zm{Ng|KdyemHn>oOJ;uv-zbs-7nm7&TL0`6vW$$cES62EG~R2AGu4h|+IAM3jq&cxhWVBc z=BIe4SU*e}>&>-(m}~P+p}uqcKs`q%&S4fd(Uo(U!bM0Q;-5RHWC#SJ3q$zlX7d5L z@x1I1*XDnKn;x2*qBMh%heOG$pi%{-_m5s*vpLR_so8nVpl`%$hAJnQW2Cb4{D42H T*Ph2E!|ucqeBq+{3)uNzRperd diff --git a/dev2/dev2-site b/dev2/dev2-site index 08c17c6..e5cbfae 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -716,6 +716,12 @@ def cmd_verify(args): ok &= good note(f"{d}{hp} -> {code} {'ok' if good else 'FAIL'}{' (pinned to dev2)' if not args.public else ''}") if not ok: die("verify failed") + # A green health check can hide a dead database: scan the last minutes of logs. + bad = ssh(DEPLOY_USER, f"cd {WWW}/{s['site']} && docker compose -f docker-compose.app.yml --env-file deploy.env logs --since 10m --no-color 2>&1 | grep -ciE 'self signed certificate|SELF_SIGNED_CERT|CONNECT_TIMEOUT|ECONNREFUSED|password authentication failed|does not exist|ENOTFOUND' || true", check=False).strip() + if bad and bad != "0": + out = ssh(DEPLOY_USER, f"cd {WWW}/{s['site']} && docker compose -f docker-compose.app.yml --env-file deploy.env logs --since 10m --no-color 2>&1 | grep -iE 'self signed certificate|SELF_SIGNED_CERT|CONNECT_TIMEOUT|ECONNREFUSED|password authentication failed|does not exist|ENOTFOUND' | tail -n 8", check=False) + warn(f"{bad} connection-failure lines in the last 10 min of logs:\n{out}") + if not getattr(args, "ignore_logs", False): die("verify: the app answers but its logs show connection failures (re-run with --ignore-logs if these are expected)") mark(s["site"], "verify-public" if args.public else "verify") def cmd_dns(args): @@ -979,7 +985,7 @@ def main(): p = sub.add_parser("db"); p.add_argument("site"); p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_db) p = sub.add_parser("deploy"); p.add_argument("site"); p.add_argument("ref", nargs="?"); p.set_defaults(fn=cmd_deploy) p = sub.add_parser("vhost"); p.add_argument("site"); p.add_argument("--no-cert-check", action="store_true"); p.set_defaults(fn=cmd_vhost) - p = sub.add_parser("verify"); p.add_argument("site"); p.add_argument("--public", action="store_true"); p.set_defaults(fn=cmd_verify) + p = sub.add_parser("verify"); p.add_argument("site"); p.add_argument("--public", action="store_true"); p.add_argument("--ignore-logs", action="store_true"); p.set_defaults(fn=cmd_verify) p = sub.add_parser("refresh-db"); p.add_argument("site"); p.set_defaults(fn=cmd_refresh_db) p = sub.add_parser("migrate"); p.add_argument("site"); p.add_argument("--yes", action="store_true"); p.add_argument("--redo", action="store_true"); p.set_defaults(fn=cmd_migrate) for name, fn in (("supabase-stack", cmd_supabase_stack), ("supabase-cutover", cmd_supabase_cutover)): From 5175bd8d30fefda339c60bc695f8220caec29dda Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:55:27 +0000 Subject: [PATCH 012/104] dev2-site vhost: large_client_header_buffers at server level Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 117469 -> 117776 bytes dev2/dev2-site | 2 +- dev2/sites.d/agenticjobs.work.json | 3 ++- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 55811c30226ea5e0a5d8fd16ed1fbb585a69f26d..0c86863afcdccdb68763e868224f4b87881b2cf8 100644 GIT binary patch delta 1950 zcmZWoZBSHY6y9fd?_F8gCD8?0NmdsVkTpfKlF&eu4`Cq`(KHv>yW1kLOLvz|b}%z+ z`WVMCN#D{TwHdWSo#{v3#sN~(&~mi&ha;Je_VLR`9KX`i={sReTtp`M+=yg@<2QZDQKDn>?5znH`z{`%)`W z+YE#elSZ>z= z8(VHia{2gS+zFjJ$=;=lW`QOY6V>=X(@AN=coT=|PYu&2bs9RbYINM7OG@w3cC0T? zX7}wFfea?5;iNIASq`jfb;+Ju_8<+(>=uV{5sSOA-s2Csnrc_M>H<=$D_9@#*!qzZ zNzXAVqb6dX*7yaV^_TnvPb=R~;vWMS*o}0YS>x8<&}dR5{T+^t`s*qVXf#dw0)dM{ zp-AA7LZMXPArjnKCRCGHD!2rGw@|2|Sd+enoNmDzwY+5cgf%4c#knHSk}npDoJoQ^ zi^NJU9GXSq%OX#cFD|2?Pgq956=JbLwTcbYUI`iTB?fjj155Be%gV%gYjL)CSQl69 zFdvTBlRld8ghMFKR#GRU-++V4y=*kc;9I5P1uO?nDaUhhQ^XmirvT-A0*Yxq@(uWv zRg|F$zq8$C_!|T4Qzs_kH+I~KG~8DHbiyuLck|K72MAgS!r<^TY1de06+Rag()Y0Q zRp^{Xqm~b&>%J$A7UZljSe*mQn^S6R0iHl@Lh2U=WYE4`>j96RF0RG5_;UhIRY<2+fX z?DpX~z@l8L$GTV?Vp0p5L@UL{vU4rif^p0r#!Q&mt}qs3B>N|fJj_yNzmC1Ju_K9? zN4+MnTW{f6=$WG(&qH9Zw&M+qVt=$_0Hc(1>(HPTEzy!i@8CrY8XMjPe=-N> zzu99(YKQHvLAmlCG6kcb4$ys3S0&GuByt?Tsc#@vI%FAGKX^C z<%4(1*QuX8Zuje}^D3QkKKtoIbSF+DMj4H?ieMI*79B$_#_jFSmB3E4#Y~TOPMH_{SEHOXCxJvTm6mlH z!E^#E8`p(N86^}dq(Q3m$z-Z2hI&mQpOg7HJ)w+rntO&;P9l8_+tY=U(Z$bHTKD2F z;2@LtqaR1t@29p zm>dpw!0&F6Wsl$8DxZ(GeuypXMFa+RsuxpBAAOp#%sFy9!DCMtRE{=S4ieN6_z2#n zJ)^ajjY`Qkcul}A)_oLDV=cRS6cdom;*W7LL&-jd{a7}X_OZ%p0fU-{z(imsP}fC$ cRkO%cH(YIGs0X!tkmd9t3muBD4=uWX0eh40qyPW_ delta 1674 zcmZ8gZA_C_6zxI!eJe7Q!fg@laBhl5 z&3-Jyc{UlFn7L@g>3-;)$s%f^Ax^Vw%M!tu#eMvpnvM9CWxMCC2J|NPdC$jl&pGeC z=Y05yu29?ecye?jU8TV+(x%GyePl(;u5Uu`sL2an2vD?#oO*^ z*Nu#Jn_N6U_M^8JAG=&WBE8~bso1`EZ?XdE0HzR z)#czO?JdAhsX_bS7R!Wc`#py8>8+KWHF;$GAhk z1d)&5>2VNw7?QqfG#Oc)%9EbMcS{LpbKLXckL`+Obs9WsT*+;x>ZFJRyojsR{)PpZ$yU+eMDQZ zS|^R(wJDm#g3)v9e5cU)pno^HagUS zcacunyKxuk(zyqXR^5|K`OaRHo{lgl$?{O_HRT=I<}2M~AR9m8}Wg*U%J?`hDMlNYpYdusv=C%H%03VQ7`*GLqN+h*n$1ey9{8}7%6rr+*2tC~*nwJVJAxUmMiuM}wPQQL z?J-GSJ%W`fvywl;iIy;!DFZB3F@zb4xmimXW-(;bo4uG;u$Wzo_^wU*3|7q$!=;aB zTNU*vdipY!`DqHQB9rAT8tlcjWaAge-jjF$oTH7tmtImvrYcitrwJuV4yFXg+^t$;cIa vjkkuwK9|`{pqp_RMl-k>%+@g{s*F{$ 127.0.0.1:" + str(p["port"]) + "\n" + blk conf += "\nserver {\n listen 80;\n listen [::]:80;\n server_name " + p["host"] + ";\n return 301 https://$host$request_uri;\n}\n" log(f"nginx vhost {s['site']} -> 127.0.0.1:{s['port']} ({', '.join(domains)})") diff --git a/dev2/sites.d/agenticjobs.work.json b/dev2/sites.d/agenticjobs.work.json index 18ab30c..be5e75e 100644 --- a/dev2/sites.d/agenticjobs.work.json +++ b/dev2/sites.d/agenticjobs.work.json @@ -1,5 +1,6 @@ { "env_overrides": { - "OBSCURA_MCP_URL": "" + "OBSCURA_MCP_URL": "", + "PGSSLMODE": "require" } } From 3362aab347cc9594b982cbe204eb6dfc1023838c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:57:24 +0000 Subject: [PATCH 013/104] dev2-site supabase-cutover: tolerate projects without pg_cron; no-verify db url Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 117776 -> 118210 bytes dev2/dev2-site | 7 +++++-- dev2/sites.d/threatcrush.com.json | 8 +++++++- dev2/templates/supabase-stack.sh | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 0c86863afcdccdb68763e868224f4b87881b2cf8..0bcc4fb6bad1a06ef1170cf65b72f032a782ea4e 100644 GIT binary patch delta 666 zcmYL^T}TvB6vywGJL~Gs&RSZDuG-bY*_AeP*EBaYL^2CBd(o1@wwcTNG0w!ytje|^ zL4nW*O9mr|;Db+YSdh8&66I6T*O~?iKGb^;_C?S`bXN&4hu=NibN>H(PIAh*Jni(n z7DX3g=f=sU!CSf4Jx@Fkp5tyi?JpS4r0>tc3kTe>yXQffjNL60g@j-nPY6ow2pA2v zIu9pWgELsEd2l3!vo;6;vQBMh2q~e2FpXxo6l}i$((-qz31JLO4v8BPJ!I z{Zu2C-e=ILtVT@JC&`Ac9Uj!LNPaS45-p~x#L6~Fnoh1p`?6(;P?c$Y|BdvBN==#i zOv@P1qJ5EQ)W;08$S^%bBo_W$22IzfWVb9pY0yuorc{z(b5$)#5GyjEl7L1+ZN^)+ zwx5f;cD^#!*k17-yJ!L0%bRNyI#RPWQdS!mW11=VQzfQSB}~$>1!#qwzf52h+0PzA zoZH8z!;hc~kE{G9Lf+vlV|<+diBSGE{x@TbqV~8l|1hs)6GVGSy0{Ch~G6j&SIyGCdsEg>9R#w rcEf7=^K%e|0|!CjzIQ!n|;CzcD~cRyj%=G!0Wm#>pfc|-&Q_Gt0hcA?9(4IGkR^GvV`#Z$QZz^ z<<0zofkD~_M20X&GJj!U;E!VNV7|k_(-GVs*%{d#eTi3ThT9bm<>_f#8Lgy68MtIF za!UUy<^h^{LP*QkhWV@ws~;PCGQ(tr8zS4!Z)H5h!+2r)q(h9n+{%iKDNLWd7{wX2 dJ{U2u@N`sNVwSwg!qMvXfti6tsz?-Q1^}MhUOE5( diff --git a/dev2/dev2-site b/dev2/dev2-site index 775c458..c93fd2f 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -966,8 +966,11 @@ def cmd_supabase_cutover(args): cmd_verify(argparse.Namespace(site=s["site"], public=("dns" in load_state(s["site"])["steps"]))) if st.get("cloud_db_url"): log("Unscheduling the CLOUD project's cron jobs") - out = ssh("root", f"docker run --rm -i --network host postgres:17 psql {shlex.quote(st['cloud_db_url'])} -At -X -c \"select cron.unschedule(jobname) from cron.job where active\" 2>&1 | tail -n 5", check=False) - note(out.strip() or "none") + out = ssh("root", f"docker run --rm -i --network host postgres:17 psql {shlex.quote(st['cloud_db_url'])} -At -X -c \"select case when to_regclass('cron.job') is null then 'no pg_cron' else (select coalesce(string_agg(jobname, ', '), 'none') from cron.job where active) end\" 2>&1 | tail -n 3", check=False).strip() + if out and out not in ("no pg_cron", "none"): + ssh("root", f"docker run --rm -i --network host postgres:17 psql {shlex.quote(st['cloud_db_url'])} -At -X -c \"select cron.unschedule(jobname) from cron.job where active\" >/dev/null 2>&1", check=False) + note(f"unscheduled: {out}") + else: note(out or "none") mark(s["site"], "supabase-cutover") log(f"{s['site']} now runs on its own Supabase. Cloud project {st['cloud_ref']} stays until `retire` (delete it in the dashboard or with the management API).") diff --git a/dev2/sites.d/threatcrush.com.json b/dev2/sites.d/threatcrush.com.json index 5fcf90d..12723b6 100644 --- a/dev2/sites.d/threatcrush.com.json +++ b/dev2/sites.d/threatcrush.com.json @@ -5,5 +5,11 @@ "port": 8270, "max_body": "512m" } - ] + ], + "env_overrides": { + "SUPABASE_DB_PASSWORD": "5df2829f245804448a8eb604a4417d54", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.threatcrush.com", + "SUPABASE_SERVICE_ROLE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3OTAzMjk0ODgsImV4cCI6MTk0ODAwOTQ4OH0.VRGW__PDSjJQkilfS6AEjFwbe3dnc-ffC57lAxQ2uwk", + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzkwMzI5NDg4LCJleHAiOjE5NDgwMDk0ODh9.3dJiBKet1EGSJCZoQaMFVD861UxKCboB7GcD-gI0EXw" + } } diff --git a/dev2/templates/supabase-stack.sh b/dev2/templates/supabase-stack.sh index 2ca5892..c81fb91 100755 --- a/dev2/templates/supabase-stack.sh +++ b/dev2/templates/supabase-stack.sh @@ -230,7 +230,7 @@ SELFHOST_ANON_KEY=$(get_env ANON_KEY) SELFHOST_SERVICE_ROLE_KEY=$(get_env SERVICE_ROLE_KEY) SELFHOST_JWT_SECRET=$(get_env JWT_SECRET) SELFHOST_POSTGRES_PASSWORD=${pw} -SELFHOST_DATABASE_URL=postgres://postgres:${pw}@${DB_DOMAIN}:${DB_PORT}/postgres?sslmode=require +SELFHOST_DATABASE_URL=postgres://postgres:${pw}@${DB_DOMAIN}:${DB_PORT}/postgres?sslmode=no-verify SELFHOST_DB_PORT=${DB_PORT} SELFHOST_API_PORT=${API_PORT} SELFHOST_POOLER_PORT=${POOLER_PORT} From bada534a03704e1adb3aa0fa7b4276d19453c07a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 09:58:55 +0000 Subject: [PATCH 014/104] dev2-site: reload PostgREST schema cache after load; world-readable checkouts Co-Authored-By: Claude Fable 5.1 --- dev2/templates/deploy-app.sh | 4 ++++ dev2/templates/supabase-load.sh | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/dev2/templates/deploy-app.sh b/dev2/templates/deploy-app.sh index 05ac592..bf65a05 100755 --- a/dev2/templates/deploy-app.sh +++ b/dev2/templates/deploy-app.sh @@ -79,6 +79,10 @@ SHA=$(git -C "$APP_DIR" rev-parse --verify --quiet "origin/$TARGET^{commit}" \ || true) [ -n "$SHA" ] || die "cannot resolve '$TARGET' to a commit" git -C "$APP_DIR" checkout -q --detach "$SHA" +# The deploy account's umask leaves files 0640; an image that runs as a non-root +# user (node, bun) then dies with EACCES reading its own source. Railway's git +# uploads were world-readable, so match that. +chmod -R u+rwX,go+rX "$APP_DIR" 2>/dev/null || true log "At $SHA" log "Building $BUILD_SERVICES" diff --git a/dev2/templates/supabase-load.sh b/dev2/templates/supabase-load.sh index 1208d5f..826a200 100755 --- a/dev2/templates/supabase-load.sh +++ b/dev2/templates/supabase-load.sh @@ -74,4 +74,9 @@ log "Snapshot AFTER (vs the cloud's estimates in counts-estimate.tsv)" strict -At -F$'\t' -c "select n.nspname||'.'||c.relname, c.reltuples::bigint from pg_class c join pg_namespace n on n.oid=c.relnamespace where c.relkind='r' and n.nspname='public' order by 1" > "$DUMP/counts-after.tsv" || true strict -At -c "select 'tables='||(select count(*) from information_schema.tables where table_schema='public')||' users='||(select count(*) from auth.users)||' identities='||(select count(*) from auth.identities)||' size='||pg_size_pretty(pg_database_size(current_database()))" strict -c "analyze" >/dev/null 2>&1 || true +# PostgREST built its schema cache before these tables existed; without this every +# request answers PGRST205 "Could not find the table ... in the schema cache". +log "Reloading PostgREST's schema cache" +strict -c "notify pgrst, 'reload schema'" >/dev/null +docker restart "${DBC%-db}-rest" >/dev/null 2>&1 || true log "Loaded." From 433bc66f9ffe67bbecbff8c8da085957af0f812d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:00:59 +0000 Subject: [PATCH 015/104] dev2-site: sslmode=require&uselibpqcompat=true, the form both Bun.SQL and node-postgres accept Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 118210 -> 118179 bytes dev2/dev2-site | 11 ++++++----- dev2/sites.d/tipoffwatch.com.json | 6 ++++++ dev2/templates/supabase-stack.sh | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 dev2/sites.d/tipoffwatch.com.json diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 0bcc4fb6bad1a06ef1170cf65b72f032a782ea4e..164c4d518416933050ce06f4e94c2e267e04bb84 100644 GIT binary patch delta 3606 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 8.x) treats sslmode=require as - # verify-full and rejects the cluster's self-signed certificate, while the health - # check stays green. postgres.js/Bun accept no-verify too. pg_hba still forces TLS. - "database_url": f"postgres://postgres:{pw}@{DEV2_HOST}:5432/{db}?sslmode={s.get('db_sslmode', 'no-verify')}"}) + # One form both drivers accept: node-postgres (pg >= 8.x) reads sslmode=require as + # verify-full and rejects the cluster's self-signed cert unless uselibpqcompat=true; + # Bun.SQL rejects `no-verify` outright but ignores the extra parameter. pg_hba + # still forces TLS on the wire. + "database_url": f"postgres://postgres:{pw}@{DEV2_HOST}:5432/{db}?{s.get('db_sslparams', 'sslmode=require&uselibpqcompat=true')}"}) save_state(s["site"], st) # Dump inside the Railway container (pg_dump matches the server there) and stream to dev2. user = rw_ssh_user(s["project_id"], s["environment_id"], pg["name"]) diff --git a/dev2/sites.d/tipoffwatch.com.json b/dev2/sites.d/tipoffwatch.com.json new file mode 100644 index 0000000..a85cffb --- /dev/null +++ b/dev2/sites.d/tipoffwatch.com.json @@ -0,0 +1,6 @@ +{ + "env_overrides": { + "PLAYLIST_SECRET": "postgresql://postgres:JApsRyjafaEFHDqXQHWFAJTQoNxADLOo@postgres-q7ea.railway.internal:5432/railway" + }, + "notes": "PLAYLIST_SECRET pins the playlist encryption key to the old Railway DATABASE_URL: tipoffwatch derives the key from PLAYLIST_SECRET || DATABASE_URL (packages/config/src/index.js), and the kit rewrote DATABASE_URL, which made every stored playlist URL unreadable. Contains the retired Railway Postgres credential; do not commit." +} diff --git a/dev2/templates/supabase-stack.sh b/dev2/templates/supabase-stack.sh index c81fb91..e70e204 100755 --- a/dev2/templates/supabase-stack.sh +++ b/dev2/templates/supabase-stack.sh @@ -230,7 +230,7 @@ SELFHOST_ANON_KEY=$(get_env ANON_KEY) SELFHOST_SERVICE_ROLE_KEY=$(get_env SERVICE_ROLE_KEY) SELFHOST_JWT_SECRET=$(get_env JWT_SECRET) SELFHOST_POSTGRES_PASSWORD=${pw} -SELFHOST_DATABASE_URL=postgres://postgres:${pw}@${DB_DOMAIN}:${DB_PORT}/postgres?sslmode=no-verify +SELFHOST_DATABASE_URL=postgres://postgres:${pw}@${DB_DOMAIN}:${DB_PORT}/postgres?sslmode=require&uselibpqcompat=true SELFHOST_DB_PORT=${DB_PORT} SELFHOST_API_PORT=${API_PORT} SELFHOST_POOLER_PORT=${POOLER_PORT} From 21e9b59ed21797adb59b23a32df702b6835cbe75 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:01:27 +0000 Subject: [PATCH 016/104] dev2-site: explicit 10.200..0/24 subnet per site (Docker pools exhausted) Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 118179 -> 118380 bytes dev2/dev2-site | 6 ++++++ 2 files changed, 6 insertions(+) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 164c4d518416933050ce06f4e94c2e267e04bb84..7d66832e2b15ba95de5f529856fdaa8525e71a9c 100644 GIT binary patch delta 3440 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 V(ERTSJYdyEVa&x_Y0m)8{SVQML}vg1 diff --git a/dev2/dev2-site b/dev2/dev2-site index 67fa3aa..f91f803 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -470,6 +470,12 @@ def render_compose(s, st, app_port_inside): if s["redis"]: y.append(" redis:\n image: redis:8-alpine\n restart: unless-stopped\n command: [\"redis-server\", \"--appendonly\", \"yes\", \"--maxmemory\", \"1gb\", \"--maxmemory-policy\", \"noeviction\"]\n volumes:\n - redisdata:/data\n logging:\n driver: json-file\n options: { max-size: \"20m\", max-file: \"2\" }") y.append("volumes:\n redisdata:") + # An explicit subnet per site: Docker's default address pools (15 x /16 + 16 x /20) + # ran out at 34 networks on dev2, and widening them means restarting dockerd + # under every container. 10.200..0/24 is deterministic and allowlisted + # on the data ports. + n = s["port"] - FIRST_PORT + y.append(f"networks:\n default:\n ipam:\n config:\n - subnet: 10.200.{n}.0/24") return "\n".join(y) + "\n" # -------------------------------------------------------------- provision From c0a8867c6f343844c4485c4a4c3c52957ce6eca2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:08:58 +0000 Subject: [PATCH 017/104] dev2-site: sslmode per driver (no-verify for node-postgres repos, require otherwise) Bun.SQL forwards unknown URL parameters to the server as GUCs, so uselibpqcompat=true was FATAL there while the health check stayed green. Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 118380 -> 119500 bytes dev2/dev2-site | 27 +++++++++++++++------- dev2/sites.d/brisk.news.json | 9 ++++++++ dev2/sites.d/cigarunderground.org.json | 3 ++- dev2/templates/supabase-stack.sh | 2 +- 5 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 dev2/sites.d/brisk.news.json diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 7d66832e2b15ba95de5f529856fdaa8525e71a9c..c4b2018258cdefab07654626d5869bdb91e06838 100644 GIT binary patch delta 6133 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 delta 5254 zcma)AdstLglD~Dk@8!|Fn}=WnjRcWEBZ|Q%5d=g9nwJIv4b${(raZ~9U1{Rjq5QDIfYIs7}ArWf3xM9|)y4k^Mdx5v7rZb^yKC=m}(j2b1H z#_Tf7E*Pv<#?gvhsSr6OEh{&E`fx;EM+#TCXG>NT$noSLn=YD!ue|G(EXHMwil_Mt4OT>4n|F@hPay zt#1))1-l4>T{j>PNUMa52{ind*8>BHqqnb(IOzCSKM3|gKH<(2GK}={t5ZetC7V(N ziL`D{z2O!Q#a{k%9TVwq_FRf<5uM^CK}hHkzZHaDk!TZ_=*fER7z79j3-SW(I+pV~ zM0@V;D-+?mr)>X_EZzlr{_te^E>}bZPvVh}0f64}lz{9h_|>O6FnCgq>B8U+O*`d+ zYo6_=A^-yE8*hIEI?sl8*6SgN2Atjjk#zUzQn*FGIh_bH4Sa6_7-;o-rJTO~y(^I8 z+46pM5TBg;s}pi*bpJBcnVvU{LbhGLQOnbrK6#!9<;dF))dS zo;1-Vn@58#w&&7aNyg+E{>5B;=7=;9qCV!|Sed2FrWeo0q|b31I5%H4`#F5}xI2WO zM)t4f+s{#9xo`}sjIiE2Lm_b<{)3m?)q5A`XD zBNC?BK$tq$zsen^6e-0AO7`nUDIh##;e0yz%jtEcYTpW3q>G2`_*W_RYJTZqFi?&2izzj3 zBX?Eph|Vqh6N?{e{C)mnaH9esP?!|lv39ZwjuyiCq;U}Pd z=WPM?Sh{#g_~d+gX2Y0DrEwtUp*TFOa@l>A0n0}ucg%q&sqO1bT~m8F9((3bHVE$6 zV!>Zm{c3rwvJJ%jLuxacx!Sk`Pu}12nC&@qu|~}Gg(%HUE>~xdjo7+e z{i%G|Z$P z2k@BZ@YNs@Iy}F*wt}}^ntJ2cA&c=IEkSrfr0XtbbMm;G6|j`H++@&0f4F%E8tM19 zrU9W-ZYRMqT6H@eCezioAI-uVN}{<-NG^_MApC$+%zdBK;v{wl=|zGh&_CX8h9sKz z{RGIP_1~W~*Klc{uGyxLe-42sAd+Q;LaAYZbDriumrG=YMo97whXU0@D9ee0 zeFn`0%@`B=EDEw=1B)`kBhp5`VvBdS5f1v(&c2nQh^>4K_D7_f-EK3cGntR9TlZNR%$LVadto}nLF8xva3}1vq#s!1QHYoK zp*6AW;~CsS!tTz1OzF2==pFCmnV`VLHa*W2Q#&HjuhL%?1jB0mb&gN#`P+sgPb)(K zJ3b52VtP=@+>5wI|1`>muho06%z_`}L_A`@Y_>dZE;N&Jv;sQ^X+ZcH3!4X9;4L;h z50>-u-4t=*CM=v4lLpX-8 z3SlL3Kf%!@*4W5TR>+PwLSe)PBsC)(M|hnDFM|z>v^$f7gaHK1NrI_EFarpNgy6Ly z=m~;HBWRpYZfWaO9faIQ3f2WF2$Hz;&Q9Up}5K3A7YUl+Udv`Us;Z^T~HE;sNA}qVn?BHrJK`Wc}6fA|`Gx`+d zz#aAv7?ahm4ogWN+|R zC<`nqlF!bpnnl&i|?L_75+AB~NhE!hH+PYPznd_+B z2_>UZk7PS`!sK+6?GH9vOX`{yRMsxeC*?>mn5+1tsnS|rpYMYyWIp?LCr@H4OW6fP zZ|5Rx;V>ixcRG2`Z|Bot?}=ToNSB})q3xf|slhyDm8yBw;e$L4aaauS8KBH3WE z^f+Cr#o67thIAq&)MBwayX}31IJ)g?$bXM|^Y{EizQf44IAo-0&o&<`I|U91Vuw#b zO5<2l()!WE4TxxcOY_)|8WlDmf)#|Iw+LP(EtSbeymOdPB!pSshWg;OXaj=JyY+1d zlcv1J$5w)sB15!(t;guW*ZAKwvQissnUf*RvXYE5{oMQ=r6 z?bY|AX8j{KY2LtYt945fjw9@eqqjFs$Dh2Tbx5mCn%l-ara4RV;#xN8eMk@fXTFl# dE4Uoq`uE{=F$8!gye!X!dE(`HEc!z@_WuM!OT_>H diff --git a/dev2/dev2-site b/dev2/dev2-site index f91f803..cc9fce6 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -432,7 +432,7 @@ def render_env(vars_, s, st, service_name=None): out[k] = st["database_url"] for k in ("PGHOST", "PGPORT", "PGUSER", "PGPASSWORD", "PGDATABASE", "PGSSLMODE"): if k in out: - out[k] = {"PGHOST": DEV2_HOST, "PGPORT": "5432", "PGUSER": st["db_user"], "PGPASSWORD": st["db_password"], "PGDATABASE": st["db_name"], "PGSSLMODE": "require"}[k] + out[k] = {"PGHOST": DEV2_HOST, "PGPORT": "5432", "PGUSER": st["db_user"], "PGPASSWORD": st["db_password"], "PGDATABASE": st["db_name"], "PGSSLMODE": (s.get("db_sslparams") or sslparams_for(s, st)).split("=")[-1]}[k] if s["redis"]: for k, v in list(out.items()): if isinstance(v, str) and "redis" in v.lower() and "railway.internal" in v: @@ -557,6 +557,16 @@ def cluster_pg_password(): def pg_admin(sql, db="postgres"): return ssh("root", f"docker exec -i {PG_CONTAINER} psql -U supabase_admin -h localhost -d {shlex.quote(db)} -X -At -v ON_ERROR_STOP=1", input=sql) +def sslparams_for(s, st): + """`sslmode=no-verify` for node-postgres repos, `sslmode=require` for everything else.""" + d = st.get("checkout") or (checkout_dir(s) if s.get("repo") else None) + if d: + try: + out = sh(f"grep -rls --include=package.json -E '\"pg\"\\s*:' {shlex.quote(d)} --exclude-dir=node_modules --exclude-dir=.git | head -n 1", check=False) + if out.strip(): return "sslmode=no-verify" + except RuntimeError: pass + return "sslmode=require" + def db_name_for(s): return s.get("db_name") or re.sub(r"[^a-z0-9]+", "_", s["site"].lower()).strip("_") @@ -585,11 +595,12 @@ def cmd_db(args): if args.force: pg_admin("drop schema public cascade; create schema public; grant all on schema public to postgres, public", db); note("dropped public schema") st.update({"db_name": db, "db_user": "postgres", "db_password": pw, - # One form both drivers accept: node-postgres (pg >= 8.x) reads sslmode=require as - # verify-full and rejects the cluster's self-signed cert unless uselibpqcompat=true; - # Bun.SQL rejects `no-verify` outright but ignores the extra parameter. pg_hba - # still forces TLS on the wire. - "database_url": f"postgres://postgres:{pw}@{DEV2_HOST}:5432/{db}?{s.get('db_sslparams', 'sslmode=require&uselibpqcompat=true')}"}) + # No single form suits every driver: node-postgres (pg >= 8.x) reads sslmode=require + # as verify-full and rejects the cluster's self-signed cert, so it needs no-verify; + # Bun.SQL rejects no-verify and forwards any unknown parameter (uselibpqcompat) to the + # server as a GUC, which is FATAL. So: no-verify when the repo depends on `pg`, + # plain require otherwise (Bun.SQL, postgres.js, libpq). Override with db_sslparams. + "database_url": f"postgres://postgres:{pw}@{DEV2_HOST}:5432/{db}?{s.get('db_sslparams') or sslparams_for(s, st)}"}) save_state(s["site"], st) # Dump inside the Railway container (pg_dump matches the server there) and stream to dev2. user = rw_ssh_user(s["project_id"], s["environment_id"], pg["name"]) @@ -724,9 +735,9 @@ def cmd_verify(args): note(f"{d}{hp} -> {code} {'ok' if good else 'FAIL'}{' (pinned to dev2)' if not args.public else ''}") if not ok: die("verify failed") # A green health check can hide a dead database: scan the last minutes of logs. - bad = ssh(DEPLOY_USER, f"cd {WWW}/{s['site']} && docker compose -f docker-compose.app.yml --env-file deploy.env logs --since 10m --no-color 2>&1 | grep -ciE 'self signed certificate|SELF_SIGNED_CERT|CONNECT_TIMEOUT|ECONNREFUSED|password authentication failed|does not exist|ENOTFOUND' || true", check=False).strip() + bad = ssh(DEPLOY_USER, f"cd {WWW}/{s['site']} && docker compose -f docker-compose.app.yml --env-file deploy.env logs --since 10m --no-color 2>&1 | grep -ciE 'self signed certificate|SELF_SIGNED_CERT|CONNECT_TIMEOUT|ECONNREFUSED|password authentication failed|does not exist|ENOTFOUND|unrecognized configuration parameter|database unavailable' || true", check=False).strip() if bad and bad != "0": - out = ssh(DEPLOY_USER, f"cd {WWW}/{s['site']} && docker compose -f docker-compose.app.yml --env-file deploy.env logs --since 10m --no-color 2>&1 | grep -iE 'self signed certificate|SELF_SIGNED_CERT|CONNECT_TIMEOUT|ECONNREFUSED|password authentication failed|does not exist|ENOTFOUND' | tail -n 8", check=False) + out = ssh(DEPLOY_USER, f"cd {WWW}/{s['site']} && docker compose -f docker-compose.app.yml --env-file deploy.env logs --since 10m --no-color 2>&1 | grep -iE 'self signed certificate|SELF_SIGNED_CERT|CONNECT_TIMEOUT|ECONNREFUSED|password authentication failed|does not exist|ENOTFOUND|unrecognized configuration parameter|database unavailable' | tail -n 8", check=False) warn(f"{bad} connection-failure lines in the last 10 min of logs:\n{out}") if not getattr(args, "ignore_logs", False): die("verify: the app answers but its logs show connection failures (re-run with --ignore-logs if these are expected)") mark(s["site"], "verify-public" if args.public else "verify") diff --git a/dev2/sites.d/brisk.news.json b/dev2/sites.d/brisk.news.json new file mode 100644 index 0000000..4fe726e --- /dev/null +++ b/dev2/sites.d/brisk.news.json @@ -0,0 +1,9 @@ +{ + "proxies": [ + { + "host": "supabase.brisk.news", + "port": 8238, + "max_body": "512m" + } + ] +} diff --git a/dev2/sites.d/cigarunderground.org.json b/dev2/sites.d/cigarunderground.org.json index 7b65c28..a5f50d8 100644 --- a/dev2/sites.d/cigarunderground.org.json +++ b/dev2/sites.d/cigarunderground.org.json @@ -1,3 +1,4 @@ { - "health_path": "/healthz" + "health_path": "/healthz", + "db_sslparams": "sslmode=require" } diff --git a/dev2/templates/supabase-stack.sh b/dev2/templates/supabase-stack.sh index e70e204..2ca5892 100755 --- a/dev2/templates/supabase-stack.sh +++ b/dev2/templates/supabase-stack.sh @@ -230,7 +230,7 @@ SELFHOST_ANON_KEY=$(get_env ANON_KEY) SELFHOST_SERVICE_ROLE_KEY=$(get_env SERVICE_ROLE_KEY) SELFHOST_JWT_SECRET=$(get_env JWT_SECRET) SELFHOST_POSTGRES_PASSWORD=${pw} -SELFHOST_DATABASE_URL=postgres://postgres:${pw}@${DB_DOMAIN}:${DB_PORT}/postgres?sslmode=require&uselibpqcompat=true +SELFHOST_DATABASE_URL=postgres://postgres:${pw}@${DB_DOMAIN}:${DB_PORT}/postgres?sslmode=require SELFHOST_DB_PORT=${DB_PORT} SELFHOST_API_PORT=${API_PORT} SELFHOST_POOLER_PORT=${POOLER_PORT} From f3db2a311c1fddcb3852cba4314ab6604c9a2d51 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:13:05 +0000 Subject: [PATCH 018/104] dev2-site: public build-time variables reach the build; nixpacks on Node 22 Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 119500 -> 121886 bytes dev2/dev2-site | 23 ++++++++++++++++++++-- dev2/templates/deploy-app.sh | 3 +++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index c4b2018258cdefab07654626d5869bdb91e06838..70e19fa82c3410332e21e0eeded1602c816f0651 100644 GIT binary patch delta 13493 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/dev/null || true log "At $SHA" log "Building $BUILD_SERVICES" +# Public build-time variables (NEXT_PUBLIC_*, VITE_*, ...) come from app.env through the +# compose build args; export the file so ${K} interpolates. Never printed. +set -a; . "$ROOT/app.env"; set +a # shellcheck disable=SC2086 compose build $BUILD_SERVICES From a6123d52d2fcf97396c2cd2fed4c27649354c18c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:15:05 +0000 Subject: [PATCH 019/104] deploy-app.sh: export public build vars by parsing app.env, not sourcing it Co-Authored-By: Claude Fable 5.1 --- dev2/templates/deploy-app.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/dev2/templates/deploy-app.sh b/dev2/templates/deploy-app.sh index 84612ac..ac603f0 100755 --- a/dev2/templates/deploy-app.sh +++ b/dev2/templates/deploy-app.sh @@ -87,8 +87,17 @@ log "At $SHA" log "Building $BUILD_SERVICES" # Public build-time variables (NEXT_PUBLIC_*, VITE_*, ...) come from app.env through the -# compose build args; export the file so ${K} interpolates. Never printed. -set -a; . "$ROOT/app.env"; set +a +# compose build args; export just those so ${K} interpolates. app.env is a compose env +# file, not shell (unquoted values may contain spaces), so parse it line by line. +while IFS= read -r line || [ -n "$line" ]; do + case $line in ''|'#'*) continue ;; esac + key=${line%%=*}; val=${line#*=} + case $key in + NEXT_PUBLIC_*|VITE_*|PUBLIC_*|NUXT_PUBLIC_*|EXPO_PUBLIC_*|REACT_APP_*|SVELTEKIT_PUBLIC_*) + case $val in \"*\") val=${val#\"}; val=${val%\"} ;; esac + export "$key=$val" ;; + esac +done < "$ROOT/app.env" # shellcheck disable=SC2086 compose build $BUILD_SERVICES From f5fd1f9705992cc0b7fb9548158a567209046aa7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:28:21 +0000 Subject: [PATCH 020/104] deploy-app.sh: recreate the stack when the network subnet changes (stale embedded DNS) Co-Authored-By: Claude Fable 5.1 --- dev2/templates/deploy-app.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dev2/templates/deploy-app.sh b/dev2/templates/deploy-app.sh index ac603f0..92dbb6d 100755 --- a/dev2/templates/deploy-app.sh +++ b/dev2/templates/deploy-app.sh @@ -101,6 +101,21 @@ done < "$ROOT/app.env" # shellcheck disable=SC2086 compose build $BUILD_SERVICES +# If the compose network's subnet changed (the kit moved sites to explicit +# 10.200.x.0/24 subnets), `up -d` recreates the network in place and Docker's +# embedded DNS then answers SERVFAIL for service aliases like `redis` while +# container names still resolve. A clean down/up avoids that. +WANT_SUBNET=$(grep -oE 'subnet: [0-9./]+' "$ROOT/docker-compose.app.yml" | awk '{print $2}' | head -n1) +APP_CID=$(compose ps -q app 2>/dev/null | head -n1) +if [ -n "$WANT_SUBNET" ] && [ -n "$APP_CID" ]; then + NET=$(docker inspect --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' "$APP_CID" 2>/dev/null | head -n1) + HAVE_SUBNET=$(docker network inspect "$NET" --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null || true) + if [ -n "$HAVE_SUBNET" ] && [ "$HAVE_SUBNET" != "$WANT_SUBNET" ]; then + log "Network subnet changes ($HAVE_SUBNET -> $WANT_SUBNET): recreating the stack" + compose down --remove-orphans + fi +fi + log "Starting" compose up -d --remove-orphans From 945c1e72c2ae92a6446a059cee83377d8b8e31a8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:30:15 +0000 Subject: [PATCH 021/104] dev2-site verify: require the vhost to exist Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 121886 -> 122262 bytes dev2/dev2-site | 3 +++ 2 files changed, 3 insertions(+) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 70e19fa82c3410332e21e0eeded1602c816f0651..4d0b71ba4e21c52f125c8ac956429c09755898af 100644 GIT binary patch delta 1591 zcmaJ=ZERCj7`{($+k4w>-Pqc7g?-#P7;J0|0SEFSLKIB2z*O*KfXGfaDoZQ3cVm<` zLqZ4|0&|-l3>i+CL{mc`ejE^m8BG`=fs2e#P>3J+k%$WZ5yS+~X>FK)yh-1@=e^JK zyw7>x+dYSs*N-Z$Q#meYlylSf?3Pl3YQ#b+_<1ayJR9EHyJH-8 zSN!f?jM9uSsWdctg;N~k`)nhY()GJzyffz1{V^w*klM{7|4ScVm7%AV<5@;Urbo=l zEmy~~#GHD*x#lU6$V8$f?3aJ1YAL=3Gqpw$J8rn*o|Fw{{O3G!lXAoTp|)&QxCW^0v|TA*o7 zSlbYYEYL4-?j@P0(SlG|OEI+eHQ`8&p*4qfEjot7jG3C=7SdLcK>L(hA(uiJT2xu3 zH3#*GK@ZHq3O4Eibju!h+SpRzZi@)Bc%WuA-1Pls(*}Gvv3GQl_wp88_ma`F|(f?RvX#A`MA%4Z`s91u^hH* zyx|=-(~s+RTxS!X#A4X~;x!-ZT#7gCm`cl*V-G-&EyvV~X?$goBJTO-&(h6JlIdVUgN4?w#8{h~)2i8XJhgjS$8il zV}O1C8m7rguh54rr9b-(3^{Ow+4i9X$fXqneC!{y$N(k-huFZ|@Y|G61wliKP?*3m zn{q-_I@6U~2N8F2>iVNd;yhdO2`*ZYq}9i<1LxS0<2dEA|2x}PPU9&I)9+4WBCb-` z89br>$p=_v(XY_p86?X7J5zn9m~OuaCqzinsU~*fHv8PfA|)@iiV+M}o KOrc$0VaGp}b+WSn delta 1292 zcmaJ;ZEO@(6n$rAXJ==>Xlb{V?e=S1z#=AX6eY!i0b(?^wS-iR+E&eLON-eiGh=JX zY+ApX{%96(g%@oAOGIOq5Yhx50ZBB$6oMv;kQAD1BAWQ8ni_vd4H$fH*TvZLyFrvVzID`*id?)NzEW1uS~u%k-21E3`*N>fVm{BStAf61uvI37x<5hgS= zOx}j&0uhA5C2MAV8>ZfH5yv%V>tEJG0NK^_gdR_;})MW{1`RwvM6s`KNYRi*lpB>J7wAzla@fMUD zHHWpVBvndc9W5xXuHEw_A?CCHJ2w{1&*dQFKD#;(?sx_JI>#puRzRcnex1;IT9Q(8 z@dLD17xdTN?+1m{;E+LgK9^i!dmBxiu1P7m6v!x{YYU@>huh6IQ8YgnKisSrhJCjg zUFtLDJYTZ~2i3sJT)k5^z0YoKMMe0Y^e3}Z(#V6FmC|ft`Zm1lofqqV5uc^8ExS>} z{_YWf_S`o3c*S;XFz_Sa(S}!{EsCDIe6}6GyIo7dXDywG@=Kjq2kkErm+{*#;;0*q zjCJ7%2z#O%kD-YTcB2*wo9f0L1lVK`Rs-y059)&@;$CaTXN?Ob%gL)ZBK&R-?rEWG z0?zzUnh_~xe(c5y=7MmOV1?P;*Z1!+s{96ls zc!{sKQK#Y#>lncl26$!!r+^`z7{x}w;&Xd()`KxV{vKiqCfL#k=s<=)J%J4H7O$Gb zVgN_E_mD_qhQGq_xQYXO|0np>z!@I@43$8TO&`Y#;C%5o8iA8M@&(#7oa2Kh@RNp% zyloaUUeUJn6>i`fCie+pR* zikV@z(nwW5csg^R)cmP5bJEPS)K%=mGT-zA+V!IB#S13*$2T#cx=XPYf2?Po3=;nU D0KP$` diff --git a/dev2/dev2-site b/dev2/dev2-site index 2e2ce7b..4cb89ed 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -742,6 +742,9 @@ def cmd_vhost(args): def cmd_verify(args): s = site(args.site); st = load_state(s["site"]) hp = st.get("health_path") or "/" + # Without the vhost nginx's default server answers the pinned SNI and a 200 means nothing. + if ssh("root", f"test -e /etc/nginx/sites-enabled/{shlex.quote(s['site'])} && echo yes", check=False).strip() != "yes": + die(f"no nginx vhost for {s['site']} yet: run `dev2-site vhost {s['site']}` first") ok = True for d in s["domains"]: resolve = [] if args.public else ["--resolve", f"{d}:443:{DEV2_IP}"] From 492849d1ffb5d52a31b664436b3604042602f462 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:32:38 +0000 Subject: [PATCH 022/104] sites.d: media2markdown.com repo override Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/media2markdown.com.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/media2markdown.com.json diff --git a/dev2/sites.d/media2markdown.com.json b/dev2/sites.d/media2markdown.com.json new file mode 100644 index 0000000..83ad9d6 --- /dev/null +++ b/dev2/sites.d/media2markdown.com.json @@ -0,0 +1 @@ +{"repo": "profullstack/media2markdown", "notes": "deployed to Railway with `railway up`, so the registry saw no repo; the GitHub repo is private"} From 3bd9688bdb12c3f4934aa5a57cf86bdc73ee3fba Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:34:14 +0000 Subject: [PATCH 023/104] deploy-app.sh: recreate the stack when a sibling service name fails to resolve Co-Authored-By: Claude Fable 5.1 --- dev2/templates/deploy-app.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/dev2/templates/deploy-app.sh b/dev2/templates/deploy-app.sh index 92dbb6d..53c0717 100755 --- a/dev2/templates/deploy-app.sh +++ b/dev2/templates/deploy-app.sh @@ -119,6 +119,24 @@ fi log "Starting" compose up -d --remove-orphans +# Docker's embedded DNS can keep stale alias state after a network was recreated in +# place: container names resolve, service names (`redis`) answer SERVFAIL, and the app +# hangs at boot with a green health check. Prove every sibling service resolves from +# inside the app; if not, recreate the stack once. +APP_CID=$(compose ps -q app 2>/dev/null | head -n1) +if [ -n "$APP_CID" ]; then + for svc in $(grep -E '^ [a-z][a-z0-9_-]*:$' "$ROOT/docker-compose.app.yml" | tr -d ' :' | grep -vx app); do + if docker exec "$APP_CID" sh -c 'command -v getent >/dev/null' 2>/dev/null; then + if ! docker exec "$APP_CID" sh -c "getent hosts $svc" >/dev/null 2>&1; then + log "Service '$svc' does not resolve inside the app container (stale embedded DNS): recreating the stack" + compose down --remove-orphans + compose up -d --remove-orphans + break + fi + fi + done +fi + if health; then log "Healthy on 127.0.0.1:$APP_PORT$HEALTH_PATH" { From 9b5cbbf494b80d06fd8b584e5b514aca738944a9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:38:27 +0000 Subject: [PATCH 024/104] dev2-site: db copy from plain postgres images; DNS self-check parses services only; opensocial.chat override Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 122262 -> 122360 bytes dev2/dev2-site | 5 +++-- dev2/sites.d/opensocial.chat.json | 26 ++++++++++++++++++++++ dev2/templates/deploy-app.sh | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 dev2/sites.d/opensocial.chat.json diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 4d0b71ba4e21c52f125c8ac956429c09755898af..d885d310e58af86229e27a778704371fea0ce52c 100644 GIT binary patch delta 728 zcma)#OGuPq6o&uzd~o}j0J-dQALRX8hTU{phTU{dh9z{Xit1(9O$}0&kypY?mn35R(z3V9y+9NSy(9dv z;Ln?|8x}v5@V%>xWgKnCDj~p8k+^F#4UnSveA;w+c KVuAK)2Eji)n&)Z& delta 612 zcmY+AK}b|_5QpFQf3CZ(?3Q3?AZY3#6CyIQ4wBn$NR;fVg_Rj0L@K(9Yob{e1(_id znZAQY8e)fnw4$*@LI?~BDvgp%5~VI7mY8XzTlG1rt3tJ>Yfgn8HLrH*v~;8ph|gK` z{MXRYOY*;lj%g-nH3nhHM8D!IZPKc<9j?ps2t10jf}Mb#F}bd3Nz<~`KK0AO0rlfA z7Buak%2z)vRiI3DfkPJDhm|xtDOaAiNtN$|p^w3acfkg;&}dFI-Bs+hY(cyhyeeQJ zG0!Tw^R4`tDpKag=;KN}E;4X5qD5zh7mvs}H(|vMsf(e<7TK*dEfg|(OG#{>pNtiM z9M^2zlTQik*AYogA`c!*-6qNuqcW4iTm*0A#b%}yFQq4)&rUwb`JL3b_$CWpQliyw zB^&&B9c+}Nh1`xL!w45MZY&qGUZze^3|nOPNlF}KNp~r2cKniFO-Ter(pJlWPKE5L zX8>wsp@CgcCuxlgIk+UVmkBCbB<~7a74>rQD&Jk)k%~?N&?h4XJ9PU^-8>ANF_S=| z#C0(i#Z&pxMmKP7v|9smFU diff --git a/dev2/dev2-site b/dev2/dev2-site index 4cb89ed..ae2945c 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -628,7 +628,8 @@ def cmd_db(args): dump = f"/root/dumps/{db}-{dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.dump" ssh("root", "install -d -m 700 /root/dumps") log(f"pg_dump {src_db} via ssh.railway.com -> {DEV2_HOST}:{dump}") - remote = f"PGPASSWORD=\"$PGPASSWORD\" pg_dump -Fc --no-owner --no-privileges -h localhost -U \"${{PGUSER:-postgres}}\" -d {shlex.quote(src_db)}" + # Railway's template sets PGPASSWORD/PGUSER; a plain postgres:* image sets POSTGRES_*. + remote = f"PGPASSWORD=\"${{PGPASSWORD:-$POSTGRES_PASSWORD}}\" pg_dump -Fc --no-owner --no-privileges -h localhost -U \"${{PGUSER:-${{POSTGRES_USER:-postgres}}}}\" -d {shlex.quote(src_db)}" cmd = " ".join(map(shlex.quote, rw_ssh_cmd(user, remote))) + f" | ssh -o BatchMode=yes root@{DEV2_HOST} 'cat > {dump}'" sh(cmd, capture=False, timeout=6 * 3600) size = ssh("root", f"stat -c%s {dump}").strip() @@ -648,7 +649,7 @@ def cmd_db(args): tables = ssh("root", f"docker exec -i {PG_CONTAINER} psql -U postgres -h localhost -d {shlex.quote(db)} -X -At", input=count_sql).split() q = " union all ".join(f"select '{t}', count(*) from \"{t}\"" for t in tables) if tables else "select 'none', 0" tgt = ssh("root", f"docker exec -i {PG_CONTAINER} psql -U postgres -h localhost -d {shlex.quote(db)} -X -At -F' '", input=q) - src = sh(rw_ssh_cmd(user, f"PGPASSWORD=\"$PGPASSWORD\" psql -h localhost -U \"${{PGUSER:-postgres}}\" -d {shlex.quote(src_db)} -X -At -F' '"), input=q) + src = sh(rw_ssh_cmd(user, f"PGPASSWORD=\"${{PGPASSWORD:-$POSTGRES_PASSWORD}}\" psql -h localhost -U \"${{PGUSER:-${{POSTGRES_USER:-postgres}}}}\" -d {shlex.quote(src_db)} -X -At -F' '"), input=q) tgt_c = dict(l.split(" ", 1) for l in tgt.split("\n") if l.strip()); src_c = dict(l.split(" ", 1) for l in src.split("\n") if l.strip()) bad = [(t, src_c.get(t), tgt_c.get(t)) for t in tables if src_c.get(t) != tgt_c.get(t)] note(f"{len(tables)} tables, {sum(int(v) for v in tgt_c.values())} rows on dev2") diff --git a/dev2/sites.d/opensocial.chat.json b/dev2/sites.d/opensocial.chat.json new file mode 100644 index 0000000..5980934 --- /dev/null +++ b/dev2/sites.d/opensocial.chat.json @@ -0,0 +1,26 @@ +{ + "repo": "profullstack/opensocial", + "notes": "deployed with `railway up`; three services from one Dockerfile whose CMD switches on SERVICE_ROLE (web/api/worker); own postgres:17 + redis on Railway", + "companions": [ + { + "name": "opensocial-api", + "id": "2c54f0d4-e1bd-4ac6-afc8-d6d1c9125a6e", + "volumes": [], + "db": "pg", + "redis": true + }, + { + "name": "opensocial-worker", + "id": "30fdfa55-9ded-4356-944f-734a26e8bd78", + "volumes": [], + "db": "pg", + "redis": true + } + ], + "redis": true, + "env_overrides": { + "API_INTERNAL_ORIGIN": "http://opensocial-api:3101", + "API_HOST": "0.0.0.0" + }, + "health_path": "/" +} \ No newline at end of file diff --git a/dev2/templates/deploy-app.sh b/dev2/templates/deploy-app.sh index 53c0717..e8eb1ef 100755 --- a/dev2/templates/deploy-app.sh +++ b/dev2/templates/deploy-app.sh @@ -125,7 +125,7 @@ compose up -d --remove-orphans # inside the app; if not, recreate the stack once. APP_CID=$(compose ps -q app 2>/dev/null | head -n1) if [ -n "$APP_CID" ]; then - for svc in $(grep -E '^ [a-z][a-z0-9_-]*:$' "$ROOT/docker-compose.app.yml" | tr -d ' :' | grep -vx app); do + for svc in $(awk '/^services:/{f=1;next} /^[a-z]/{f=0} f && /^ [a-z][a-z0-9_-]*:$/{gsub(/[ :]/,""); print}' "$ROOT/docker-compose.app.yml" | grep -vx app); do if docker exec "$APP_CID" sh -c 'command -v getent >/dev/null' 2>/dev/null; then if ! docker exec "$APP_CID" sh -c "getent hosts $svc" >/dev/null 2>&1; then log "Service '$svc' does not resolve inside the app container (stale embedded DNS): recreating the stack" From 2b8438139763cad638229b8ab966fa29316c262d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:39:23 +0000 Subject: [PATCH 025/104] dev2-site scaffold: pass NIXPACKS_NODE_VERSION through --env, rewrite nodejs_18, --regen Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 122360 -> 124189 bytes dev2/dev2-site | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index d885d310e58af86229e27a778704371fea0ce52c..c0c507382e26202d9d81f2936c36a418ea7abcc0 100644 GIT binary patch delta 8726 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 G1pGhN=U#vS delta 7539 zcmaJ_eO#1P_Mh|23oyew3~!;f) zRPuE8H9=^YJ+v2Srbgp=+RPq?8^{o7H#2RoG{|BLzjsJ?cPQfCA^qK<$U8%_?%Pmg zYo0hLZ8$bhkv|Oxk^Q95$dUh(W1-|5IqH9L#2A_BuI3o#YqKyvyX9#O^Y78xVuvg- z%V#BHNE{2ajb=f%cou9+U?H}|VGLb&x5k#l%(gKsY@^1O%)${cJY&7ytWbxRW#1arE zvqZ!x{%jRX!l9{>#%^f7pEf9DjK@nXxn3Hgi;`pDWBfTy{z)6Cy)#dIauIywG@aL7 zLi&^7lf^RXrFy+>Iva=jvRNkL3^pEdCd)$1VG|H@*+j%yY!YJLa3^h_l%=#5rs_;#`)ESjc7|&SNtX=d&Ed1uPeFA)AF*#PSe}Sw3P3E3hqMv$0jm zR}sa9T!X$PoNM#WCkL8D?zm}&s0@R>St~)R;@4WQgNcfBRV=`G zW#?)eE#YhD<2snDbgj#j(DuU*UXq|n`Q`eNTCgj>edu9;Rweo2M1Xd=WMhq# z!er(1jaMc4(&oSb$HykdOT{RIc0{D&Kz3PagAVT=(BY{Mks)iRtuqrpn#j zA@cf1vtmYhsNCCSV51^Yh|7n?Ojn=HRyu21iworakC?k+S)3=zJx|a$O&+~2Z#LSc zWAQFsgWl!SmoSVc-NWM?=V`fsC92whCEatwG5w#lz8Q zavAw)Irotm5!(!*^2%F25rI;VMbvauUA&TLQ5&Gn zp>a-7MS3B+Oe|CW>Q~VcOO+RGHOte!^NH}N#tqE9qg%tqvn*l&qx{tyVY0Gyf@y-s zoX&u|3k?tOOy;%DJkR9jeDYPm#5;OPE%M0+^-=Pla=j(zEZO*7w7l;-Z~3`xenA0j zl1K6cO=qAhP!7BpDzV9OPIQ!xP3iRVXbX|EAIp-fx0}NYJ(}25kA5_MzWl50kwHP7 z!On$u$ETs*qkVkE2h%?ADqq#`#-Vh?scq}VmCd={o zm*w`&k~ei1rX$U{$iwRbkM>^OAdO{uWDZ-3D}CX&V)}v+1S@ke;Rt#&`^ypn)Y>JvzC05%8td0VlF~SDFArmwjaq<{M z=9-7C-tIEfN<2*H_r*9%{drWddY9RycWGT-u3(qc*U;-NcgvV#HW~cFStD!0tl-6( z`&vZJWC#I&`PQ%J#|$HTTnL)Ang_I1nzfMJD%U-6QFiX!9A+*myssoTZ+=;IQE`4j zbwxpGSz&RJ{GXkch-BaiXa^sM#T(Bg$RgMyzbxK?t@8c5O7+}^4DzX6U&0B+{p1Nr z&y$I;ZueGrU-s@PFwTAfIF(|_sd7b6YtB9tFko;ru5mWC4(Qi5Hq|xqhe<~fNuEuk zrd4ZdTbt^tTkPu`{8bvG_~hjT56EeIqC!?sGo0WJf;SQ5IeU^JOkTZbg6!Ti$>)6( zKkEl;NDQvKZrtqw?RqYyC3|<>Wkr;f8?&XU$t-6$|?KVxQ~1TwiP{E)ITEg zV6MC1P{EP3Q}GwGiZA(=T=VtcuH9a_t9D<~^O^ktX$SKz`%eDWThTwWR=2DQ)%CIS5yA_MFK@S(@{YN73)_mj0R2_V*{Ok8dfUgd}H5uSDdCJiu zxT0)6S_UvuzIH6bFcRnSQA+UfYY-JfZ41G8(o|3IN80YsWbc!i`UD)gpv*jZO{-7A z=68zaU9&$#$P+KeNVh?*yj%*w@`200NxVc8FB0q}=p)b){ElD=O2ja;$o#`ekyns1 zpmXvy_6}1+le2M6ZCYLXN-Iy2PkeY4;*{N24uat6l8i&2DW~H4w$*ve) zN_+=t)pZ0J@}cWb!2ii}ZY(nu;q0Gq*m2Zb96AD{l(RQ7d>})<{nvK=A{@(>`QM(y ze$?Oh>npIIqa6GD>j0PJj?W)}EAls=uQrB~0R(wU+ZW|v!ir48|3ixA$!GtOtFOZT zLM7zO(*aN+U;nXHIu0WH02;gIb|fs9x82Tx`;`9MWm2jSawhAjq|<>0}<{k0B(RQVw)D4pv-+s3#Tza#7RBOgvW%|04WgVPBTCzz)?}- z1?lj(==Op)d{>hOPT-K_{kPMQ{)jg$1gE&<4Fa}_Rv-A$u$CD9D(}0U4&7p}5k|oS z;w>Z0iun^6Q%I0O3#O&=P-@ZM@d^=Rf*5?4oN0nODO(cHnjjX+#XBZA9?^~*1KQde z#!uq>0jTBkXil}*>kE_h+pt~YzUB)L`#`5y5eECCh{Q`s*CGNcS@?9KO%o%+VWM8f zw(PD5hxIyZ1kt03?H2^&sGEcnI=q}U++xkMba-2=b@djjC5<-=h=xhgb;Q#`unQTJ z13BH+oRT;yD368;-0j|Im}A<7JU#e#I3zV>BMv^6U_iW)0EKYGtxH4)sISDYx+{{vF6j>< z_X+p0Wa!euagk$%DUjjrutG9Gzx&xV7!9!7eJULi!9c9MSA3fRWpF^u9S7s}N04;d zy03>MHe$;=jZ0 zfYj>X9|-?4=rB>P;g@OnQz4hZGBo<@GAM`jVs<$+f?K>?4mtWSaF)jXZ8=;q7}Rx+ z73Y^gi0&rRMb{FD6UL?R0|=pC2I=}AkQ(KlvJB$&@V;1H3zOgj_s&{eB4mlHb+AVV zSb)nxl_6p)gORwBK89hf6(2BIZC10N-r=lq+B*zs>XYY)ayzt`=sFt=a)0rj9TGqi zH|?k;6vd^wBkSQAkQV6F_m&-}f`xY@{0M$-LlgW7bbZmiUgFDUv?Wj^w!k;p=Sf%! z!TYp)eDT-t262MCHu~e4m+jsof{h_7NtO# z``OhnMvEultP}GZj1u3kffRi_N-1$C-4AVA_)_d#2Q7);lYS?fo$Nd8G^=YINtgDtcMJJDsrrJ zPg@T*fOxU93lXwjP*zZsU!7Z0SUta>ia$#thOQyChNLQys~2~yXm|>y7~*Jpy7=8w zunx0? zqd>zK+{^r1@p=y&H_)zfi`cLS?9oG6?HGzk)(&PkKbrXm0ej)7hx?tqFb}4QpnXt_ zapT+vYxGW(^Mw1$eXzIH}nRQFxINg`+$GQ{o|z#d79 z?Fh2B-EYZTP`o(5I)7I6;?f1#e2$1Z0Gpsq>^lHc;SKTe0lcbfMRYHwTP*H-VM{Cp z7N_MF@c?QrM;Opm=9U)m!Oa<*`=%)D16NoPu?$^D-kwuqF&=+{osa~Kkh8U&HdHQ2g{7 z#Dhh|yYYmovopjzH>85geZL#@fZAP0Fac~v!Kd84N8q16;|CW^ni}rvY@t>&!3KhR zLV1$tKLJU}v{-H@C#&ppsjG?@vV98CnuX;gB!xFon>>ionnmpi1V*v^BqoGl@%l+9 z2v*P40t{v3QJuI~1ilSsgPLaNh}5@nA2Y<#x8YxK#a;SGcoNKNf*mS#)GYha&Uay+ zfs=fPD0vsA>bGJ0l6%{`FkNTzLHXF#d~?%BbZ&rn_YY@a1BjOz!CDZFboEy+u|Vgj zVZ9)yWmqrR88@sK;*1~a)q9I07qHR{-FXomGIJ9ch0j@tz|!v6If&JVvGBeKQF0Cz z3a@i`tKT6s`Ishh60q4{B7YwZMmT8yFqVc5bhxH?U+FFx1pJ zo7&q3>jL#MsK@Ze0exEyZ*ZtpLPvnr+SYEZ!#5^tU8B9O1Y0 ztk$~rwz@T(+uQ2a@qd#w62E~D9z>vC*a>Q-KOn3hfxU~a_5t4-#;VccH7o6HPF_s8 zeLaC%mJC%SYBBQj>SM5IQ4i5QRmvz7%;^H)be1?JPfkgUZ$c+>N+=x501?{ukd_?oEyAyB_- zn2B9|Kc|liTy@gmiy?K@_h$8tQ7tOfvT+v;sGg#)U0i*cQ9U~3xO;r7x-edwgWOcH z@e~58$_DQkwMwX@u6l{8z8-S3>S5LOPl?7)At~l1X1( zA8X9+L!aW;Nw0}e4F7b3i2e-z5*-dHH#0Rl-_0Etw2@bJ-kWCM)M|85+>B!WA4kl~ A`~Uy| diff --git a/dev2/dev2-site b/dev2/dev2-site index ae2945c..b7bb49c 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -25,6 +25,7 @@ reproduces the box. State per site lives in ~/.local/state/dev2-fleet/.jso """ import argparse import datetime as dt +import glob import json import os import re @@ -380,14 +381,22 @@ def cmd_scaffold(args): sh(["git", "-C", d, "worktree", "add", "-q", "-B", wbranch, wt, f"origin/{branch}"]) try: cfg = repo_config(wt) + if getattr(args, "regen", False) and os.path.isdir(os.path.join(wt, ".nixpacks")): + shutil.rmtree(os.path.join(wt, ".nixpacks")); note("removed the previous .nixpacks to regenerate it") df = find_dockerfile(wt, cfg) if not df: log("No Dockerfile: generating one with nixpacks (the same builder Railway used)") nix = shutil.which("nixpacks") or os.path.expanduser("~/.local/bin/nixpacks") env = dict(os.environ) if cfg.get("startCommand"): env["NIXPACKS_START_CMD"] = cfg["startCommand"] - env.setdefault("NIXPACKS_NODE_VERSION", s.get("node_version", "22")) # nixpacks defaults to 18, too old for Next 16 - sh([nix, "build", wt, "--out", wt], env=env) + # nixpacks reads NIXPACKS_* from the build environment it is given (--env), not + # from its own process environment; the default Node 18 is too old for Next 16. + node_v = s.get("node_version", "22") + sh([nix, "build", wt, "--out", wt, "--env", f"NIXPACKS_NODE_VERSION={node_v}"], env=env) + for nf in glob.glob(os.path.join(wt, ".nixpacks", "*.nix")): + txt = open(nf).read() + if "nodejs_18" in txt and not re.search(r'"node"\s*:\s*"[^"]*18', open(os.path.join(wt, "package.json")).read() if os.path.exists(os.path.join(wt, "package.json")) else ""): + open(nf, "w").write(txt.replace("nodejs_18", f"nodejs_{node_v}")); note(f"{os.path.basename(nf)}: nodejs_18 -> nodejs_{node_v}") # Public build-time variables (NEXT_PUBLIC_*, VITE_*, ...) are baked into the bundle at # `build`; Railway had them in the build environment. Declare them as ARGs so the # compose build args (provision) reach the build, then keep them as ENV for runtime. @@ -839,7 +848,7 @@ def cmd_migrate(args): if name == "dns" and not args.yes and not args.__dict__.get("no_confirm"): if not sys.stdin.isatty(): die("dns flip needs --yes when non-interactive") if input(f"Flip DNS for {', '.join(s['domains'])} to dev2 now? [y/N] ").strip().lower() != "y": die("stopped before DNS") - ns = argparse.Namespace(site=s["site"], ref=None, force=(name == "refresh-db"), public=(name == "verify-public"), no_cert_check=False, redo=False, yes=True) + ns = argparse.Namespace(site=s["site"], ref=None, force=(name == "refresh-db"), public=(name == "verify-public"), no_cert_check=False, redo=False, yes=True, regen=False, ignore_logs=False) fn(ns) st = load_state(s["site"]) mark(s["site"], "migrated") @@ -1023,7 +1032,8 @@ def main(): sub.add_parser("registry").set_defaults(fn=cmd_registry) sub.add_parser("firewall").set_defaults(fn=cmd_firewall) p = sub.add_parser("list"); p.add_argument("--kind"); p.add_argument("--status"); p.set_defaults(fn=cmd_list) - for name, fn in (("show", cmd_show), ("scaffold", cmd_scaffold), ("provision", cmd_provision), ("volumes", cmd_volumes), ("cert", cmd_cert), + p = sub.add_parser("scaffold"); p.add_argument("site"); p.add_argument("--regen", action="store_true", help="regenerate .nixpacks even if the branch already has one"); p.set_defaults(fn=cmd_scaffold) + for name, fn in (("show", cmd_show), ("provision", cmd_provision), ("volumes", cmd_volumes), ("cert", cmd_cert), ("dns", cmd_dns), ("railway-stop", cmd_railway_stop), ("merge", cmd_merge)): p = sub.add_parser(name); p.add_argument("site"); p.set_defaults(fn=fn) p = sub.add_parser("db"); p.add_argument("site"); p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_db) From 2086bee653bf42bc6883d0bc40056f8d402eb9a1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:43:08 +0000 Subject: [PATCH 026/104] dev2-site scaffold: honour RAILWAY_DOCKERFILE_PATH and RAILPACK_*/NIXPACKS_* command variables Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 124189 -> 125470 bytes dev2/dev2-site | 18 ++++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index c0c507382e26202d9d81f2936c36a418ea7abcc0..79018862cb0b932ebf80db1fc724734d33d1411c 100644 GIT binary patch delta 7097 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 CLRO0a delta 5978 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 CNC^-C diff --git a/dev2/dev2-site b/dev2/dev2-site index b7bb49c..b3009fb 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -381,6 +381,14 @@ def cmd_scaffold(args): sh(["git", "-C", d, "worktree", "add", "-q", "-B", wbranch, wt, f"origin/{branch}"]) try: cfg = repo_config(wt) + # Railway keeps build settings in service variables too: a custom Dockerfile path and + # the railpack/nixpacks command overrides. Honour them before guessing. + rvars = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + if rvars.get("RAILWAY_DOCKERFILE_PATH") and os.path.exists(os.path.join(wt, rvars["RAILWAY_DOCKERFILE_PATH"])): + cfg["dockerfilePath"] = rvars["RAILWAY_DOCKERFILE_PATH"] + for rk, nk in (("RAILPACK_INSTALL_CMD", "NIXPACKS_INSTALL_CMD"), ("RAILPACK_BUILD_CMD", "NIXPACKS_BUILD_CMD"), ("RAILPACK_START_CMD", "NIXPACKS_START_CMD"), + ("NIXPACKS_INSTALL_CMD", "NIXPACKS_INSTALL_CMD"), ("NIXPACKS_BUILD_CMD", "NIXPACKS_BUILD_CMD"), ("NIXPACKS_START_CMD", "NIXPACKS_START_CMD"), ("NIXPACKS_NODE_VERSION", "NIXPACKS_NODE_VERSION")): + if isinstance(rvars.get(rk), str) and rvars[rk]: cfg[nk] = rvars[rk] if getattr(args, "regen", False) and os.path.isdir(os.path.join(wt, ".nixpacks")): shutil.rmtree(os.path.join(wt, ".nixpacks")); note("removed the previous .nixpacks to regenerate it") df = find_dockerfile(wt, cfg) @@ -391,8 +399,14 @@ def cmd_scaffold(args): if cfg.get("startCommand"): env["NIXPACKS_START_CMD"] = cfg["startCommand"] # nixpacks reads NIXPACKS_* from the build environment it is given (--env), not # from its own process environment; the default Node 18 is too old for Next 16. - node_v = s.get("node_version", "22") - sh([nix, "build", wt, "--out", wt, "--env", f"NIXPACKS_NODE_VERSION={node_v}"], env=env) + node_v = s.get("node_version") or cfg.get("NIXPACKS_NODE_VERSION") or "22" + nix_env = [f"NIXPACKS_NODE_VERSION={node_v}"] + if cfg.get("startCommand") and not cfg.get("NIXPACKS_START_CMD"): cfg["NIXPACKS_START_CMD"] = cfg["startCommand"] + for k in ("NIXPACKS_INSTALL_CMD", "NIXPACKS_BUILD_CMD", "NIXPACKS_START_CMD"): + if cfg.get(k): nix_env.append(f"{k}={cfg[k]}"); note(f"nixpacks {k}: {cfg[k]}") + cmd = [nix, "build", wt, "--out", wt] + for e in nix_env: cmd += ["--env", e] + sh(cmd, env=env) for nf in glob.glob(os.path.join(wt, ".nixpacks", "*.nix")): txt = open(nf).read() if "nodejs_18" in txt and not re.search(r'"node"\s*:\s*"[^"]*18', open(os.path.join(wt, "package.json")).read() if os.path.exists(os.path.join(wt, "package.json")) else ""): From 0ce1b24e91a8c70c78ab009b291b68546857d006 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:44:56 +0000 Subject: [PATCH 027/104] dev2-site scaffold: pin corepack 0.36 in nixpacks Dockerfiles Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 125470 -> 125841 bytes dev2/dev2-site | 8 +++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 79018862cb0b932ebf80db1fc724734d33d1411c..eb9773b6f6a912291ac99474912c71c405c372bf 100644 GIT binary patch delta 4744 zcmZ8j33yXSl2+;INyfs+hcLb^gO3;T%AQ zd;uQ~G&3162C`vd7BJf3<$w^F83H5(NSGxUCs{%UzMW*49L!F#*-5tR8I1}0KK-w{ zj_#`J>gwKm&Cq$>Fyh^ikYEk}9jZ9d_)TKphy$Cn_aj(eG)(b-v8hCa_x+PzxTn*~ z23po$O7piQ(v$58u$10x&l=&2@D!pg{u)auq(wwODdox$y}}^R7b!=|5i2KaG$fVw zbd1%?ku>SWk*2&}7*qg_Y$g{T%vu5EI7M52KH4kq~jVslRQd} zlA~yMhZXYanyn-0<&TEZe{3Bo%$7>jaa3oiaZX?rU-SdN%@9owbf$;Q_2mA`zi4`+ za~v$7XGTZRxGr;8v{V|fo22o@$T2j(Yb2D>yto*hWb*|-m@0x!{pBP&&}GhD6qrgX z4}=ODPgQ^`gRBosSk1%s#ronrO9N7=f{vr{Fprke6mh;R^lE7~jg799V*@t6cu!bB zyRn+)Z=GdYAUze>H)-MFMHF_Fh@9uCq2)W$gh7|hS~(tl4we=dX_oQL@g+R$IV8~7 zooPaqoIs0rCYhEvn@J5(UbkJ_1DXZ7)L;d@2Mjo3#=e{$tUaT4ASVs8>yYuBtA3_>?~+nHS2^bO)I3g`9Ec4!c4E((L|ihcE`%Rtm@41 z)lzk4wUpsC4RJFk`*Z;*!pXy*d zeRtm%x*xgo^mjeEV4;8SX$>;CoUJvUbsoBMf2PiYvNg98=(gKIQNtapr5d-xzG6kw zDv1CuSr6|4?sNYB{rdrC`+Wyn1aY_X!|-Gpb|`A`eZ3|$yuEd6Yx~Bn8+SXdnMMyp z#_XOz8Yh&{MkbGs9_gcXQfBKm^f3*Kktt&Nadfa!wQ1==RV9z>Dn;i6xExT1!&QIXv04L&^rDgp z|3|-9MvISeoojx}Yrhg9)&It+X*&4M|MxS&0K)O#KM!!<|I0sA0%XyFKW6E(xGu@| zho2h&{dlA&(EIPq6sK_VZ~ol#1A;h%lM#N)dm~3c2F>YDgmAjBzYh{wBS^}4}uFk!R+_JkPELc!3bl) zs$?3W7T{IZIUL>?{v1xX31PFA^$3v31zSR(47RXqA;91;TQvgyso#nkciBDx#=#NR zZGr@NfxTsdd7&5vF=JSm3sTP#Lm?5;SZ*k6ffejrD4dCUhD-W|TDwFpad-QnmSD3d zYgl&}Oc8rHy;>OvgXe}pJ6j$LJu%246&Tt)gy}eaIr3~QHV$&cmpOe*Sr`W!b=C~z z$D!J@2t_zd;|5-X$4)HPLW?)ZVs)&rSS^`EW{?Dv<62P1hp?L~riYR;T;M4L8(WYB z3wbJglVFzdRj%^}|GV8*?62= zT|mq08r4$yBn?so_>-c`;K>zFanT3L!pFg>70+_|Q|0YUcwK-(#XBC-0sg8SoB(40 zjwt6RLMrG{hn!;fCqX5gWV3T%f_Ra$?kG>^fWcrqjp8RU{6eK`23!Z@E68~lIk}1_ z7nTB?Wmoc`6i&1Fe3&lY;PU&*vV6!1;=%4HfgfNt>zD)C!wztX9Se*##d1-|T=*NT zRsJ~_Dzy3sdr)cPKF(%78;pi-w$BC``oC~1ouFK`LGrNB4Y+s-rgI6xYfM}W+u&P9 z7sE2{$JdKt0d%mLRZtHX*wHG;6My6u@yh)wxUSc$uII9=%OOIihqnL*IXR&9qhu#bJR9b~wwENzF=pydaTcO^5+>V!Dn!pb_K0={B7VIEGR9+!Ni{)21xkCnOj^%PT&+nop&N`ZaU_w5Cs!hT{leC zPek(q_HW&=9txOcA2{IOnf(~Vu)X_$a8_6k#ApY5t(kT0=ZE`krM?Hu@N?!__WFJ} zqsIi1@$BgX;EWqOxMC4C@AE=QH2MvR+8iUd|33b9(!!M^L`JKDy^9fz4>2bWz|yvLzM z2;DxY$CXw8*Rh{|2XjXt*Q@6bKK}S4MQrg2C?4FJLU!N;j7vjZZ?MHyyr^b&dF6r` zWF0b$mI^+oDYunY&FF`0vX*^+f)|>PjaB&GwX;16qz3Qenm761ZDGewL6UM$fn*^@ zbwl+4&m-|7sO8?p9)ACe^z==!8gMu3(?@_jDyWM(-JY2h1?#vM%8l9@=)VpZp%22$AT=lNaP$3ic_NWIzt zt60Vbp0Pru`U13TN2;xE=pI-5-CuUS2XpkBaGuSq;ysuq_Hg=ZrTslPX!^xa7giha zLmd|l#$wt07l(8pzeR@Hs?`I&4$lPii!M8cbp6o_Wh}G(7ayeQEak?Dq$uQ32$@&UYoQ$A$+7$Ik)6c9QgR>2ra{q9=RfNd>DO0h*roauuwEyi6PW5&ix;yb1Mbx*^Be&=y$x?HYBjj;y9=nD-fq)m(;2BlBYy%F}CZr~B kC~s=Y)Lb5B^>=s*&L|ysV6)aRAD|q*S*67NH)!Gi0q_|6Q2+n{ delta 4648 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 ish&= 10 on Node 22/24 + # (ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING). Pin a current one. + if os.path.exists(dfp): + txt = open(dfp).read() + new_txt = re.sub(r"corepack@0\.2\d\.\d+", "corepack@0.36.0", txt) + if new_txt != txt: open(dfp, "w").write(new_txt); note("corepack pinned to 0.36.0 in .nixpacks/Dockerfile") if pub and os.path.exists(dfp): lines = open(dfp).read().split("\n") at = next((i for i, l in enumerate(lines) if l.startswith("WORKDIR")), 0) + 1 From 6b2185036534d4dfe851cf909f7a893296107834 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:46:09 +0000 Subject: [PATCH 028/104] dev2-site cert: remove Railway's _acme-challenge CNAMEs before DNS-01 Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 125841 -> 127073 bytes dev2/dev2-site | 9 +++++++++ 2 files changed, 9 insertions(+) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index eb9773b6f6a912291ac99474912c71c405c372bf..e841945f44b89f02e17979bf931b304a3c252b7c 100644 GIT binary patch delta 9442 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 delta 8380 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 BV~zj- diff --git a/dev2/dev2-site b/dev2/dev2-site index 4d84459..14adaea 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -745,6 +745,15 @@ def cmd_cert(args): k, sk = porkbun_keys() cert_dir = f"/etc/nginx/ssl/{s['site']}" log(f"Certificate for {', '.join(domains)} (acme.sh, Porkbun DNS-01, issued before DNS moves)") + # Railway's managed certificates leave `_acme-challenge.` CNAMEs to + # authorize.railwaydns.net at Porkbun; a CNAME there stops acme.sh adding its TXT. + for d in domains: + zone = ".".join(d.split(".")[-2:]); host = "_acme-challenge" if d == zone else f"_acme-challenge.{d[:-len(zone)-1].lstrip('*.')}" + recs = json.loads(sh(["porkbun", "ls", zone, "--json"], check=False) or "[]") + recs = recs.get("records", recs) if isinstance(recs, dict) else recs + for r in recs: + if r.get("type") == "CNAME" and r.get("name", "").startswith("_acme-challenge") and "railwaydns" in r.get("content", ""): + sh(["porkbun", "rm", zone, r["name"][: -len(zone) - 1], "--type", "CNAME", "--yes"], check=False); note(f"removed Railway acme CNAME {r['name']}") ssh("root", "test -x /root/.acme.sh/acme.sh || curl -fsSL https://get.acme.sh | env HOME=/root sh -s email=" + ACME_EMAIL + " >/dev/null") ds = " ".join(f"-d {shlex.quote(d)}" for d in domains) out = ssh("root", f"install -d -m 0750 {cert_dir} && env HOME=/root PORKBUN_API_KEY={shlex.quote(k)} PORKBUN_SECRET_API_KEY={shlex.quote(sk)} /root/.acme.sh/acme.sh --issue --server letsencrypt --dns dns_porkbun {ds} --force >/tmp/acme-{s['site']}.log 2>&1 || {{ tail -n 25 /tmp/acme-{s['site']}.log; exit 1; }}", check=False, timeout=1800) From 7182d5b05c4054313c124e3d2f6e8d2bf1019eff Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:46:32 +0000 Subject: [PATCH 029/104] sites.d: meshhook.com on Node 24 Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/meshhook.com.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/meshhook.com.json diff --git a/dev2/sites.d/meshhook.com.json b/dev2/sites.d/meshhook.com.json new file mode 100644 index 0000000..f381a45 --- /dev/null +++ b/dev2/sites.d/meshhook.com.json @@ -0,0 +1 @@ +{"node_version": "24", "notes": "pnpm in the lockfile needs Node >= 22.13; nixpacks pinned nixpkgs has 22.11"} From 3d0e644711cc8235f60fc26ad4a4c680407311de Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:50:13 +0000 Subject: [PATCH 030/104] dev2-site: image-only sites (stock image + Railway start command, no clone/build); openaccess + openmcp overrides Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 127073 -> 128736 bytes dev2/dev2-site | 48 +++++++++++++----- .../sites.d/obscura.openmcp.logicsrc.com.json | 6 +++ dev2/sites.d/openaccess.logicsrc.com.json | 6 +++ dev2/templates/deploy-app.sh | 14 +++++ 5 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 dev2/sites.d/obscura.openmcp.logicsrc.com.json create mode 100644 dev2/sites.d/openaccess.logicsrc.com.json diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index e841945f44b89f02e17979bf931b304a3c252b7c..9ddf0d4e42525c94bdc08228860e8d1875583a70 100644 GIT binary patch delta 9173 zcma($3sh7`vbXMhm|^%0KLrE^1Y}f1V>Bu;ih{p@3gQuU`g95NUAvG@}wXIb*dztq_0%%HaIoJ znM$i5AKDSaC$0f~!~yW*wgQWUKu_{rpw9>qcI%}vEn86A$U}O! zS`=JrDO3vU=hS=}=}~F~Nj+#hp!V}5eo`di@`QE!1;MTH)5~8T&m0#`_<(e`mN>dJ zl7bCJ*g$E7OGC;eM^p+2M8DXBBMq`caAvMiUojh<|#5K$6@+eyW%>R}jid?^F$rmojAI`_a*ZQ+D|n?+p5DJaj>T~h?)I;{EM+*5V5|uAtpihkkA^GCmcYr zREE$i(vnrJ!Kj_yMfMC>o!Ni>;!Fr&#}!cw)lywWpP3a_<|K7Wp-QbHm0NH?m;T{0PzjQ8coAw+Ag1cXOi}Bgv@*Fck#_v3^A8OLia3a zUC{nO*QBnM`(|{F_f9DFuC;heC2x%NVo1d&{(kR>`_IfdbGJ8U>4lJ@E8%gMBaB?P zSJ#nvA!1Co@zs(0NA@a{?we%2V73l2o!ph)Ro@-mt@TdW=(SXPH`aJ#YA=LrzO2*l z(aDX!`^@MIx7at=mD{T9wz4`qb2{X;r>BP8qP@vV5i}*q^WRR2VY5i7@rd?mt89)c z$;#B?;r7!nL$;1RM8q2r_|zNi_F9F)%A9NwjafioF@cFlVGfa3oJ}hBiIzI{4hPhLDcgWlqdT)y;-}A0z+1~^~zfANQLR;$hY;E28 zl&T~5NiEuU#IsHNQhMrpM^5+7T<2ZC!CPM0yMB{*U6r@0+FP^PYisPS+2XC<>fPGp z&3WKrxa%u}5SI8=h@jIS?tb?(QIn6VB*^jyFNzO`ad5z*!#h7b0Q`@aAXa|brwwmWApj-OE6+)TmbDD;UvFPiVGy@k09|w>Ip+5NfpLYg~iy! z0E?~QQ1X@A;0|`{CBY?1N%tZ{+i~Xzg|GaTH{O^$gxmmvGfsXgK3-nk8z=u`mtOD4 zb|wtaPX8xbr3--z0$g1BK-}&ZZz8esZEpgk$cEnZtbw?V{TMx*iGw_(k)&mSlRMZY zQly_XRH1Mjfw9J4Tren2N~N)GgEM7FFGe5dX!%fYRQTvXj&z4eY1Qd6jEQPauLQJa z293)T_Jmr*O2Hzo3qmfTu25+V;rEJddNpF*p_C8?W)_AErj;_+;;OsD-4V7`&M^V0 zl35xnjYGbQE)>(H@lsZ$Mw)OiJ7Ae=Gg0%Wr$B79G_fWVxfrEMficzNaCXtn(p>>^ zV&aZ)8Ci%l*%g6771Kz7y>xe{#-@{|48y40;nGyZ1f1p`S2&KE=AzUv9q~b=8rEmH z!Vu=T3<&QK!(> zd`12|IbvRt+7%^@k#F@SFBv*r&Cuy~s@&1eDFJ0%T4nHjT&M-E=zyCjQ}3Vhy16t_ z`PFj?&7smlQs)4k5vjOioD|uV;)+3c6a$VE;G?)mtb4=|d9jMT0ZKI7qE7X2*=_l1 zVaSL`WyJlRVzX2*bc#brLKKohRN`MyIPVEeR9YPHV`a<|9J6#7*XaRDXHKBz3R1?% zbr-eGMMKd2%Nk&ax)cu$O6XHD&Y1&D`aM!w=2GFo&maj0OAcuLy_EKyvxZO(4+SHg zxq-e&*GOsk(A`))S1vjdzxVTKzF`;Wo8yLg#)_c}{}ZoWIjnEhu-58ftzqsX4Ij5= z7-cE>puG99;7GK^aKg2Lu1xG&H*D1U&bYv4R~%1K-X90%48{R>{J#z}QW;)6#!DN9 z2O4*RE3P_UE;^rZj}jhT35~!q^ujPcBc*a_W5B4A1-GgqUXmnhrJi4JfM`*wkaKrO zPn2d$|GqtII)ixo6iCaIYAM38>_KlGTuj$M8l+V`pU}K{m|bkHcsef+-5eO81k3?L zBa?mDn7U!D;VW0KgioeG2l1AiouPx4cV_YT+CiT^TWT1_q;XhlILj@aL2}WZ=xNec zX`9sKq&tC9D%C?HzB^HR;9qHdB)+*qlw7VvgziBG*m`hKZiQGu$7~FlN<>L=Cl6c0 zW@)=CX>jc-wXWoYKO0nxLa1|KTT4H8E(%Pux&UkZwWlFMzT6%qzw-M~%?|PazIO*i zCH(H#862_1IKQ?@g;stik3mm75$+yQRi|70EF14Unl_a?OjS;^smf+@R5`7V z2~svAH5eTQ*h_c-W*-x{7EG{p`3MNBxK6-ZcI*|Nz_jf^&SHI2>g-&8IjGFCZd5%X_r@tGY|S5`^i+BEn^234NA}rQ+ArT1%~BaUFfui5uxN-XeY{2$_<2 zIet{f#;%l?()XozhxVmTZ9bnm^XTaI;+7RHs>=z<_-=FWdELmHVR0R)&tyE2(G}kp zKmL6Dtk*)0799@n3!9Bp;l@3Ut&Q!q7s5whO`g!rUTxgp*i+q?oqImJeWudt*Cvea4jY#-gsGmzM8a-aT#7b7j~%7Ivck zOzg>Rebb6sw3kD}_pE4L(O%dWntDDo=lSX$W6#{ajA=)Wy%{-p1v>rN?B=g^f-drM zQd+Mrp+&dXd^0k-UFyj1TG5@`U9o>*caC@TJx60t#Cu~3yd|qIM6S6MxuG|5Lto@Z zZ$)iyWNk~(<=B+2l&*RE%$HIp_ohznnR_(4FZDie$}Dg6>=yl%>9gBQI?NpjUAbN5 z-P(OsU8Ozh-l!?w1$ZmQt5vwvyVmM0t?*Vnw*$Zl$Or z%0Ms|!kuqN3W+P>@88`e@BFJJ<7{+DX-4q-BTc2_bQgouOD73`n517PsQxe`XKjw^ zYK{i$7D28{h?Y~oTnvSB{g-x4E&APN`O`07!uF%L_i5^}U3Yl$SFecRIDGw^^#E3R z_IJ4&C*s|QAO7xaI6Taks^Ji9rhX2Bz5Af%3h=Di|?Rj=Ini7k0@8&BrAPyGu`V2_t zbr}%PdomydGWl^r97BYBY=(ZSLzFallF+(UPCZUWDN+z7zQC19r<{%TPJB7^7fdO^ zJZ21Jg&KyCh43X~VBGZ3POTIsg&&Lv9Q)mBC#{B3@a5J>xoI#BNs%r!9>fgJ5j?j6 zmv`Jrh#VX3QXPy5Tr8AVu-L_{5IL}q$f`;Nrzc3J;@^*fF)*K}WlBwG; zM>G2-&-Eit)SqrR*>Gmnsb+uMTU~Rh+TL4j_trIDsNT|Bx)FEegODeBTFmgJ2%mel zj7Rs>yn{kN^_)&`LPlN(3oZ#E1!d7(sJ5#|7qF&`mn+7QWM?CYULk4Jx zmc7H-4A>2C^B-owGQ%}Q-NB!uLd9opfOtzHbeBd^B@Nd zuTt0hWTp9@Q~6K|@GeiD4+U_V*UpC=?O(A!Lgjg7K1|V}Q{7ks{{zeTwWTl#4tR`3 zFaw~#Q&0>`MQy(aKU;#cUB=%pfmC>j-!6f1njevg$&tH98a$W~@S|wtPH)A-;a_%$<@h6#c29Q zO9?f2%f@}39S}orat$!okSHZeN%%`Vn9Q>qVfu)vcp(4h)3u2=H$t*8Wk`fc4a^}F zB;{`n<BhpF#fj250-HLH&2LAy&G2F#$fmzLC`lHBI@~CkH-bXT4Ob!%B zO%K(6Pv91T?+F|vu$6jaasAj>0$Z?&r%{EEf}m*Bls3cUsHv)}v*Hn0c|y;kCv@eI zJKkion>2LHGxL30fTzDfTseXNA@BtPpPE@EJG)KoH%ZWAwxvis@;5E%9yj8TE-qZ(+SRpIw;k@HV(T9Ze7g z+9<)8_Fc5+&;t++hBM(I3&q$o86gXUJYE;fQel{j*bd7rR-a?IHDA`ExrVU6Y4He&zXKCyZ7>9RIonR>C~K zuoWh17GTdyo(EfD4Zywp%5EqKo=N-(yyD4z1nvgS609HcY-)pfI%wvv{u1i(ERpg! zK7P*dJpDMl3E=V{Gt`g6D2%=B9G1d4ex1W+Z3!`3&PzHF^#p&R1M)E?|G5LE!UCT9 z1QZyQHGPuidOzRx1mHOcM;!LN`2-Xippl2Z0Bh3kCnic%E}+^mT8*&c{AKxz^U89U zE-br0e>M9pZ+ihoY72?eef;GYV7hh|5ijDmUVv>Ft1T~r1zzW;Uxc<%h1lZ@TA5qC zc;VvtK23gc@si@j>@4|D|B2~hH|CU&JO^IF<$1LHbx8U2fz;5!qm*LF-wf+DsLiI}yz-7%|WN^+i=^)hU?)y0oYYNVCG+9+hoL>l4)+ilI3}2 zd2`B^7Z=QA5?}W^2G|x_p&9TUPdb7}N;fY%0(r2LKYs+;QnryUr;y=dHlC^vAn>Wl zIzvn{1OCR_Jm8A0Arj?Wq4*sw7h9s@wlZA%4xV!qri!~&{N!!#Uh^ap{Ka$p zC^V=dp70an2)`11&M~+@jId2wyxKbLCbon>c?|OXYgfoWI|kWiqHPK`EzVzAwqVJ! zl9_A=Aq=La*zjLr+5KdR0)Fptn21_!J`N22gMV@yGBjOC`*)Awx3EYRuZUM9S0I+{ zB%pYEC%2!3(UJXvl!-VuS!puQpM-U=go|&%EU4gf-hwok$t&LilU5mB$RB+R#=#Sw zH{Jpbpv*Tf5dFCAK5o45aId~3NV&;#0?s-@gvWFP96R-;Ws}e}!p7Ri^rQ>Fp+S7L)o(pdt z%m_*{_(}9vGRJjP&kXD^F;dcoGP&)jqZxb}TRE$AC`qI#JS)p)&$8fuh*_3RR!fbN zRho2JS#=E?YpX0xp;=i~l{P!GX4Tqp45gb2U(n{NN`{wzB=XmK2Xo6h+ z`x}r_I+$RT+%i0wFeL{mR{TlQq13@B05CEjqaDJ?PuWh2-{d_kj~CyB)xodP@)MZq zdHp7=&`kRr>zI%cn#IMp=$~ja+@ebcYgFoNqTj^iH)heNnx9{?*u0g!!RLN~XZ~7V z^97zmZTuHsfZe)Uc2+Suy*~z~{m#Fa4$DGtw0+3?8LBlCF zQ;hi`~3E4_X+!S z8#SW-DV;;55bP?^Ao_O*(upS{c>4{|If(ch3tSc1+hno28 zYB7Xx-oLnCXs=>K(uNw)NweQc2$4yS0f`X=G2E`EjnV?^g=2Uqdio=dsXZeQI?GkG zRV0aJ$sIcB@G}w54e3;8)izncfXx?AH8f%!NY$c z+;10L=9)_t8(d454QSs8c|-Ze^fQysq@1a^l2wA}Z|_iOJrNcGt%gK=8n)HcTV^Jn zoc`V^0DtMuZx+QcEsA3UNXGV%V1FDDX=0)zo|~+>OSmNI;JKJp!&+UJwnOU*TXsa^H%+ z1+IzfUDX?1>+4-1o33aZr0nxwN*Nb&PX6q|5r|W>r%07r0!5@*8G%VSHR+|@AH*+Y z@6pmd!pfSe6pNZhaiwa&}SV_`UT2Qo{eXkpcLP1~BAtQIx{E;KE z0{^ZGRBwl`1%kKZrMio$Qvb!ez;6UWw_I^cDFhln_0yiL{qTZL*5*5IBphF$&vzMyo9)%#J7J2(eYzZ7=>lBqz7|9@6P@4-Xi5NhzXlh_>CJ7zPTQ7yCjlQ zAawcHDe8qf#X3Kf>2LQJlL_CW=+vnY>+q*K;FabGu#NZbA*P6_;&@qYL{R|{Cx~hF zYB9av_ZDmUI*zC}zmyd|NI=wTTKb(@RI(jv#v=^B2wbAWS*+s9Z6t zSKX);vwbj1N3b{vF+jQ^{bYME!bj{>Kc*nwZv;Qp9)Or>c0IzJ-T+64En`Igb%Hou zeAJfdJq{Ag@R=ppm5B4enY}u%s#q&P%q8*o_kjt!LhF>W5z(JyM5!kwVrlMZ5y$F+ z3GE2AhxRBtH7cRgh{jbqLOtfi)rqwFKsnNFmc0LLTeerj&6A|<_Aq6rm@K{aQ9}7x z&DCQy?^Qa&Y*V~kc8x4&4!UQqJ!|e#SQAWy- zJzNg5k?`=!S&P}_h;T%X*)KxgPvH$Dv|)a)%6IS1LX|LvBShwy*Q;m~=Z{r!42eM| zDZoSg0EKOuS5dLh8?7?W0_0ifBXzo$(l*08vU~ES0q3Z1(U^r1AA3dOYH_d1JHrIc zeISDJ7bo5|N0e>$gS9Ae(bzQFJeSf!;mM9R$2cFqFCOd5hhnnCXRXv{R^~JFi3rh` zuiS?+V9CQFmNw3N2t2MY>(Ym5Gsn^{^AWtfH`*s@Vx#56pEow~9nn8rP{bA3olOu| z`j!+&jD4Im`Nwf>o+@aM*#T^VPVf;ED^`fB>?1}OS7V~et`RH6s`>{V4m$5aB#3Lg zCq}HdLQoR^urrJgn`T3=pZB;P$EJ%lRQ6c*XdI6X3Ga@cU}zI-eT-LUkH(_tlQ)eZ zVyEe&p6J74@tOHXz(%>O*2{1c$rc-W1HAg8)V8sw-s~f2i_gqg?AFmkqma?NVVl?} zHrce^1gKYyy%ijBV)Mh6kULe6DPlG%#O?Mtgltp-&aAdf>G&s6i;a%>F*~J*HnGKS z9QAU&#vb3lWAtcLYR7nbr)_~(tIb9Hb}NLzVydgst3A3|XqH{xXd&}^eS~Mjw%+sL zx_s-1yX{K5&aS5`pAuib^08lN`{90}zx5bivh;wk8!uOoFW8-)k$fBM6jB7+JP(Z^ zqzY_-BNng4EL(sVH>#ev)>ilc8|zh7^jjwiF@h%q3-CbeeU332^3`4r5Gn;xaKt_q z+YOx;J)@nlQ`n`f6n27X&&jsyQz7O$HE#b)ppU?D0@(Hn>=kL&jTC9ijYlC@dg;a% zDdn?9$dR7=?B$b{zyDYXv!t9qb*8&@&8%rhgSDZl(WGVtcx!khyh0LWsU+W&CSCv2 zl$d`fWEX+M1b#w*lw~ud$zPPh9OK3+Dnsl=q z5~TK<$^6H!gGM@fGssYbD#zi^dRoiNJ0YHjn;=}echlXbkLa%D8+$e$s2Yk)zZ{v{ zU-CxCvGO7PtoHfChJdb|j-2kyD~5!RV<#SY%+c1BP_*mvz%$ns{XCfO05#>cG-V$1iq9TU4y+i8pXoB@x`Jo57hjt;t2?~Ap?ggq^dRJyLKfXtJwdm1}jS zt7^lg)ithFwXWKYu6gx}_9E%xUyA%zDsBlvXvN7ne|5mg;jfZZ!7D*9mBP1oLIvY0 zxPLcG(%q>`IcM~*)al<3O{@&mUN(=f3>U72>#9E4|t zwTKR>k{a&5g7c7jht&-@Uw6`Z@3;asoV46u53o*p_uG8+R>W^UssHXm5Lo#Y9h`ve z{24vuK?^^phu0{*Gkui%p-U<$m-4@5#dJjzIlRi z$u=zK&t^duyw2asf>ixUG{FV@SwG@sosgKL59~_sNWZM#;3*F-W7be)Hckf~aps)J zc_V#j%6y!i*?z8Nu;u*fvu!xR+;MKmhOZQG)mi=sMw9v-6nNQrcnVk)>a#e$?lj~; zi3Z-}t7pM3C~#hy1tw5@qi}vd8kw37{cb_6}c^2m9ey{AwO7R}UlVk4|$w zJgzmIq2;-BwgUcUF}$F>sSuni7eOu`=?gIKcJX9D_}CbYpp_Mi}_1c zkPLnNToq&(?jvmgzHV68D5$O`IOEp96)n8SZ`VOaf}NBpMt89)0&mjvHw3Pcg6DXy z2y-4ij|h{>tR1#ZZ9tMc(-9}w|H0v{q+z&<0st0e1t1wSFeT0C8#1y(>0 zud_hyxT{3^5dycSwx(`NOEa?&@$dL27BH)osOSV|&_?)36C>Lx4=Gy%8k=m1R$EhZ zb4~453-kNsHb|g4k=O{?>JVhfbFOHFLmH^!cWv;ZY8;}ZkX2#)XbTu&4L{d{o{Ql( zTi|I}&YN~Xvqnz&@%#@vU_3-~!%j#I%s{TC__Ma-5#%g4I;(bq0~AYC^j)KbkKYad zh!4W|F_m2PDK&k=|S1JCD3Z=PWqM)6~N zV1C%RE!b>ex;=P-6{9(RcbIR85o5$y{KfRg@p3!Nhzi9HZS-Zy+g};^Av+`l#gEDN zgpo}HKun~b%kv$B|IH3jQNEuowDi7(oGIMsfG9}j(;P6jG<77cKc+CqAaIe~TSi`w zrs)8UeoNr@1nv^}4+6VrH5l#BQVHzDDZlRkKK^xD?Iv)Kz~>0uDrOOz80M6~P7~i9 z{)@+8_gc9QQzOBsTrw&cjIxqZPBF>^Mx~fhm@x_*MoxEYHa2XvSs2SA4Am}KvRhkS zEjHBIs;lJ>Z@i-oo;OTG5)psaFFo1Bi`(I-GwN{&22F?%l6p7PX?_C2!T(%_e^Gqo zd4+#bN~E;(yGUo<7PdeTAAA-f4Dw0os}#G* zZ#)Z+YL1e|Pw<3J7_Xj#Y)?3gJ0V30VSHN`tiq!CUKdPK&%+hT`CS)O0X)iA_CSe# zI-(eX7o5j>U@EAKaop?t{5e>lgt2Lynlchr&Rv9hjwc<0LRiM@4?zyh<1Ze95`*lfpOCJF{N5qRS2rTZA!lwM zlp3IscN~MN`Gq7zE`1AV^j}Er4qR5ays)gGI)6!V_2R-x<|Gl1kW;4+m`1=v=Ah1j zJ<2Ea!wk&~VxP|~{jeJr^6ULjhaEuTuQ18i9S4Re*Kvpexp9bi{#C5k7o2IYqFctI zKH$4vgFy`$g-z#k2fz|HTD3pL%}h#91;?+50{euY8GzGX>Zeb@B0y`LfMNBsDBzUy z`0LQD4c&umt$MSy1uIpJ)naCLo_z}9B4q15Npdpj6m*odHX4BlVgh?atrl8Z$kk*M(68HvXZ)IQBoP3K*JUUZq=&%Wo7IF$$!Sf zdxLA=0ehr{NJd)_S@BA7T2NJebli1K`Yf5CrJnAeIdYnIlohgVvXD~A=V2Wz;O*yOE^Odu&O<6p=YKd4i5i)^fEzAA z21w577eEcT`|S5&qUI0^{z~cG_CDO!P4S$VwtDQrrcco5aRQvcLlIiWd#*s5iB8LQ zk&9*WYMRO-MwMSexX1YoBoA$;F}cyCV2f#7!eg$&FR|gdbQK~ra=ly6|8f;=Rp2!K z4!RT(a&;SRHRTd_pAQbhB25QL*v~_*!A#A|IQ~xQTzUdF+ECXT zU^X|@H#RYgd218$P-)-j_S@D_&+uhX?h#rwX0u$rpQO!xPT)BNt-*x0T5RU#8mo0@ z6BF4Bgf^h)CacZd)VOsw>m>|sQP&1", check=False, timeout=3600) print(out[-4000:]) @@ -873,6 +892,7 @@ def cmd_migrate(args): ("railway-stop", cmd_railway_stop), ("merge", cmd_merge), ("verify-public", cmd_verify)] for step in filter(None, steps): name, fn = step + if name in ("scaffold", "merge") and not s.get("repo"): note(f"{name}: skipped (image-only site, no repo)"); continue if name in st["steps"] and not args.redo: note(f"{name}: done {st['steps'][name]}"); continue if name == "dns" and not args.yes and not args.__dict__.get("no_confirm"): if not sys.stdin.isatty(): die("dns flip needs --yes when non-interactive") diff --git a/dev2/sites.d/obscura.openmcp.logicsrc.com.json b/dev2/sites.d/obscura.openmcp.logicsrc.com.json new file mode 100644 index 0000000..b71a557 --- /dev/null +++ b/dev2/sites.d/obscura.openmcp.logicsrc.com.json @@ -0,0 +1,6 @@ +{ + "image": "node:24-bookworm-slim", + "start_command": "sh -c 'node -e \"fetch(process.argv[1]).then(r=>{if(!r.ok)throw new Error(r.status);return r.body.pipeTo(require(\\\"stream\\\").Writable.toWeb(process.stdout))})\" https://github.com/h4ckf0r0day/obscura/releases/download/v0.2.2/obscura-x86_64-linux-stealth.tar.gz | tar -xz -C /usr/local/bin obscura obscura-worker && until npx -y @logicsrc/openmcp@0.4.0 serve; do echo retrying in 20s; sleep 20; done'", + "health_path": "/healthz", + "notes": "stock node image: downloads the obscura binary then runs npx @logicsrc/openmcp (Railway startCommand); serves openmcp.logicsrc.com and obscura.openmcp.logicsrc.com" +} \ No newline at end of file diff --git a/dev2/sites.d/openaccess.logicsrc.com.json b/dev2/sites.d/openaccess.logicsrc.com.json new file mode 100644 index 0000000..311d580 --- /dev/null +++ b/dev2/sites.d/openaccess.logicsrc.com.json @@ -0,0 +1,6 @@ +{ + "image": "node:24-alpine", + "start_command": "sh -c 'until npx -y @logicsrc/openaccess@0.3.0 serve; do echo retrying in 20s; sleep 20; done'", + "health_path": "/healthz", + "notes": "stock node image running npx @logicsrc/openaccess (Railway startCommand)" +} \ No newline at end of file diff --git a/dev2/templates/deploy-app.sh b/dev2/templates/deploy-app.sh index e8eb1ef..31cbc62 100755 --- a/dev2/templates/deploy-app.sh +++ b/dev2/templates/deploy-app.sh @@ -61,6 +61,20 @@ TARGET=${1:?usage: deploy-app.sh | --rollback | --status} [ -f "$ROOT/app.env" ] || die "missing $ROOT/app.env (the app's secrets)" +if [ "${IMAGE_ONLY:-0}" = 1 ]; then + # A stock image with Railway's start command: nothing to clone or build. + log "Pulling image and starting" + compose pull -q || true + compose up -d --remove-orphans + if health; then + log "Healthy on 127.0.0.1:$APP_PORT$HEALTH_PATH" + { echo "DEPLOYED_SHA=image"; echo "PREVIOUS_SHA="; echo "DEPLOYED_AT=$(date -u +%FT%TZ)"; } > "$STATE" + compose ps; exit 0 + fi + compose logs --tail 60 || true + die "image-only deploy failed health check" +fi + CURRENT="" [ -d "$APP_DIR/.git" ] && CURRENT=$(git -C "$APP_DIR" rev-parse HEAD 2>/dev/null || echo "") From 5aab1f642e896b1f2f41ae2a146c57a3841c86ef Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:50:57 +0000 Subject: [PATCH 031/104] sites.d: mynaposter.com with its API companion on api.mynaposter.com Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/mynaposter.com.json | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 dev2/sites.d/mynaposter.com.json diff --git a/dev2/sites.d/mynaposter.com.json b/dev2/sites.d/mynaposter.com.json new file mode 100644 index 0000000..d0ad819 --- /dev/null +++ b/dev2/sites.d/mynaposter.com.json @@ -0,0 +1,27 @@ +{ + "db": "pg", + "db_host": "postgres.railway.internal", + "infra": [ + { + "name": "Postgres", + "id": "815793bb-9f7b-4703-9bc2-1f170dccb836", + "role": "pg", + "image": "ghcr.io/railwayapp-templates/postgres-ssl:18", + "volumes": [] + } + ], + "companion_ports": { + "mynaposter-api": { + "host": 3361, + "inside": "8787" + } + }, + "proxies": [ + { + "host": "api.mynaposter.com", + "port": 3361, + "max_body": "64m" + } + ], + "notes": "the CLI (packages/core/src/store/cloud.ts DEFAULT_SERVER) called the API at its railway.app URL; api.mynaposter.com replaces it" +} \ No newline at end of file From 287d9684cde53149698739c4c49165143ce51b34 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:51:46 +0000 Subject: [PATCH 032/104] dev2-site scaffold: per-site start_command feeds nixpacks; mynaposter commands Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 128736 -> 128818 bytes dev2/dev2-site | 1 + dev2/sites.d/mynaposter.com.json | 7 ++++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 9ddf0d4e42525c94bdc08228860e8d1875583a70..2e3d14fe3e68d664272d48d463179891c7c4d796 100644 GIT binary patch delta 2403 zcmZ8hYgAO%6`t?jc{w-?CnUB?~2G967Tt6XQrkO&pR*W{n-=$>JKPoTN-3jWu~lA@cfsyhz;ZKaeLXlZd<_ zvPZ)~rf%qmRFb=)He~X%R4PKsGM|N2rVzO{!f$Gm^z=lTM&fGi*6Ci`7M(Jq2VWvN zwNc#L+qDi%CM9cw$mTk9?!{70e{@b#A;uKBy}|I{C3K60^vpu>O*gK_-$a z%qGW(Q_gGxIrE497A1=pQZEWgQa6S}CJ`GQ$R@6hE?bT=r%C>a$7{{6lT#bhWPV2M zn;bm;k9C8|+)a*Nb9p&A|B8XUG;VTp(@@MKw)#jzuJSXtfi%(=NN&9q^GRX7BkJdh zpW;&9Q1YI|IF(~Wg$6J?{K)4MM|OaCRdjPU1hP? zpcE;^ibq-E_TzzukT`0aFSOqJKdln4tf>DJw6sB1%9QfwLN9$zUe;jnT9k~%LwTP` z%5o2s6mW4eSBy%h#4~aD8rz zYK?Dootu)gt@P~hiRYpxX)k{Kk+Cva_gA~Y%`trX0KS|aKY0jW&JCVy;mi5hX`}dZ z$Kz}K2N)X>%mixE|$(Mtt3WaOoecZ9~wfI!Axjv!itaE_y*Ez`G9%kx{G7CA@ z;7&4iCiKY3{T2nt`9D2f9T{u4YMz}&7)+f0No~chN5FYfvd3k)!*x^vf7jafgc@;J zyU)@96G_=SQvU-#r+Mk<8)#7rJW7G;D)1~-hjAYlG_rTI5m(8G)=&)9rnMf><9i|> z`5p!hN5=vWN%FB=Jk;usx!|uJ5>f0NVk9+zSdXU~6Sx!ANwSZp_{Vaw%~2I?5{(zPk*(0td916Q><9ab1GiU0r9 z3U4fyjZaNUTc;Dli2to<+#!j#%5j@C-x|Xw)%9D;@Ew`gwE$7tfvzSCJX+y5=|G9b z9_$6$Ny($-=pbJ`DzU_fbOe-X1>a@^1IfO}8Ca!Rx{upXML+L_BAlYB7W@)x*)a=p zq~YOQJ4UY#6qaEE90Gy_BnXa90U-kHbcPl0A(oxBf&m@uJsX@740L}WCgM~2BoM<8 z$D;cn0n$m(*+CeIgY^Br_=Yc)LHH7_tRR^G#b4;=e#parXm>xPAcl?&K|XHNUx(lh z&d};mq*>nK3`%__o$gzj>BB(wBotW|9A+7jSf%GAPDdkxKO^?_Q0&&@3Tuo-DvXzf z)nhu#he6C7hbIzlQbPjL(8V$mkO|ymjZPFwxX=6&@r8j`qjNk~nA&)P)71{p7;MC4T2X-+d^u9VFX|{-~;BTyj-KfK# z%s9vzw!kCd2<^R<-}2vR(pJntEUnp!Bm5e(Kg3P^o{isz9e%t@T}^lb=>+J^AlU17 zVhFC#=$+`sVLEsha?s9}@4`NblW?|R1uyT87JOp%6R%WF=df}Ft$ml@;Ob9d2Oh9r z?MDd04)c^}sUvtU>f0RJ^sl96%~DT@)?7zj`;m-?%zO}|fZJ@`XUH%ZAB(&P(UKF0 zV7m@uzX38UI)N76M>PBb<{_G{yntz(aq9(SiI(pB4@{6?W3w+I(Z-8ebqgDCorZSt zG>6bDU0BC!TJ#l;B8fTwg+{~3e~b88VrIN7V4r{@!7)KdUE=QuA=w1npweBu!J9Du pE=&j(lU^0`QVkT)Gbo4A%6o7ihBe&7IN9_nkb!$1cKHEh{1;zC|C;~+ delta 2345 zcmZ8hX;f547Vhu$N@D{8f~bgy3Mf$l*&;6B7NdZ;CK7Ft(vAx#4T?*?3GQYz4!A<%h(=}H;&OB{Rd_tdnfK$X<=*dp_ui^kwfBuT z?ioE!S}bOb_->~EZT?qbH6EX>Qy%-U$h~-%4UdCaeY$Rl9%s~;jUzyli~Q)fMMKe_ z{$4Z&1LzoMFb2|j&Jca1Lr0>h%jpvo?dWJzR#-HeRbEbaohIL12A7}1%h91~5UqDw z5ko_YQxHpYi$f7dYqrf*NIb=+bhr3Q<+U@tx4BpM9FT-J8+JIW^M}1Qidf#{U0S{= z+AWq0c38?ZBod}-L`4oIXio3k*4?%2FTJCqEgF(o08q(Hi~#1F%1 zafu5_G|1Hn$u!0l>^|a+6d6e~T>+L+4$m?T8QqR!=%=pUf?e$j6r9`*o#+i$ckO#* zEX9^g_xIm0R|SY-qUf$I{V2@}=yMvaolpsu87gA0=EF_GqO4ZhkL154yyUI<(sI4SOi~wwX*70)6v)G0A+?s#(<~0lseXG%#}$qs zhu)!em>u05O4Z6rQ+vvE^?HNFpE$@Wht6gsJ*!qz>vn6dMmn1&}itn+x=+#UJtV(?q$B%$UCl1*t-XK zPkr>4O;G>*`5wcXV9gH!dYizY(TW((^i1v}hVyR8W-JH0r!FaGXYOf@Yln%`XZ=0{SxpR_Sw1l+p-BM({TSmRDqw!V;lbrkNZQYHx0@~-nezrJm zmdK|4qwxV2^~4tj=nW#t6Y9kSPk|rl=^Cr~p760tIIqUmb~d11P5XK*V4yqd`r{S- zp{}PsTyQR^ZFSoXU9L&*of1++VEex%ypj;AR(yLzhnLiP>=ktCjN|dZ6I%Lx2A-%7 zzqg`ulO-=d)1Gh5T9lognM3RWB1pJmkEGUyBtxW7ut(8-4MRL4#I(sUFDGvvnPbx)IuG)s=Lmmc_MCJ-X+&!@!s)EmP~4-1txIs1{--rYJXwQo7vKf0x;-1cRL?t=W@M{}9;X0{Rkz>j zfa|pW=`ys?#AmtYFj+FG&-&I042|f~{WTw#){3Uc|J1i(P$d0aXv0dI6$ftSW6QG4Rc_LbY zj(K=v7!I;jZ}dffzT6v8P)>mLI0|2O)(>59gkAB$V=JG$Kf8}#eYgc zwh1k4P)!jP|^Ocuoc$nMC;61ijJ73JDg87Cs~R7)aomb1~9{ zNqk=pdg*bAoy8oV$TNQReIHY|vjtwpvGQOvCn{jrh97h<*! zrM%3EFSYobk1xTOKsA5mg7_P2cr3-&CLG~yWyn$R9hfIhg&mVbd4sAu^HFa_87i32#Oh%DCEV!7z= z@3q)(5(m8h5Jmux`Q5|tN4I)WWU4(vWN%+7ph-6^X=19qL0;2hR&yA`_{^h-0Pgal zZ;+ Date: Fri, 25 Sep 2026 10:52:02 +0000 Subject: [PATCH 033/104] deploy-app.sh: REPO optional for image-only sites Co-Authored-By: Claude Fable 5.1 --- dev2/templates/deploy-app.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev2/templates/deploy-app.sh b/dev2/templates/deploy-app.sh index 31cbc62..65ac926 100755 --- a/dev2/templates/deploy-app.sh +++ b/dev2/templates/deploy-app.sh @@ -19,8 +19,8 @@ APP_DIR="$ROOT/app" STATE="$ROOT/.deploy-state" # shellcheck disable=SC1091 . "$ROOT/deploy.env" -: "${REPO:?deploy.env needs REPO}" : "${APP_PORT:?deploy.env needs APP_PORT}" +[ "${IMAGE_ONLY:-0}" = 1 ] || : "${REPO:?deploy.env needs REPO}" BUILD_SERVICES=${BUILD_SERVICES:-app} HEALTH_PATH=${HEALTH_PATH:-/} HEALTH_TIMEOUT=${HEALTH_TIMEOUT:-300} From b1a15374741c70140868a39e6497c02ae31f5d23 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:54:03 +0000 Subject: [PATCH 034/104] dev2-site: a wildcard covers its one-label subdomains in the certificate order Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 128818 -> 129580 bytes dev2/dev2-site | 5 ++++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 2e3d14fe3e68d664272d48d463179891c7c4d796..cffe7deddb9f971e974abc8dead787e8076fe8a4 100644 GIT binary patch delta 2603 zcmZ8jdr(x@8Na{F-UZnO5d?ydia=tKZsP+*WkeQvL`94*&`?BN7DZ)|vn;Cw#g2)h zVuh~tXiN~3Hrf#tZIYX$R%*2~Vrl~7W85~=8vkf~v_(uZIL5Z$cZs#}&fMSq&Ub$2 zd!2K>-BbSzZ2KxO^plW~V1=Gf7JRTecI;0>-w(y|WM83ekoi3*$~Mhc_3(MwnhZT^ zqp8l6sqEGq>Pp3-7R4yaMy*csHOEK#24{qh(MkuE1`ku3)yCl_Wuuwq4EDAs{IQC% zL)-p@AG|cOI)wf5)1Z~1)bd(TojFrEI-pXHl6QUt8WXo`Wc5cT^14x5NC=kTeIjZ9 zFC|)~a}rvEMISGorYefByFEH;FO7bIISLf1)G0;j6g;-8zLQ%bqm??;((Dt5>mgo% zLUYZYNO|UW806dSi8h3cQiHhWhl;Wn5>Og5Ov(oJSb!Ia0iu`v6%ynns^S3;z9OKB zFX`h)t@tB-eM1U{THWQ<&f?O_ijs1d+c&--qW%#@>3)T#`@dBBH!EZ*EnB^>D0VeV z?RvHD!;+5GAFaL+aiOX+x+iV^<+Pfv{DSNGMcw&DUCT;(@>g_~t-M}V(Op*2<*Mo_ z6J4H~?y{Qh{FH5wSaAgWhRTu6 zBllIc#_X><;^-bVzCEjZ)YP6K(|W9_o3m~XkL|If?4Q(B(rQ1PdnmVk=+%VD4fckT zh7~)q_D$NH*=0$&5j}zmYt2!cJx!xqaoBpudSua+*a@fO$#kDhLX)Z!MdW_D6Z&(1Wy(zgzj;N8V~uB>a5Cv{^2IXPG%E!&T`ws^ zuh|qvvb?tzak{qxW1$VBae@A>3kS`plGz@#m`0O|PHu#JGNh?l7~c!VFz z6fd4foGkJpAvBqotLSxarnvg${6Zw?zwu(KdE5;S=pMskhBk)v9DEGXg^j^WL%KVk zv(_d@Y{F;5|IM_25co~PSz0M*uN2Y2gm0y}9=lSx@`7tExX9udOPrnHGz5p^H| za8iHVjAUpFNPb_R?8ApaIII8b09-0A$Ym{f8JFdW7F3uEc*znOcMy9~C_g%g$(SR1 z4?WV#S3z$i{=vU&H|uPvc$6x*c-jRg4MW&N7#sF5M!Q7t6I*@h7u!=~n7#*KkEO z8`mn;SJfXOlWt&w_2*3VF*p@DtPnqt+ioCAJ5HK#{p}k#K6tco)t|ZBNgU=E1c?wI z$g0~I5zWn@cCgLnsiL&aJk!XYiO6uwJxOqT5wjV%oeZ2m zDM& z>Kko>Pg*QN2L2oqKA&N>Eebvmj43gt4~7LAn+&DVr+bu!l$fb926}M1!D$4KcQM~! z3!vyhs4&_z%8o^t(dJK!V$m_6z@JT7tybC-i%ymkMuXuFkAj7)rL(b^7;O$&?@yL> zma?f8fg5L4rJo6l+B5J>SnSz$ac9j#>ivhb-qsA3*aD4rl(lvA^QW*c(OG{GhtX{i zq$fEr#)uQNb|kKte__-2#C(n)P02@hy(|&i&Ad8dJXUDed8NHxHy&<_<(hhQ4JkwwU0igd&t9j4l;nKrfF3-<7xDZ8F?_&rsjdc_w^XMcUf5 zvOeo4vDm7t6jO}*h@dDhp2xG48-8a#`e z6uAaYOCl49N5M5VBNrK_A6kP4Ms%ZtA0it`)UFg!+GwU!>7z>VDT3lzRsy^9mi35% zmdbLq-fsgA2H>#XR1UWh-%<8fj7J?+ZN+R;hHS~C&`NB;M5?aDAdIG~mB>Oey|fJu zt&j=(^<~>oWi}@Z`IKZNh2nN&Fox6I-H1a1?bwY47)#-MaEpIJ_wxQry4uTraGAcT z#!-`mqT*@gK6pC(!I`u9sRXeUEbtqd_v6UF9KC)&dO=&q8|wAD2T&e>45Gs*?3TbP zZ+n;fE$hgX)Xc=mi6bUwPDqJY)2a6ne2u^K-;baJ+82wfZ`tlR6)ED|6mS%;poP+p z;+@VjMKxBgCR0Gpr&9fBT^@Rp=@|NBCB+>>lD3H#uIo#WVV+;RjgLso^yO;_1=b)r zShQY~HQ!z6v8q&>R)bNTimDpKbP$?1$U1IR_TI(3 zQ9^08h{8sFaV<2k(-o%>Zuyvv`Ek=KSoqlK&!=!YaKNK8iL0fLovsjRxxi+D{&GtL z1O^IZP~(s2Yg-~q3#ANvbeBwhCY}VJCA#Glc>#SpEE8F7gbOcCiDHivsiUx9iO9)dS8bZ4%%`Z5ox{-@&A^fwo85v2|U$0q#G!C zf-6Q%!_eEow7M1L{{UV%5&-}J diff --git a/dev2/dev2-site b/dev2/dev2-site index 1eb6d7f..8a0e911 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -757,7 +757,10 @@ def site_domains(s): proxied hosts (the Supabase gateway).""" extra = [p["host"] for p in s.get("proxies") or []] www = [f"www.{s['site']}"] if s["site"].count(".") == 1 else [] - return list(dict.fromkeys(s["domains"] + www + extra)) + names = list(dict.fromkeys(s["domains"] + www + extra)) + # Let's Encrypt refuses a name that a wildcard in the same order already covers. + wild = [n[2:] for n in names if n.startswith("*.")] + return [n for n in names if n.startswith("*.") or not any(n.endswith("." + z) and n.count(".") == z.count(".") + 1 for z in wild)] def cmd_cert(args): s = site(args.site) From fc6f772ae0af70b1ed3cc0f3d1d09415b8f956c3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 10:54:52 +0000 Subject: [PATCH 035/104] dev2-site cert: reuse a certificate issued in the last day Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 129580 -> 129996 bytes dev2/dev2-site | 11 +++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index cffe7deddb9f971e974abc8dead787e8076fe8a4..e55dca9289dede94a79577e456e2dcd928f7d858 100644 GIT binary patch delta 2446 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 RbZf@77~4HsX8eh#{sz&4NW%aC diff --git a/dev2/dev2-site b/dev2/dev2-site index 8a0e911..348c77e 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -779,8 +779,15 @@ def cmd_cert(args): sh(["porkbun", "rm", zone, r["name"][: -len(zone) - 1], "--type", "CNAME", "--yes"], check=False); note(f"removed Railway acme CNAME {r['name']}") ssh("root", "test -x /root/.acme.sh/acme.sh || curl -fsSL https://get.acme.sh | env HOME=/root sh -s email=" + ACME_EMAIL + " >/dev/null") ds = " ".join(f"-d {shlex.quote(d)}" for d in domains) - out = ssh("root", f"install -d -m 0750 {cert_dir} && env HOME=/root PORKBUN_API_KEY={shlex.quote(k)} PORKBUN_SECRET_API_KEY={shlex.quote(sk)} /root/.acme.sh/acme.sh --issue --server letsencrypt --dns dns_porkbun {ds} --force >/tmp/acme-{s['site']}.log 2>&1 || {{ tail -n 25 /tmp/acme-{s['site']}.log; exit 1; }}", check=False, timeout=1800) - if out.strip() and "Cert success" not in out: die(out) + # A certificate acme.sh issued in the last day is reused (a retry after a dropped + # ssh session must not burn Let's Encrypt's duplicate-certificate quota). + fresh = ssh("root", f"find /root/.acme.sh/{shlex.quote(domains[0])}_ecc -name fullchain.cer -mmin -1440 2>/dev/null | head -n1", check=False).strip() + if fresh: + note("certificate issued within the last day; installing it") + else: + out = ssh("root", f"install -d -m 0750 {cert_dir} && env HOME=/root PORKBUN_API_KEY={shlex.quote(k)} PORKBUN_SECRET_API_KEY={shlex.quote(sk)} /root/.acme.sh/acme.sh --issue --server letsencrypt --dns dns_porkbun {ds} --force >/tmp/acme-{s['site']}.log 2>&1 || {{ tail -n 25 /tmp/acme-{s['site']}.log; exit 1; }}", check=False, timeout=1800) + if out.strip() and "Cert success" not in out: die(out) + ssh("root", f"install -d -m 0750 {cert_dir}") ssh("root", f"env HOME=/root /root/.acme.sh/acme.sh --install-cert -d {shlex.quote(domains[0])} --fullchain-file {cert_dir}/fullchain.pem --key-file {cert_dir}/privkey.pem --reloadcmd 'systemctl reload nginx' >/dev/null") note(ssh("root", f"openssl x509 -in {cert_dir}/fullchain.pem -noout -subject -enddate | tr '\\n' ' '").strip()) mark(s["site"], "cert", cert_domains=domains) From 0c29994e6aa74eb934d4ff9128390c72d96b2e3e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 11:01:27 +0000 Subject: [PATCH 036/104] dev2: README for firewall, box-tune, image-only sites, companion ports Co-Authored-By: Claude Fable 5.1 --- dev2/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dev2/README.md b/dev2/README.md index 158ea9e..1f11f36 100644 --- a/dev2/README.md +++ b/dev2/README.md @@ -38,6 +38,18 @@ dev2-site migrate --yes the whole move, resumable; each step below is a | `supabase-storage` | copies every Storage object through the two Storage APIs (node in a container on dev2; resumable) | | `supabase-cutover` | re-points the app (`env_overrides`: URLs, keys, db password), provision + deploy + verify, unschedules the cloud cron jobs | +| `firewall` | re-applies the data-port allowlist (5432 + every Supabase stack's db port) for dev1, loopback and all Docker pools | +| `box-tune` | sshd MaxStartups/MaxSessions for parallel deploys | + +Image-only Railway services (a stock image plus a start command, no repo) are +supported through `sites.d`: `{"image": "node:24-alpine", "start_command": "..."}`; +the compose file uses `image:`/`command:` and `deploy-app.sh` skips clone and build +(`IMAGE_ONLY=1`). Companion services can be published on their own hostname with +`"companion_ports": {"name": {"host": 3361, "inside": 3000}}` plus a `proxies` entry. + +Run at most about six migrations against the box at once: more than that, and +dev2 (or its provider) starts dropping this box's connections outright. + ## Layout on the box (every site the same) ``` From fdee8ed3c0e0a311f857ab68230a6c40c79f05bb Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 11:05:46 +0000 Subject: [PATCH 037/104] dev2-site: db-create for Turso/Supabase second passes; Turso variables re-pointed once a database exists Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 129996 -> 133472 bytes dev2/dev2-site | 39 ++++++++++++++++++++-- dev2/templates/box-tune.sh | 8 +++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100755 dev2/templates/box-tune.sh diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index e55dca9289dede94a79577e456e2dcd928f7d858..a00a5028b4ce645734f27f2b1eeb5d87e97e076f 100644 GIT binary patch delta 19639 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 delta 17335 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.q-7IXxEJ84U0# z>iFs7Jbab{un723+5q4J;Kw=pP&x?kGyuy%C*RPXz<0mAY(LRq34#2v9LOaoah8KU zVUBJ#p^jr8mZYc^sbwgLG=zW=H>)|IwEDes&1")) +def cmd_box_tune(args): + ssh("root", "install -d -m 700 /root/dev2-kit") + ssh_put(tmpl("box-tune.sh"), "/root/dev2-kit/box-tune.sh", "0700") + print(ssh("root", "bash /root/dev2-kit/box-tune.sh 2>&1")) + def cmd_retire(args): s = site(args.site); st = load_state(s["site"]) if "verify-public" not in st["steps"] and not args.force: die("site not verified public on dev2; refuse to delete Railway services (or --force)") @@ -1091,12 +1124,14 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("registry").set_defaults(fn=cmd_registry) sub.add_parser("firewall").set_defaults(fn=cmd_firewall) + sub.add_parser("box-tune").set_defaults(fn=cmd_box_tune) p = sub.add_parser("list"); p.add_argument("--kind"); p.add_argument("--status"); p.set_defaults(fn=cmd_list) p = sub.add_parser("scaffold"); p.add_argument("site"); p.add_argument("--regen", action="store_true", help="regenerate .nixpacks even if the branch already has one"); p.set_defaults(fn=cmd_scaffold) for name, fn in (("show", cmd_show), ("provision", cmd_provision), ("volumes", cmd_volumes), ("cert", cmd_cert), ("dns", cmd_dns), ("railway-stop", cmd_railway_stop), ("merge", cmd_merge)): p = sub.add_parser(name); p.add_argument("site"); p.set_defaults(fn=fn) p = sub.add_parser("db"); p.add_argument("site"); p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_db) + p = sub.add_parser("db-create"); p.add_argument("site"); p.set_defaults(fn=cmd_db_create) p = sub.add_parser("deploy"); p.add_argument("site"); p.add_argument("ref", nargs="?"); p.set_defaults(fn=cmd_deploy) p = sub.add_parser("vhost"); p.add_argument("site"); p.add_argument("--no-cert-check", action="store_true"); p.set_defaults(fn=cmd_vhost) p = sub.add_parser("verify"); p.add_argument("site"); p.add_argument("--public", action="store_true"); p.add_argument("--ignore-logs", action="store_true"); p.set_defaults(fn=cmd_verify) diff --git a/dev2/templates/box-tune.sh b/dev2/templates/box-tune.sh new file mode 100755 index 0000000..9d8aa24 --- /dev/null +++ b/dev2/templates/box-tune.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# dev2 box settings the fleet kit depends on. Run as root on dev2 by `dev2-site box-tune`; idempotent. +# - sshd: many parallel deploys open many ssh sessions; the default MaxStartups 10:30:100 +# drops connection attempts (exit 255 mid-migration). +set -euo pipefail +f=/etc/ssh/sshd_config.d/00-dev2-fleet.conf +printf '# managed by cli-tools dev2/templates/box-tune.sh\nMaxStartups 100:30:300\nMaxSessions 100\nLoginGraceTime 60\n' > "$f.tmp" +if ! cmp -s "$f.tmp" "$f" 2>/dev/null; then mv "$f.tmp" "$f"; sshd -t && systemctl reload ssh 2>/dev/null || systemctl reload sshd; echo "sshd: $(tr '\n' ' ' < "$f" | sed 's/# managed[^M]*//')"; else rm -f "$f.tmp"; echo "sshd: already tuned"; fi From 32b63196eedef439e2b569428969d25098b3e24c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 11:21:40 +0000 Subject: [PATCH 038/104] dev2-site: DEV2_JUMP / DEV2_PROBE for when the provider blocks this box Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 133472 -> 134119 bytes dev2/dev2-site | 16 ++++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index a00a5028b4ce645734f27f2b1eeb5d87e97e076f..9407eec9c7e884af518abcaf3aabc4132d9e3be4 100644 GIT binary patch delta 21832 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

    H7B@L~FtWB#jgGX;$W}`hI#g4` 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 delta 21402 zcma)k30Pd!+5eoGVP;qYBtSwIk|Fyf4rC!_F$)C94kUzxgoF$OcYq1YOwJu38Ht0| zxYZi<+Fq+h1Kj-qk?t14&SL*Q*Ba$riyL!vxyEiVlAvIf!eso62nxc#dX;;S4GfxRAOoTKDWt+~oXg7cI~1m`Pmf(1%0!3D}>f)^@# zlqpnqp)!@;7AeyRE>@-!T#~9tWd;>pWVHmGyZ35m#$;s79GsHkX|xW@pY`qe+QEZ~ zNy-JotLn4=DJU;*c-}?%L(S(jS{oB;mML>-2FsOs1Xn2Y2^J~^1dEgf1Xn5-5-e60 z5-icuh1a#{h?Nng%}>a+Da$X{u1@e&)l@30sK#bRRaVn7s+5SbhMrrLwUoP6Sx2y1 zSx<1A(xGgilI=lU_@yq*r5aob}G9Gb}20c_XhSUtt~Sx7U?1k?mfaYw^)Q_pLL(r z)76{qoa5}hF||n~WKz*`?d!B_r-7E_2!N3URM3T6W)L*&?V(6Oj-liV?Z499B1^xa zUu0@MISHCCZQb0&>Y~c>wdJdqxU1yrXfOGip`kg@&>|;e2xo&o9O;k& zIgQ@rbYzLJ>ZGUJ{Fw-`s?Bm4WtQ|llC?~TEbXJwV^@7h7}G6^P^^lL{_NcrV#0(7 zRWpCLS`OK|MU!)XLc}(hr#J@pxWrOhX0hydXesk2W_KraTNS6`?6yDbI+9>k-tX*o zY9lkUvJzVs()J{DJCr2eVXLsT=V*)E6DKPU%1n+@{U|jW>{(c@t*~3VZE|1C>>vdl z$lVd3nSaU%?mE)}siCB4g_mY(D@v2~8S_Mr7Rt_=mEP^#ojq7P{@g268eAEvj8ZJ) zu-f!)?Vaqb*ie3knSaPNIG4*r>|1-ZbmqBUbt&4b5e=-^=y|)ac0K zPgVUZqy zSpc&EE&%Wn@T5wh+enc9_NIWGgZ#Mw^N?$g(D+7z+8PLi<$Pop04xBw5Pr1rKN1PFg(Gq)6{s!(1B7N$xLkqy07M)Xbvmu3 z$MXA>k=Ga8Jn4a{{d0<+vp?1O+@|OEJ=fA-TGPMN*PmS1(>rEd0ug0z&iFi$f|Z`N z=SAAWa<*1IVR;6oQSPw~oA3(lfeDkv8twRmaT(0-PTB`wxFaH~sBX3P#RN6Z^1B@s zlqn0e(>Xb#xptBo@kau_us_l)cTicaHf7?pc!LD3e&T&&Yp8%Q7YT$_kCUs(YV=c~ z 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 diff --git a/dev2/dev2-site b/dev2/dev2-site index b91896e..b13a80c 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -48,6 +48,13 @@ STATE_DIR = os.path.expanduser(os.environ.get("DEV2_STATE_DIR", "~/.local/state/ DEV2_HOST = os.environ.get("DEV2_HOST", "dev2.profullstack.com") DEV2_IP = os.environ.get("DEV2_IP", "23.95.228.174") DEPLOY_USER = "anthony" +# When dev2's provider blocks this box (it happened after too many parallel sessions), +# DEV2_JUMP= routes every ssh through a jump host and DEV2_PROBE= +# runs the public curl checks from there. +DEV2_JUMP = os.environ.get("DEV2_JUMP", "") +DEV2_PROBE = os.environ.get("DEV2_PROBE", "") +SSH_BASE = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=25"] + (["-J", DEV2_JUMP] if DEV2_JUMP else []) +SSH_BASE_STR = " ".join(map(shlex.quote, SSH_BASE)) WWW = f"/home/{DEPLOY_USER}/www" SRC_ROOT = os.path.expanduser("~/src/profullstack") CLUSTER_ENV = "/home/anthony/www/crawlproof.com/supabase/.env" # the shared Supabase cluster @@ -75,7 +82,7 @@ def sh(cmd, check=True, input=None, capture=True, env=None, timeout=None): return p.stdout if capture else "" def ssh(user, cmd, input=None, check=True, timeout=None): - argv = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20", f"{user}@{DEV2_HOST}", cmd] + argv = SSH_BASE + [f"{user}@{DEV2_HOST}", cmd] for attempt in range(3): # sshd drops attempts under parallel load (exit 255); retry those only p = subprocess.run(argv, input=input, text=True, capture_output=True, timeout=timeout) if p.returncode != 255 or attempt == 2: break @@ -692,7 +699,7 @@ def cmd_db(args): log(f"pg_dump {src_db} via ssh.railway.com -> {DEV2_HOST}:{dump}") # Railway's template sets PGPASSWORD/PGUSER; a plain postgres:* image sets POSTGRES_*. remote = f"PGPASSWORD=\"${{PGPASSWORD:-$POSTGRES_PASSWORD}}\" pg_dump -Fc --no-owner --no-privileges -h localhost -U \"${{PGUSER:-${{POSTGRES_USER:-postgres}}}}\" -d {shlex.quote(src_db)}" - cmd = " ".join(map(shlex.quote, rw_ssh_cmd(user, remote))) + f" | ssh -o BatchMode=yes root@{DEV2_HOST} 'cat > {dump}'" + cmd = " ".join(map(shlex.quote, rw_ssh_cmd(user, remote))) + f" | {SSH_BASE_STR} root@{DEV2_HOST} 'cat > {dump}'" sh(cmd, capture=False, timeout=6 * 3600) size = ssh("root", f"stat -c%s {dump}").strip() note(f"dump {int(size)/1e6:.1f} MB") @@ -752,7 +759,7 @@ def cmd_volumes(args): user = rw_ssh_user(s["project_id"], s["environment_id"], svc) log(f"Volume {v['name']} ({v['sizeMB']} MB) from {svc}:{v['mountPath']} -> {root}/volumes/{v['name']}") cmd = " ".join(map(shlex.quote, rw_ssh_cmd(user, f"tar -C {shlex.quote(v['mountPath'])} -cf - ."))) + \ - f" | ssh -o BatchMode=yes root@{DEV2_HOST} 'tar -C {root}/volumes/{shlex.quote(v['name'])} -xf - && chmod -R a+rwX {root}/volumes/{shlex.quote(v['name'])} && du -sh {root}/volumes/{shlex.quote(v['name'])}'" + f" | {SSH_BASE_STR} root@{DEV2_HOST} 'tar -C {root}/volumes/{shlex.quote(v['name'])} -xf - && chmod -R a+rwX {root}/volumes/{shlex.quote(v['name'])} && du -sh {root}/volumes/{shlex.quote(v['name'])}'" note(sh(cmd, timeout=6 * 3600).strip()) mark(s["site"], "volumes") @@ -846,7 +853,8 @@ def cmd_verify(args): for d in s["domains"]: resolve = [] if args.public else ["--resolve", f"{d}:443:{DEV2_IP}"] for attempt in range(12 if args.public else 1): # resolvers cache the old record for up to TTL 600 - code = sh(["curl", "-sk", "-o", "/dev/null", "-m", "30", "-w", "%{http_code}", *resolve, f"https://{d}{hp}"], check=False).strip() + curl = ["curl", "-sk", "-o", "/dev/null", "-m", "30", "-w", "%{http_code}", *resolve, f"https://{d}{hp}"] + code = (sh(["ssh", "-o", "BatchMode=yes", DEV2_PROBE, " ".join(map(shlex.quote, curl))], check=False) if DEV2_PROBE else sh(curl, check=False)).strip() good = code[:1] in ("2", "3") or code in ("401", "403") if good or not args.public: break time.sleep(15) From 00aed0cc1a1052d6d428747fda60475e15b130e5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 11:26:05 +0000 Subject: [PATCH 039/104] dev2-site volumes: per-file, resumable, chunked copy (Railway's ssh drops long streams) Co-Authored-By: Claude Fable 5.1 --- dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 134119 -> 138132 bytes dev2/dev2-site | 47 ++++++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc index 9407eec9c7e884af518abcaf3aabc4132d9e3be4..a2914a846746beb1bff8f2a51038d798dc60fc4a 100644 GIT binary patch delta 8043 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 delta 4254 zcmai13sjRw7S5eN34!ob!4eUpBAQB2t0GF(2BlV@pr9gF#1JAxAi+uS5qVU%3hi2@ zMI39j;#RGeuC=~?>~5{t>eklPC|Y|`Pb+onqiB8Zsjs#>caY+G_MH9C$v1cIy>su( zojZ3Xt7Xk4rV0EiAiz(-zwz1o=FT7SQQ(%#;NHFGay0?l?q}OhD`COzkQY&Pf^Lu;{QTOSDh`Pc1gVms@g%uI(?g)q{hMp`r9)>^V_L=4= zE-SI>?-Tt1I*^CN?Wcb+(ZE*4LueJn(9=L}&+X$fQ@wGV-mTFaEpzlneJp(&h3h%E zef9l2#Nm45LKH_w>n(*Pj(oi_LvJ2P7fjc?waCmb(;JMq5)*YhJ5B zMc)K>ty-O0JEkG*1K)p_hi=WmK3umInR@~_y*4a zFcIFALn1bbB!%2jC{oE}@(VUu~p>kvW~1$!(P7Av1+ou(1$3xlyH1 zIEWLwcH+XuMJmu}B^f7iqm^W$#K&X9Wyw+sHmOnuGLxlDi5smXQ;=CfreZTynubkK zmUL4Bjy2N1H2y0xYNfT)PX|Pw4Tw1z5YrM6`?Y4!4XvW9?oN!qLgiZ%QWN)TcD{wLfrzM%f(FZ$f~EIjj&SAD;k)B@93uoAkWQfv!&9@#4kMo`ax4)M>CIz0Cz+YaL1}}oDm5Y7lv*~b0 zf=G5O6L!G2Z1EI`RbAndFnPxmsPutzvT-K-1z@o3$$>`jv1xp`>r?X5T<8sI)TFI! z`)qg(&a+`=cuD&^XX(-FafuzP2A#ae3`aF^L9Uz&=Rrlk;&M5!81jHzA?!#Aq`(E% z#SRIo06uDroMDGAeZkCnE`%ZQxtzWb)=BC|Rs8y5Fc{3}^J`3YA8K_visxHt8ipf; z52t;RuPksl^65clDu)*!H*jDp8tcOfmP3wM z=5ok{olI8^dEpOG^;ZsFb)MN$R8~sMQ28NY_0zxKsHJv=;io7fZ>yTuyZehq3z# zO5?CQ5#dj~YasL|d#o2n*b8N*fD;1TLMq>0bQ5M98Zi07JGdR6f{&o|5 z;-i|)>HYGC?XV6Yhs8I7Q9X;Z+)=D~8+2n;jgX|unuEhE`Lpu4Hu!YS~tS&=S^Vv_$FbQ_CghP-7i`bGw{1s$l zO@|;=D|TrQ&b^ogeFi3VJs)vNlBa#fh42b{_c+WLSBzG?xM3woeZ$qgff-4eNonJA zO_L_(rX*!kFWMM`I%5$`2zX=Dmr#(yo;v{}VHR_rfJ(5ldndpGCs~dMR`A|W9?-#~ zhh)(yh=vf^bP~G3fAPAqH%~#c2nC(VvRl9!aRE)IpeHq-mqBW$b;wXMZwq|dp}p}m zBuC9b?amuk+`9NE^ZGeUi*2rAo89VU>NBucwToMmUaDUHsFqpf&uKW-@|k8XZG{=FrFV&9t~aH`CuiB(Seawo(EI!br|oHd_ie6 z1PdHp3wmUO~}uwISMwjzi$Kd`0^`gRx; zzFdV?^f$Z>a`;)TYlE>+&05+Z3TCpqZJ>v(Ec_}AhL7dstDpkzZPGP(T6LJ4jFYRb z!7bmR?aM$PTmJC29jR>y2N0gf)H1gD7wB);j3V*c7wXHgD@=6GdI9pbu{&J7%X>)U zzVOmVk#;iUUDy{P4wa~oW}aVMDE35@!DaIAci|uj71gfuPl_mck?iyXNLKGeOCPbQ zhcHTejMIrqUOd8->_II5!}IDxNDQ`g7HCnMpD4y7Ff*=mFaSM`#=gMJ7o}Xx4F9?q zz^k&GX@OG|c6X4`Xm=PbR_ZcZ3ayqRs$;PMq-QkNdaqB3t$><&rM-=*uQnP*6@MQs zA4S;D<^_M!Lh=?3rLBVy?E4%_q-@W^|(cga3&Q9>DZlta2QA2 zBkW2jxm+az(*Bncwc-)P-%Jq;aV3g?bl#fynxo=5_Qd`9L@-6T+G8(5gHVWILl7sk zRO-*kdhCn3B7)l)R}oSXYY$t{o%Hv+$~E~46wV9sw(ex8q|!pKTam0)M|S@QhrWRI diff --git a/dev2/dev2-site b/dev2/dev2-site index b13a80c..34da3fa 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -757,10 +757,49 @@ def cmd_volumes(args): if not targets: note("no volumes"); mark(s["site"], "volumes"); return for svc, v in targets: user = rw_ssh_user(s["project_id"], s["environment_id"], svc) - log(f"Volume {v['name']} ({v['sizeMB']} MB) from {svc}:{v['mountPath']} -> {root}/volumes/{v['name']}") - cmd = " ".join(map(shlex.quote, rw_ssh_cmd(user, f"tar -C {shlex.quote(v['mountPath'])} -cf - ."))) + \ - f" | {SSH_BASE_STR} root@{DEV2_HOST} 'tar -C {root}/volumes/{shlex.quote(v['name'])} -xf - && chmod -R a+rwX {root}/volumes/{shlex.quote(v['name'])} && du -sh {root}/volumes/{shlex.quote(v['name'])}'" - note(sh(cmd, timeout=6 * 3600).strip()) + dest = f"{root}/volumes/{v['name']}" + log(f"Volume {v['name']} from {svc}:{v['mountPath']} -> {dest} (resumable, per file)") + # ssh.railway.com drops a stream after ~15-20 min, so one big tar is not an option for + # multi-GB volumes. Compare size-lists and move only the files that are missing or + # differ, in chunks that each finish well inside that window. + def listing(txt): + out = {} + for line in txt.splitlines(): + if "\t" in line: + size, path = line.split("\t", 1); out[path] = int(size) + return out + src = listing(sh(rw_ssh_cmd(user, f"cd {shlex.quote(v['mountPath'])} && find . -type f -printf '%s\\t%p\\n'"), timeout=1800)) + dst = listing(ssh("root", f"cd {shlex.quote(dest)} && find . -type f -printf '%s\\t%p\\n'", check=False)) + todo = [p for p, size in src.items() if dst.get(p) != size] + total = sum(src[p] for p in todo) + note(f"{len(src)} files / {sum(src.values())/1e6:.0f} MB on Railway; {len(todo)} files / {total/1e6:.0f} MB to move") + ssh("root", f"install -d -m 0777 {shlex.quote(dest)}") + chunk, size, n = [], 0, 0 + def flush(chunk): + nonlocal n + n += 1 + files = "\n".join(chunk) + "\n" + for attempt in range(4): + try: + cmd = " ".join(map(shlex.quote, rw_ssh_cmd(user, f"cd {shlex.quote(v['mountPath'])} && tar -cf - --no-recursion -T -"))) + \ + f" | {SSH_BASE_STR} root@{DEV2_HOST} 'tar -C {shlex.quote(dest)} -xf -'" + sh(cmd, input=files, timeout=3600); return + except (RuntimeError, subprocess.TimeoutExpired) as e: + warn(f"chunk {n} attempt {attempt + 1} failed: {str(e)[-200:].strip()}"); time.sleep(10) + die(f"volume {v['name']}: chunk {n} failed 4 times") + for path in sorted(todo, key=lambda p: src[p]): + chunk.append(path); size += src[path] + if size >= 400 * 1024 * 1024 or len(chunk) >= 2000: + flush(chunk); note(f"chunk {n}: {len(chunk)} files, {size/1e6:.0f} MB"); chunk, size = [], 0 + if chunk: flush(chunk); note(f"chunk {n}: {len(chunk)} files, {size/1e6:.0f} MB") + # Empty directories carry no files; recreate them, then make everything readable. + dirs = sh(rw_ssh_cmd(user, f"cd {shlex.quote(v['mountPath'])} && find . -type d -empty -printf '%p\\n'"), check=False, timeout=600) + if dirs.strip(): ssh("root", f"cd {shlex.quote(dest)} && xargs -d '\\n' mkdir -p", input=dirs) + ssh("root", f"chmod -R a+rwX {shlex.quote(dest)}") + dst2 = listing(ssh("root", f"cd {shlex.quote(dest)} && find . -type f -printf '%s\\t%p\\n'", check=False)) + bad = [p for p, size in src.items() if dst2.get(p) != size] + if bad: die(f"volume {v['name']}: {len(bad)} files still differ, e.g. {bad[:3]}") + note(f"volume {v['name']} complete: {len(src)} files match") mark(s["site"], "volumes") # ----------------------------------------------------------- box steps From 96b223ef432478d09ec10c8c659ac920db99f489 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 12:57:50 +0000 Subject: [PATCH 040/104] dev2: keep credentials out of sites.d, which is committed and public The fleet kit wrote the values it re-points straight into `sites.d/.json`, so this pull request carried threatcrush.com's Supabase service_role key, its anon key and its database password, plus tipoffwatch.com's retired Railway Postgres URL -- in a public repository. ThreatCrush was right to fail the branch. `env_overrides` now takes `secret:`, resolved at render time from ~/.config/cli-tools/credentials.json (0600, outside every checkout) under `.`, falling back to a bare `` for a value shared across sites. A reference with nothing behind it stops provision and names the key, rather than deploying a site whose environment contains the literal string `secret:...`. `supabase-cutover` was the thing writing them, so it now writes the reference and puts the value in credentials.json. It marks what is secret by provenance -- the state keys it substituted -- with a key-name pattern as the backstop, because a wrong guess there costs one extra entry in credentials.json and the opposite costs a published key. Also: - the four values above moved into credentials.json on dev1; provision renders exactly what it rendered before (verified by resolving each reference back to the value this commit removes) - `porkbun_keys` and the Supabase SMTP lookup hand-rolled this same read twice; both now call `cred()` - `db_name_for` validates the identifier at the one place it is derived: `db_name` in sites.d reaches `create database` and a DSN unquoted - drop the committed `__pycache__/*.pyc` and ignore it The keys this branch published still need rotating; removing them here does not unpublish them. --- .gitignore | 5 ++ dev2/README.md | 22 +++++ dev2/__pycache__/dev2-sitecpython-312.pyc | Bin 138132 -> 0 bytes dev2/dev2-site | 102 +++++++++++++++++----- dev2/sites.d/threatcrush.com.json | 6 +- dev2/sites.d/tipoffwatch.com.json | 4 +- 6 files changed, 114 insertions(+), 25 deletions(-) delete mode 100644 dev2/__pycache__/dev2-sitecpython-312.pyc diff --git a/.gitignore b/.gitignore index d821a81..b44bd9f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ credentials.json # land in the checkout that owns the links, which for a contributor is this # repo, so without this a 37MB stripe binary shows up as untracked. vendor/ + +# Python bytecode. dev2/dev2-site has no .py suffix but is imported by its +# tests, which is enough for CPython to write one next to it. +__pycache__/ +*.pyc diff --git a/dev2/README.md b/dev2/README.md index 1f11f36..6c429fc 100644 --- a/dev2/README.md +++ b/dev2/README.md @@ -84,6 +84,28 @@ small file per site, so parallel migrations never edit a shared file: "pg_service": "Postgres-iVtY", "max_body": "256m", "branch": "master" } ``` +### Secrets in `env_overrides` + +`sites.d/` is committed and this repository is public, so a value that is a +credential is written as `secret:` and the value itself lives in +`~/.config/cli-tools/credentials.json` (0600, outside every checkout) under +`.`: + +```json +{ "env_overrides": { + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.example.com", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY" } } +``` + +`provision` resolves the reference when it renders `app.env`, and fails loudly +naming the missing key rather than deploying a site with a literal +`secret:...` in its environment. A bare `` at the top level of +credentials.json is the fallback, for a value shared across sites. + +`supabase-cutover` writes these references itself: anything it re-points that +came from a key, a password or a DSN goes to credentials.json, and only the +reference is written back into `sites.d/`. + ## Database kinds - `pg`: Railway Postgres -> shared cluster (above). Done by the kit. diff --git a/dev2/__pycache__/dev2-sitecpython-312.pyc b/dev2/__pycache__/dev2-sitecpython-312.pyc deleted file mode 100644 index a2914a846746beb1bff8f2a51038d798dc60fc4a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138132 zcmd442~b>DnkJenNiOW0(3&g~q{Je$gH|?4Xh9O71tbIsH-SV+Cb~BPkxFb;O-(e| z>Jch?%4jSZ<*)K<4~cgHI&L=Rg1X&;S4DKYy8&WK!Wd-u8d?U#wQE{xkiMA4A0R z<*yP{s<%{}s!heIIgLx*re<$Vo0h$GZ94YWx9QnCp)G;E4Q&SYPHanLZ)2O0y-jT< z_BOYf+1t`)Veh22B=$~jOJ?tswp8{`YfE(`^cTw2ioW}cqJG);H8E}S_vqV^^?_RY zCBNIUzb18gG?nagWOLdNG}5oO72hbGPDz*hjne6rbSu9m-A=i7(Kll5ivE&){kvsf z^vxx32Dt>KN7jCW^og8NPT#if8>BOFW+k1Kv$W-XgS1IpvXVCc8>CB#NmtOT;!?k_ zcI5LqF3pkOt8uG)RX%h~TOpU;A7|_}D#v<hr&BkT7ZRB#=HgPN3O1Rv%(r=K&KP%XudYq=HDq+g%WGa z3-a2k#M<_PSlg9YJ9;!d`nFnbC)!iT?Sfm+Rl+^URlz;P?S^}p+XJ_O+Y9%|*Njda zI06pYC&NqIQEop{AL9~P&& z7hDh54R?^^;9lk&aQUz4rFb);M;?ndf%9{{sIQmnLmoq%6Yekvz-k-eF4?beF8D^d z0k}TS4fiVNf$Qf6;f`^a;lAQ{mE$iJt5kdNjcP_UwlVu_Sn5oiV|Yuc z;Pg7GtOK55ht+OvvO8TP_R-QlkKlE1R{Cc3xV;`LMYY=9ob{5!F=%yq5iscTj5^)D z_--9=@V$;QlgWxl$<)d_dYyuoAEnSG5AWz2a=N7Kg|dDDIrQ+J0l9Q*vA54*_3}gB zKHIV^UC7XSzO?j`6IB`P^}9W8hhG>Pw0GGBhu=HI3!e66NrXPnh;&al8FmUzG;vueuau9BjFd+L@tI1ivO3(uCDv}wz@SHPlten&Ds=PCL9g=j zl4G>QDmc1%hgVou8rQYFm`JNQyO;HmTxFrp&SQYPT|*dX-nt284vsDzg_D4RUf!|1 zh+&UwXuu&nUk=M50zg(N7IJla9d56HVgW?4O)ih*v%CQetmMqk^NyZ&sRa_mY_bYo zJMSf!V-Q)4PNxx&M$BsO?)D71z00Ad+kqs@x}W8G%;7B(to810ess`l9d=^;Jp84u zA-A=zvAJ|}MTxb`(c|GA^pj1I=Q@KXO1!Myy-xQP3=%(#-tBS$W~`9^ShgAQ9KuGb(2XFdrQ1ayh%(t=&Vsi?x$YNN*qS8S3q`N@WxeI^AvzWn_|; zSUo&z397ujA2@fMVWW=daXAOA4irCXg!QvgV~9o=4K;#f!Q-tN9-xif^aBwLoZRl+nP3 zC5KK6`5uw=Qc8yx?Jv9F!wXvB@O#nWI z3<2L2WfGJ-1uM`9H`MI~9;rfZ2=ID5E-Sr}HOmO0K*ruajNAwhY)4Ums7p+Qwb|H8 zF@U=x-;o-vat;G{mZ0BFl&OGPI|c?_=pzBx#sl2OJ4*Q>w+YyeRfbAH;a#39W!7dd z_2nR1!XVUzOli(z#+L#1?XGf6Td$*>=4@$?%i-`w5FbPSWv0(*d1?$Bl@fg>%12ro ze3_CTW5M%IE)p6}uB$(@rK7o}x~0COuA#}7`?Y0-jV$Wn6U{BYEUH2o8qhQ3a-mY) zm&$-`!v+?k;iNBV%hs~W?PXiG>?*6+xh-t4yS;rL_h=-GBd3p_46DK>7W-t=iJE$w zKCBglzGs?J&$FZ&g5={K&aoE&z+2EJUs|mM?k&y%hiAyUcguF4ru0ZyZGDzd-sc%` zl>2nF4*1evDHr(e^4N;`%A^>PDzN3Eo41d+5XsX+)h?G;zVb4x8$PrAvy|)d6>i_m zb#2+XeOIYt`>xHU+w46%O1mmIZ!6upV@KDHuC6WiofTcqYpA3z4Lyf8lsmZ&2e)PW z_KHg1ib$acVk<;Nc1O07jGoZL)#C5V8aQvMyvjFD?N`06dPn1pY#ddpejQ7(#Njme zv>#AyIYh9jReic5|<4?$eb4_*KG1)uSJ#~8W#Jz$KH{RViTle$i z>M!w0^}o7ddWH{L_%xKoXOgo5j#PDiCA?vs+XGav7T$yOB~sy`$)Ty{srt$Gdl?_D zyt{IC+s{)D@T(Ewe|3e8XRDhU8yXK)SzlP`5j*eZt*Fl-sx-9;y*xBI8psIf#jFkY znm=s2+cxX{c}ksBYXu?@Q-L2Be*AOc#~Xnpz#3@V<&R(eK?FKwNHf$oHJxaZTW8bp zSxCcYlS5q});X>?y*3S>%YJlYRtr>jt5q5;E$4Lbg$Ou|KOrB^HPsWHas1F^?d10H z^7pjwo9>uq3Loos{wU+)757)n75>>;sRv?fM>vAbpa5zY+XH@8bneJoC!VpHI4!61 zt9tcgDlhdPd*ZfO<=1d}zt*qw>+dCe5Gi#m!LR0D_b2ox%4wr-&Tt9a65j7M%VAt% z{FvD08>6#5Ze2BgmAjZT^(V=BMc>{O+3!u2ef{aOFZ%YYFeK4#S`dp4HVg2PH;4FrYQAaj!Y=*G5dw>h;yZap7 zmw4*@PZHB_Bu^wy3W4?0+}uv_WV6`L%_lmpRexr%Os2iH`sV6D+M~4m zC&??tHKo(`4_$X%(-&vAe$p?VYMDRNI)AY3z94pXFLd?=JNxFHuK59eVc<$|;EL!Q z3prm2b-wykrP^Pufm^MqrB}V?pyoI9IfsH#}t9=@*S1DJ? zZ@jAcENAU_#f@DPyF}fFKn)xT$0(QqU8&%h{~_TR1W6CA(VRMpXD}PIes!;gSb}5v zD0bmA@*JsCwI9c~1h338SH;64emyXcmILOwr~4pMlQDze(9c-#a_Q2W(-Y1S^!@}c zfw3zvi`{Fy3fAZ+P+s2$y^g=uk1N!k3b-j2!BH zo;Z38mGGO%R{_VC5>9MxKHO1L-CWPp5Qp{HW%WAvZ4_fWIXfs+N7w_{0wn(+kAYV4 z#H8md$*CfT`1AZ8a&q9<62rP7Y>@c96vC>zk9-Ve=a}rRbtW$aL=sp@s;~mey06d{a+pZ^r+a{9y)w6w7)~#*C}>( ziJW7Ba|JoqBaRmbdC@y6dVON|Rk6!2`d`6puh!HM;;7a%5aOU05J$D<1Yv^X((9Dw z4E;K#IZLktnsfAeRozOj)ol!MFvMg$uc4L!ZCpF?Ji?&mnGq#_1Wznc(ob-*?1>Uq zV<0y8)j%i&NbHFlUmG(eG=4oMhV~u3Tg~Z!Rur`5*MW4QZwY=4+(EO`EAtKvvN_?)07pQ$WZk@TCyRvb_dcsHnZ8Sj z20WZq%3xsFTH0gv>DjltY6jnpHf_vAAmGIUYKXAS!n#4bw=b+i7MMgR9W&z?qJ-6# zX?;b&^NXkfJGl-}3;a1myof)c9}ZU1#H1VM3G-xKC@~jnWaYl`{@YpauDZ1SlMyLFbE+dfzHaHZIC zT5LWeo;fdGXcy046q7r|UiZ&+9toTj5J1^9s0^G=!UJ%ED?RbRqhA35IhIQ0RVZd@ z#d=30(}lorn#N~(#znB{!WxgjHzOTGGGU{_8^AtM$9M=-tqA>YVjPJM>R`SD!5sbs zLc7;gx0~KQd+Y4G7j9j+XPec{49}jPxfohgCuT>sFmW4q8%bm53W(Q8ys?-iHr_V4 zUs+6%MdXRjDt@h(s5th-TR@{rWi*Xkaa$BOtL_RcidZzW<80RW+ivnHoQzL+^s?!qc)DPGpeK;km@qoDHi-c-q}# z8YMvP0#+^~37prE#Aadu6iytlUvh9xUf?evrUY384SY9cpyP%HuxVg?N5Wh{Xki2R zh%S4#19S<4N{xUW4zOle-6PN_(y}gXwM~M3*b#xUVSMSspWuN*JMM(vJNm7oZyE#o z8Qo*u_9v#y$(E35<-z%LkRNZkGJ#Y)b|ux z9-ylw-iBXIO zfDg-{L^ojPFY({OhXMN71PAb8%)p9g%)M4~kN@!M-K!tIdiT{I@gHBkfA!;6@4p%< zKe15W8Z2)WyIo>=Yp8r+YRgpbt=$1{a8=17prYYh4dae^UFMtpfy@B^ID6ystDAzJ zrcq$*xW|kf!N#&D9@6(JBm6w)1IS42SN9WyEqgwQ{O;B)M=j0IP54ojG~w^68*K?Z zo1Y5$k1U!k^~X;h15dpaqv$Kh52xo6e12 zdDqGD`q#?7=vz)9?Y%EyWcU_up8UN(U-m`c98{%xHJs_5qWH9A5mT^XG(~g^@V~`a zT9n?=_%(vryI!s-mn45tgU<@`D!I{@4LM3gELsj^*yHSFgsDWbCwo1Y9PaungAf!7 z)n2yI4y)UI37a-=uG+l$^AaqkSQ90wYB*t}&to5OwqTb)TlKIRl5KG4S||_SdbdZB zCbC??@dI13Gm_W}xrfu;Y0F@1FV7+UvZ)?6IiSqo=4h)O)?(YkZ=pzAsU$6gEc{ja z>8GDbv~f}pGtX0)ks)}m9Xp^S6mc6lY|mc^pG^w}U{5c&l~)F_pnEO}G?A6XJdq#i zV3p?IMld0GfmTeQ4pZ_C+l1}Sy4&gR=G@9z$lVmgzNDlslv^LlJQy+^x>mbrO1e=r zQ8c+>DnDdevtY^#n)1Z_Ei=_2(+({53a{`?T1J29-Yn%50Ct6=WKeaWOwDyr-U7$2%+I+3{GjsBrS}}9? zeCnP>bMmz#(lSE*B0na07{Xf#_hkqV3$R*Z2T9bu42tnKPx7P%@uBQ9qRToMdVdQF zSQlM-;*k__(VT_|h$woK%OSBJhuu4ua9bOtjK}mw)l14y$P2kkK$PgYMC|V)XaY-M zLJV&q9tE5r_r#cC`C`bKUNsD>cy~+=anO(7z?lfsD)|_>6u)s;C0Mvrdh>fBPSJ2_ zK8zPbfZM$aYK@07xpZ(c(rA~llvVi>w3)$W0&Qm9%a+>?TK#bh^2zZV(Y_T-fT56R z5MUU%T&jIHDEwxXP8E|v(U1JL`ng@ezmbT7g4hvK7Nh8B#9NjMRs5E*L}HhMy_pz~ z6$*|`628P)sAPyiOGp%>c}M3|_o|)hF+(f%NMnhwBwqi8^d3=UJPA|4Va-x@-XBaHsCYu|JfP>(wj+vjE7rVPS%r` zFF+prI5}(`I7+_gVnFO(Y!Ba|(EkGtl>#xD0}O^P4>!s;QkWJcht*hxAU_JL9ZcFP z5HiGq5qTo=*51o5{y*TuxA7q6%G1@p0>`B=!@ zC~(0?g{roa$zvJP+WgPJkz$gZo_+5e&7qZ#5G&R`mgBBD{i%i(l?FQE~ey8H3w6yV&S$~ZLo0noZ(>+ zIB1`4dUW9Qlhn+&OgBwa6#>I^-W@Zx9`mWC_k16|a`%TQUDszsQgSDqj)Rp}v zLzP}7rd9sKla#C*{t5p=%KBi+`Y%*!v~4kC)zs*8{Y>5uH+-~VX2op!Z1u+n?;re+ zhv!=6x@Q~3-N(g^jpEu9;+m6U`l;Wq{fDPoO7=|Hj8S;7DX(s~=Es)ox^n%G^Aq9! zq+DOO%lOGQ1NnEaIcU=V`HJF$di`JM_3(Go0w+JT7}4)9DW}*4k90@CSkZ8r+sZCe z+NsJ*9;hv7idjtQHsqV^$Lc5`{D9uc1{Qwhn)gLT>` z@GR{#jzn%)cca@*@Xm=_u7(g_(;FpZwNVl~p4tF}V%jJdE#5Xt=5~$g{5l~y$|2)S z#H$B$7ITvx5XQQ=l!IB0fq}F%O6bju1mOgUYW5jROF4d&l+B=5<)ojHk@F>$md1=5 zeTj^kFZJRDVI!gC3&N)23(cEszCsxrf+J3fsG!HK3@3torifNB8>|e?f)!d#<)<`N zHyk@#-P%E-t)um=VGU2hZ7dkwePQ)+rtT5e_d@$}(5C1A6O!;BQ#wne5*=)(JWE09 z0fBHgLtzpEGXpG$H%~-2|1*4h3x7g49HMln^*N zvl_sVydNmZd_aAz_E(9f8^#Hvn7%$B+^hMp?k@QG72xNaE!Vrn%w4mqXOqSBL-Wap zNxrjlV&`PV>s6vI>v^^+?MoRbxmZ<*T?zmK>E^|6>4~@3jz?mGWf~cBM*L}Tz(&Iv zxWwoV4nPH&DrCzk7;>|3ZwvGWX;es}2Z;-0CKzi03WftvC+L9s0CWeLWJb|QXPU%N zOm*;Ol8BGku|DV>^3HDWVJP7qb&S@M#sSnY_R?l)&|B(ujQBEWq>qBzy@T<<_nAhLPT0H1qYmKs$W{t zCbx;XyJuU(`g8NG=NDQ#gRPwlt(StWm!5*#rEVa>UYq&|N%>mUN9k3kKBif;q>AYq zr|ZNmHFI@8D;Ljqh<1nAdj+4bs_QiXE6YK|RqGFGB=FFoIQdZ+Fr5flifJVD4C-HV zJId$)E|8Ekry~lqPIW`yt?30N+73hy$)KLoQ}|8Ib)ycUWBO)BrIK$%WlL!Ez5TnP z^O8b$6^13xb~Q$^xVQ(5XKz)ZyM(FFR9*eu5&f9$s)Orw2v^;Zv0ZiIOac^DhXgB> z>%oBJs=Z@)Fr)`idA1htKY}l;c8AR|6##8SnUD$}NWecs46xS}{J_sq5gzdCc~HFFFbEVy0LiPbmw5>lu?OWq zyMmRY1I+8`B$v zp`I7RmRDGrJsNoj$Z303J;a^@i(cPUU)RtKSYAX)e3+bHkn?}TA;@(*Y(|hX4llIO zAR-r{Od<0}Bh{|Kk657O)ZZb_@e9FG226RFF+iHe=U{#-a{h7<&QZ`( zZz0om(2!$lE=Fb;mHED?z+u%DHJ=38V~lP_H07}Bd+N8;;4o~~;dIy^Y9SAVwlz)% zIfZAb5tN`{1Z=-0KB3;_8FBDX)7F%2=08Uzmn>(-J=hzA0m$$3tpUM<<(0B4+rth6 z2>Z&E&qO~7v}gQps=vRc5|g`v&Gv`(@sZmFQ}(wvyjyasjPz@R=r za^ZbA*HoXa%nKzSx>YoJYC4V7^2KfSk8KBEyF68N?U0y!=reQr)HbMQhRmmKj!fnS z^4>4HQ#4)hgOZuNQ2tizv5(z9_AvYLo|CUtPmWxx6V0bSGe9+GD(&W>@r$43T0=?a zZskl?Ppdz)+_lV{d0f0_?$l2&Jh&h>oq1e;_BH!d&b5PL(z!)T`XkF4oM^ZlH0O!B zysy46t5OcDzxo^VsjvPf=^T^E{p;+SwYu9nICpbv3Um*&Y2;?<$lZ`#Q>=TiHx2HO zQ*>~bpdba*`_bfB3>R--K>G(^y*PlQ;JOGn$CG?U!5WxQ+xX0M{-V9qx4E>keN$Ln zdRNC5AgoP8UHlg`hR|TaiAbB8pP(V3`4*YxI>9y6OQ=ruDSg=hhbDN^jU5v^CQET9 zfqTFIPX7;FA@kM+5K89C*@BRHpQzjS{7ly&Et~1IT+&RJ*$@3x-m)h;zol3l&FmMa zQ0M?9g(l_*1C_q)f!e=Xrc!wIsQx9fK_kS(h8`W%`w3&{zz+cRfET4 zrV+)F%y-b^J6PP?*Ub~5Rvy9sn0}?o;jD6*YV*sUDu3BVndB>#18owa0kRcGjCMG$ z^x$Njpd4B9CCTwh2OM6zFIh?NbocOf;Pb&j@L=9VYH9TKPz=B^KJZ>}PzqwIfnGip zBe9hCVq%Fqc#q4$XW{G5kX&%Wxu$~P<+aMm{2S#HX{4U=1L z?4Q^_wIgKCe`KcMx+ezX?_K%U6*09HTk&~AhuG(e1WhJR70esf21XVQW-%rIks&|e znav1p*dx}TUNomZ4@e}0xfBH)NJbTePkZqw6UM0wq_nw`P$-u` zh!hfLny2iE$E=A*f<_5eWr@g-3UcD9A;n0k6=GLTE5F@pC#h0>^V(#}^_O$vfA`uv zmsZZht>p|(6@&>|EOiFzss0?foakG|O;~(&-I_iuL+yx4jIDU`_NW>cmfb9DcoQk# z?`p2E!Xl?fDt#Z$#r#0+L#@X2=nYK4I@P4U9a~1yf0sl{V~PGm&P*w#5k%R>Zs-)C zY>2?dj1cWG5fo>kILfai`t^q9dIrieA(rVyA{FFmzG3MG@sC`SB3lykn8k15k|Sh; z#cu`?l7h{+cFZzn1`;v@0*NAGkKZKs0CN}_a`%reD(D-e@efic_5WfF5+cSyxyq3MPBr)>{H5Q zJ*51SdM};Jq#V2jO6+tli$WquH$`qGmknk{1hIn@Zo`%fDq6j&u?(~{hf>I+HK}g@ zE!vam&y?Epnr=eJt%!|rBP&7>W*}acKP%EFa+@WxEkzE;C}d$wvk@~J{k++q9qDIE z$;5e5U7c}tK)99B-odEn#4ufQ{5jkzs+Yoa$@!gfT>VXQB+=&Bim0Fwy(RU0GJ1vi z6n1g07lf_!_;cmHW}D@eW2-of$k^(!HU8We&(roxD9_?0Z6o&hEnZ@*vqxU;(2JSg z3OTfYi|mWOxpjVv9D8i7e=VQkUoE8h*Y=YZ!?K67%5@!E{gPbsmgb6job#^%DSZB= zIj^}sfU_$Z{qf*?wSSG=mv}QM_mXUlJs8n^8e_SR^6wYIOu;vbU8uxffq6=WEqmnJ zMd8N3(!a`EDMyQrws&`&u;^Ik#><^sFPH!PNb?ndmpC=z)^i)=@$KI$ml=J_c%M)E zy7Y3HIGCW~if$*%ztyTq&2U41s^M443d1$Tfk#5dK1{Dg3$c zvzEo+PTIx;clOuH^^3ka#q<*TEz6%HwFaBl9OSj-pPCnXY1E#lt} z|MrMK6@In`qwF2%(k+26bGhZw8o{&o$F9a6Y zA0G=~wyp4`EOkHy&Aa}y%**0-cH%a)!x>4yX9YAhwQ^i^cN^i$vMhzuI{#W zY*WlWP1zYm%{M}Ut|Vrk*CUq{efu~0OE~MjdYNVel$G?;_IcUE9hA#`(Sep2yr3`! zF;0h~n$D0HD{K(#K7dW6$2%bV`rWcG`o@Z^x_4M!-=j4d#hhiVM4iG^G)8=EkE4WU zDZZRo9~9t-VZTD3__P8DB91{(KyEbgvkr z3T)5SFk07mzQWl0Mbbv=T%>$Oi=I>K`}4@*o`y!>sV3;Ak$E7=##2N*N=C$(Eh1PJ zV_eP>*=!ftHc%{v$+|2{j0D*Su~~|UY)7KHHX|^fCBROaqge5gKFx?t7Rd_B#I2+; zK}OGHRh)R=A%T@^8t=3Gts!F9U`d>qtQ#QQ^$hXdj;qAvvNEpMSfu5`)ks&=v5pX6 zP>;-DjluF3F^iXll1vy+q_>uo2MIE3l4RX1Zj~Y>p*l+|H$bM#PU1Wwnfv0QmelO@ zIgZyzWigkh2@*CcT?_jWl17ujhv9 z&dX-UfM?E3%-zYi3jmw3))rR|#_+?F))qq1-t6F&GVm?Vpd z%%x=eUzU#KqXc%0UCF0V^>rstG_^2}t1TJgi7uvtLb@Rkk}<#3C)7J&$r3Y@Se+~_ zYd^o9a?x;II8xQs!KAG`@ma%qH%VXt7k~g#LgPtf&VyQ5YA%Sl%S^gh1<$`#EAqv3 zEb2H0!l{V~5(h4dg<=%Ol}zh#9bkkbiSj6;NuubO(PZ*Sc@!Z5Hmj8} zmB*OIHZrQ%mW`^J^jE`1wUOV0LSn}>toHm9Ml=t(2%Au|$cSbidvIn>J$ZR5`{wAQ zoZW#_f#Ex?GkG%yKe7ea?+)ecesD$-?Eb6O>(oA1l@)>j8O2*k$Y+KAo;3XxV})3* z5GY_RDP_1FhgRI)-B#g}b8wKX;y_D*tl=^AShm!T9J7+=h8;}KVdJl$n*1mn(&{F? zFrY$6A;K1tW|O%<>U-#W!_XL?i+1rYN>C05C;VCNWaSYe^1r3+~|wC&}IhcB9%KkoOuLE8X`2R+~KWD#bt&{XQ z6WEG{tcM-=bMis~v#(!EP}738EkgP3@JUU%V%(E=ovd>~;F2dVamNmeMD znerF&OQEG~g0^xcy>~C}uAz6G`dZDe$~J%4d$;#P_g(joTIb6Sy{RL8o7;uAN8c`= zPu=*i=~}~+BHQ~TcShcS<<2XA+BaXc|9WBMwAYF^8s|*~bG;Awxps)-))!Cb+_|u@ zephh)uKD#uheaGCvKO30Ln>_f2O*x98fC zMN2ZwRwSp6L-R3te0bsr%(v{>JL?YaIsUNs(VjD*l(WG-XJ#%>swX#)GSJPGKw4nO zo!sdi!MrWO)GgwkGq8A(a&~cT{=5CR`ft1DQx4p6-Q*_iQ)$!onLVMh{ohqjxgMn) zc$CukN!`QDpBx$2eU_3jd2TT|eX?Sr7e~O-GR6}Y(=#R;Zk9}q%%>NP*DPkQ0H*5~ zQAGCEnM3p0dnPrDnc0)WH;+t0{XTQDBbc#%I^$7B$#mahYW6~EUN9931A(J=%4YII zsoNJ)_XJb-%ym7?egMl=XM+dNk*%0+XRxzhyyOXX4q~JZXqcI*8p$x#0ZpR@>u{T7 zZmLsbCxy^1&1JG0)2ZRfYD}kOS!x)sFDd2~`nGRWgC#o6r}?!(m9rNLP#MKfO{&y% zXh@+&M{bq|43E=`(V|owHn?dTuU;%F7HzwOMSEt?J}RmYWn2mt)d%t(raj#8X|CAZ z6>R7hd;3ET{gc&`J#RFKMfKx#V#cM zw1Hj-JR+Ilx}dp8J#ksxLCrj`?v$c*Xv9k{@%#m`-4Qz9gXk0ii6ap*J2m5VPjc4> z?9;kCy*O=AJ;Qx;D3rTrGV50>*9RP-l_e8L$Ll87FBTMumLgdE>tD=T`PPM-7ru9K zQvD%`??=6}_CM*ryYi`7m0kGMqDo4;adhHn;LvQQcF|^}V^&x7z_~lS9~Eqy$zRB?4CYtP7KQR_9(qymLT5jNL67 ziiSg-Ba7<_X0kq6`LI4z-8A3cDcXBN?Y&Q`>gG=WwDm#j!{Jc zXa^8q7KNdZeR#qCO3?lahO0|mtqGidzx7V*^zi)p9jMVE%~1fQHusq3f2y=e$2Cv$ zR2jBk7pqoVLn%9MZJV?QDgtNjRL`VuI@pR^y?^qsgrqsnVLIQF?HrvVQAEs`aS>**;BFrR7gwp2__Kf2g4Su75gj_VUNR`#$l=`N#V&e7}0yA1r8pYNVJZ zRazF1rS4|$Q?vBjx)Ghxc6YD*dv}d`u43-&gDN@Tq`Fltw!wywBiPm>2Rgw=6um9hpu70$p^iMNq?6V0UIsarGM%?(o^$2MC%(;d7j$l0|X?Lie z6CFLl`d+dBlIXe;>>qv9|Ef6lD&}6lG-v6Bd=CTC(Xf7~@8j2TsgnI_X5*_~eUfUh zP>p}hs{hM%8^6d!HJVe>3H2@#)`KpZ&pqLCl@}- z*fp*DF!65UOm?Vv7vMf42PhBfw%IGE`h(fU(}gp6cWu*!liXDQqwL~E*{<5W#X3_6 zTa5{KN`H~HjV*msy6;v7(x%scf9-7DT-yCZGg&j2|9Ax&m%aU$E7y(JPZqv@^yyAj z`id`isnT<4!exhO_Wis zq*0}${oOH*YW3Q8ci-Cm?*3c*?``>T_ubte?!UV~v}*Uls`}un`iG<9s(Q?adDjr| z&?`73f&S*y2Q^PJSKQ7H=-)TrG5;Vblv@_cEC=ahuE2R57`*74(#{)K2h{Ivd%yBd zC2VNHP}bvueRJxc8Xg!P>OM(YFs=q|b$#pj(Cby>C6oQ{tq7RJ#7*YhJ z>w-z^0xdIz^GQ1&>vnzh#d(b?{kZz8FR=WcQ-Ae~lmlOVu|k#ULP&xt6CuFhv{?Q9 zuQJv>vUI)2fjmjL>zqD63p*RL2k+ZvSIr$4o0?(iQrvWUe#04%3_m~D{tu$1>mR>Z zsY>1XRTzKBE4^0zRXF_+d9zNj@>aJYq@ew)FLX3FzWO4O;QluoRHikearHCd0m#Ll zBxheN*ZpL@0q!T3?2FqHKG~Ucu`=<`^*Xpe-IjWBf8s;Ef!qp9hc5Bcm6i^p>C;Ua z9m%@CDo*NDCH&P69b8eB)TvJplXc{-$mldBh$T95cVu*?Bm_-5a*Iu!nTf%zW^(uI z;D%DNJ990e4LWkSXLqi(%v*Hi?pWJdU;zr>+*xdSvVUV|srIjvjU9Upf1R_cW0&Et zcj@8(WvRY%$5KlpvE#D%&6Dx;x{ga z%!Q(^kY(pHNEPLlFf52Q$@)AIIsfze9Ouf^#H0c*UK6zo2dy}zW8;paGCoQ)oH>1(p5jZRD zbD6n9d4Rw}#wM`}b`Ei(Zh-DCU}g_xOZ+7!+5p0OKeo^K=x{}D8X)$S)lJd>D|n7x ztKAy85QI{fS#g*SmznUAWkgm2uF1f)BhvL3#k8j`vEsbD%L`kB;8;VFKnm9#5o^TK z4JM39CI<;6#O>}-_UR?Ky`>|Zs$1t-_y2pE5qhGLQ$8=127X0GuU97m|s`6s;&v zN6pJ=U|JFLn(FUNDx>*^VZw03GGTf1th9v;n5VakYb!#DTVU=EnxiA%8u`6fzV*ta zecrI*i6wP1?e#d8}Yox6EXvO{)HH+|{@Lul>h`K$_@)Vy~1d9_gmF%bhXR%P@9 z{HBLAjKRla;198GT2y^h4)rtstqf++^^e4khsNYwA(({{)ELa&a2lFeeu@9P4!@bX zu!!hg7gvp25rj#~a*?z+e-KkQ<$N%SvzV0%WisNlGVDNbiLpXKTnk`ql$dP?Xeb#Y zoKo-=`1$#7@%WO4-0UbP!-`g4`fh7y6de)=sS^xX!enePB`#Vk{UlTf4(9}AH%tKp z5=%6SB|sywZ6&@7>^?6ybdbrQw${S!0k3k6ldf~wiAp@RK$nqWcg zoHtZ(biM_2D;H{UJUZ?GMU-6dE7;>KozhKS5!Y3SiCZ4)wmuIs8K@n@t8Z$)7Gz`) zpm@ukC z{9;-YyoO7M&RK?3Zgg$Icc#fIPv*!p%#iA{BwiLx+xYR8_7rP_0Xb(=3goEAN~Dpr zT_M$0bYr0p3w@Yqt71x1r1w~VSHvFuS7h-TMMe#uEjKciNwTtpBp`4h@coj56N~NS z1zv8D)M+>c2UF$j6a#D?M0_v`9`WIX1D_m&bCjhW)il0?^N{T1gEY78C@>6Tcyxmg zGf{#!_bdrZBaS3SZea2mJZ^R!SXhfYlze75xWcR7k6WKO_0s z0wW+Gc7b7H0pALlxgC{(NunNN5(|fr252TJN7~_plJWyPgGrmf*)(TN@)P?4wng*G zsji^eI-QF%2a`1uyQjuJGbi27m}+`E2X?En#q44nn}b~tiK-(}1Z%EOyJzb*$*ecA zlzp0gxR7Q~B@=MLvpuYXU{`=|TUbZzXvDAB;?sku9Q1_MqpX!oZpGJ9mWo)Cx`8Mc z(3}j>GD*eXQeV*e88*Pk7z}swtnt!K8?2A?dV_GWV3QNh!{?3&B$tLUk8#cFRl2+UeSlGn2LB$7r+y=~JtK+qvm2fmUqG46~YP|7^wV znYo(TqjQ%ZX2a6$L$lc2BA#v+FWN<*`L15k)Hknl{vs)DT>XR$h=aYFJGFH(W$JQ- zL%k)CjJ(t{gd&*nB;(=dBo^vbHQP>~$4v&<3%3I! zBNq;AL!@ih^w6(fiEVg6bBTUk(zxg~pmEd7qZy)f1GvxtB@AflvcADeY>m*5v=%OL_LWARHAOT=5UVwS47*UBhZi43a(Bsq!fh$!s0~&v1N~(BQxLnTq&TCw2e$Tfr%qQ>SQ6kMDE@rA5kw1FX3D)O}E$s zb4LM5V;FvtiI&Blq1Fw|W5uI-lXjgIM4^QQ87-R3`C|0CVUru(K( zsvdHG<^0qcsy&AZHovR;@fPlRqJ-MUh?0L~M2U>gNzXvY@uEQKK{#6ufuXguz|)ch zzdlx}52sG!kS#EG8OcRC;jfc(MGgsmEvcDBk5kEEoZ%ha^3b?ctwGM8<&!Aq9gob2 zw?V{tJF;Of)k9HHe2F!@I2%pmLoHG0XIudUrE?G+wIGl5FUe0~F37b9!{Nm(NsGpm zeX!!8SmTrX1S+4^e%ShuXnBwiCL*tcrJG&`WBPBj+A{`xQ9jlI|(DF{2y;RrYYuk%65+HLfQ~~NUv(fnGfX$jxSXH`gOOhBtv=72v z9!<3!O(#yYge}tbjlxh*kMl|l`{)hIMNcXLymW;J>8A3eoC*CUCv1g)K~argFr~z( z9wH1Ni|2bKMKXOiR0;*2Fb}Cuz~BxcWa3TWk!90lz6WODI;f1>h)EQJ&@%0F&o_W$L5OJyg%MW>s-b!3pPxbJu29By>Gk? znlYJE2|@F^A9a1)d%t&K-^t*hPP56>DGl(*Gm@CbqV9T@g5u+_Xb)(-DHY}9rL=KFoJRI z=<~>1SvKo1T(QfhWG_m(<&Vsc^(tHccqm%lSz;0-H1gjiD8Tt%=QEJq(8nu(rYkM& zzwApXi>R@5M6T@AFp%Och>x@01X8g}7w-FGN-vSv{2hGfC=T!sEnw!Paqr~msh&Vf z;NaAW=`-T?gE*QWG9O+rHwMj(A@fO5ck%^ICUvj3aDM{!8aT$;jv0VWD6*EGxNJq@ z*(`g&qfsaW*q%|o#!XM%z3f?HFQzt%)1u+=j`+$QKBi+pzZkFy#?&3*yn>Gz=C*SF)?-}5b$dN9=hnkJ z=&EE%5g9kE%BDrg_GUShY{9yOK{ko2$Sw~fU0700mhW4Zelt#Et#kofnN8!f#O4$I zMHxbKD`i=J;pC&4w}T5wbOzr@ey5uuqT1^A|9 zunt#Tg1{!A{?~{LTVN*3wc1}KmyK(lSdwoXnmELScxh8LA?J9bl|EW(lsCGZE{FA#2ons~`e`Ec(@U-@{THi~f(w?=Pl~;!m$Wku zet1cWxIF_ye>$0cCf9>3N?<1e^!y%14>QM9r%XIwnlx$3V}deBrLBC#(G?T;%&1(G z&Qre6GL_#kv9LjT6XO&!le*~VE;!)DDiekg3rl#h;glYjk%D%vj1c%gLLsyy2$UhH z1cQlK?_$cjd4u)6ruWa@Is5*_I~V`7=7)zqI`qS1A03-Zo2wNMp9|KthPJiMuRkwx zy<*>$AU7(ukNzSFJbHP-CizzWyG6H($N~}cB=Vs2^VnQEWB-FIv|t}r|0>Gmf3xeY z-kZH|xo*0khH?pdV%Z1g4b|e2POPcVFE0vPpM)1=R^-K#MF5u&V5)41;;pW+7XLvA z8{W|Z{U$atMZ#w(l2JM3GJBsTCZJT>8~!8pKk=_XVdB1faIx3`@dyS1N%UOox|>Aj z4|-t!Niui&$CQr7U?<{X3<}r3Uw@~5x^%v9muOxs>fCb{sA3F%+PZN3V(|Dyu>&`q z^@fi3iN~Cxb3okZMii-=VsQ%X^VqJD$k&&56PTA!U;P@)qwJU>OjBYq;|f)YHTyQ! zXWE6uk z-IaW`Q96bAw9(zX98=y8gJVm*A9FuaRP;*Bc-l`g+v3{T-Bgqn9hZ_xiYYM}JO5O< zjqxs-?I&i*vgai!;_VGJ*!!hEhg0+ZxjDHjVscoqteW^4Pm;%_#q`h0=hMivp}a2M zOP75(WUkP8@vfi*We5nSht0J()Gn7H#{lBWbmwre6ef*+JOdwGHmB^P0R^xH`rPQ} za4RtWxwukP$E}q3AH9lc6157q3gk&O0{>E}9k>GpN~l^gf`NLhrj0t@Yv?VH6}y-; zUQ5=>E#VKxj6u4bZ}i>2KI)f!FG&#(f^;wtvzFB?f(MoI6Xh@9E36|ro3nyy0Zq^N zU5PlRSR|K=t3%_o#jngSFYS%kBab=g`wf1Bp9!YmFOmH?uxq5Uh^CJ*+@a#~xO}fW^bkZF(#il z*h5J{*Ivhg8?IQ6!NbyM}&T@$=mz=Nnm(OU9C5%pr+zK(Bf% z`D^knlT)EKF!;b`b3&vx@ym(UKlf`|Xp{34qAiT%$(3W4Rb$moiJ#W52uLw+vi!;Y zyX9VwzWpg!={937G1EyzXUOF(fvpM_50@Y-@f2JJ;cG2+)MjoAFyL0d4qV?bKn6E<-2E+be(RrvY5{#6U_s9QQRdhT9?I z0=Zo@6m(=!O2qSZq za_`CY=XXY1^^!Wo@3H4{d{K`;5ui%00ZbtLVYF8Ov>R^22 z#nfFKNbqOLt>(|i^r#a0q7fO(h=I&wa*LvGZa;UxpP|$)+n?>vU`Eb1#Fq4uvCEe0 z7dt9kbu`qU$$$FKYWE++)<(|vMeXiC5pARF;}o+NV>wad99->pRNiJejz34vJ!;yA ztCOLRtM?(|*b09plzmbnDo2FLQn-T*BOYSzVRGL|fZ`6BtJ44<=lECnSFBRSw&KNo z%GJa(D;BMtT+1ar-oPD+n<`U|P3x8HN6HsNVtiOWD)+@$?n~0ggJi{w8s8NIm+>&> zv6#M6QoU%zy*2U}#xt8DLpUD`*SXwrxiv5~veLgY9%kYiqdi83JfO%Gp?6_QFt=uzmoj?IrM=3AH zxqwkfuFb!;SCJZnl538o&mytTa~EQSXLh-lqHmc#5-c&hk#@OcwaRUMuH=i@Gk0*E zoLz=Gtjk?7{_c15y~>^tqpQ%2kvefm@8B$456Of;lgE$S%k^B! zI5+3%Rqpude?g1J*7?^7M`B8+90!->co~MX_$4h7-qCViZYZWb!!iDmcl3S}cYR$&Ah1PHKul8&1`9DaMVm#Ng zP_E?0xK}{M8@T*?ugXxQRw?R|DbE}7fyu1jmv87>dVe=4t5@VNF}*19&Az527b5p#XX@;FBAuAagm7whM4&`)sST+;D$V0 zHtN$eeQSw_Y$(Hp4lt)68LII@>L-MgYmZf*uB-1ja-ycAuAvF$6%yz&2Qq@HhAv2jgpQO*_BNcjEw=lTU@fLx$;yVcYsl%sO*hIlv}9_*7EW|WV*0Xh!xFpe zp}foKE|-J(e=oNwY@wTSI-q1L;3$gJ5Y3h?zU;US-5X9?a=rvych+mHyQqu8>b*Xr zT#7ZUXVvrROG}|nLN6RWfdauM0vG*8>W#3G%I_CCDt1BMOa6Qad#gy`OY+{02)0n0 zVHjPy!%-YBj)ka!Gi6Qmn5jct|AL!>WB1KI{ zIpWHg6h%cWUbu)RVg_bS)C^46K=w*7T^k|oP@|jC?|(=A;9_0wS|aR-G4CTQEk#wE zmFqt4Bc+g!JS6KrD^CY)m3K0sf;OdFFdq8-rI7!0-;}hNl5hDxdrOmh5|LS zJ|!7o(p5JdvikI<`o=R>pJs2Nuau28-6bYiIR|hL^tZpR_o{I9W{LHgiBzYYy>5(= z&vNlX*?t*z!fLzEV#-rG-o*bQYV5Pg-!Z4iAQlS189XIc?C{J`-z0W)&>^Ibj#L8o9PT*XbgZMpXP{d24RuL~k|b&ZZumrco7R^W`Bp9q+XObj z`Cm|VG^N#&7P%QW<9T3O$^~0xzKR&VG3bd6IK5Sh{+QM7BF&3YOlpQ2B*ae_?om7+ z@^O1gC6#yJ_yL~KD!?Z|%0;FuAbw-zhu(Hr@ zC>{Zsc`-%HtcPUtKB%dw+vP0vdY}c*8i7KGx0I4@yV99uCR44aRJy9w!IjqF_+2eq z@vH~wHd^cUvI^@Sf~`T<=mBa1?-_JP?a&lxC3Un=q=+5=xoz zA|Kw68*=oURAYQ5h!0ELLjzs7H!wiMpICN&b9)&=F@EftGD^~A7aY$FrKMh{*X8h~ zJvU-34vx_I5{}~#o?!KDvU+-Y6a)@?Ns%Eg?u^s->pN{_)@qKEdXIqs`Upa01|afy z`U+W3+pY3+DOKzy#Vj&IBbeCe!`M!@MmxQeIy_FVcOuEtci;P7%wVzA>3pQ`Yk`Z~ zm3x3C5`_A^T=JH72hI2z-g7A2DhWZ&6%Q zg9sZUHp{{$oSUFnAd;}q4-lm);HvJ0zCKuMklSM zFQh9G*0VXozd-@)WMm74>Uv$CF6oZ=Wm-&wh!{>J*dh(juz7H(3wI`W&{0q_C#K?= z`lt?9J$b`M2Y{3j7W_lXzmOafd-Jc6k90+OQWjwPeEfIt(`J>9fBc9(F~two@0t-@ zL_89qO_^j}k6RdF)=tA;3Txc><9fncz!+Q6=>{JnCBg=TbfDuTn@+^+mJB^P>M9a8z;d}~CizdPl&g<|jCMDlEJ#l(+*XtdRk}BUz zhLURb-O-S>^4dXC@wAZ`%jA;wT+@LWxuw61MGHsrz5c9Ur_w+6F3N`zDJTAPA^{;DX|kvbuxpAPNEp5M+@P z0ZJICRh8WdSw7dG(z=FInVn&mb2F^;PGVQyr^ueXQ#h5~P2R5QI$?wbNqO&d*OPO* zYnErSD{H1J?K}6G_xsN|aSmo^R#w+cC};i8fByaa|M$J$`;jq6wMevaO4~u!9}}80 zM6NTSIjjA!T`C2m6;aOM8Wu?!dkt+>+R3dbJ~D1428*+$$0h(GF4a+6o;) zR_RXYaIZ?&N){os!waf(?X>IFo~9s?9lGlkglj$WjUzJ+ubx=R%YV;s-{2hyQuu?dYo@xTV&R<&8f_AEZsZFSA z5h7Xx`nJ!sM>U2z&C}>qchCvAD0)Z*=>*o56D{e`tSg=?#snw8X|`Ec)LA!Uz{ym#jCN=)p{ ziP;HPS0E+_<=*A1Z1&iMt*`iU52@eewY_)g{w1OGc%bNnFf<~JI(?`@S=5G^{(YN9~t==9XzIdkc7fBf|jl0(~;2rmz@ogx+ z)9Y#lGIPe});+OgJWWo!JvcXbPy0^9-H3NAcP-wA`SgVWzqMvL`LIii+wPJ#&fgxH z8+l{QrCUkZ;Mw#zAqT}gcz;mXbBa2KwuU-Q6%inbJ9gF}o;6ShYy`g(E;hnm3qfed z&KRRrqs#%;L!FHSqZ2w*qt-^xRRQuCXlMzvwlBAK`dT}eTYG)2u#xK*&QJPVFZmlT zFE>p48m0pc*YK=hT-!*yvzjLGJB77r zz*hwZvLl+`M2978aQFG+v#FgW-p-xNWzCWI?3=gv;}6VKq94Sv7RMm3y@8l4D$^Zr z4Bj4{8}%n|cIlp`WxT!l&SoKVk5G1esnMTS?}}JSNOSjo=it4aUY)n``v;ddmHIZ7 zE=0h{@1tztup>}$<&&6rwCYQ9mu_F3yXxr;q-=S*F2mjUJ=5FKccT64@|M>X`qmYC z?f!K;U52MgslK%42dVSB0{MIWY0W}wn?I>tICIvQbk?Q)q@votvF6dHg?dl6XWY~4 zO`g|)Q!Hz~e!lXtbz}~^93G3b67)BCuW{t2h z=y@6yC&X{^M`bTZZSzHK^PUztvHVe$%TdRDQOB1~2&d2aqq>))27OV3!jJ=vU>YAD zw9)7aEg;VDb#0zC->SHMc<%7+6LTlf@!IFj{>1%@_9w;@i2hcVs!{CqUik39Qk;L^ zaew@YrQSePgU~{>SE9dUp}RNG&MikZ{35Xm^ll1L`purV2ks2KJ$z@_TY?(hu=7dG zuJ`$cU5nXE+NElt5mB5K$~vEv(h!hTt@$)2&b7;(?a_LUct?cz{r;E(%Q2O{n94v* zHMkyxxT+_X>Q61vs8l;f)phvPtY6eGS%Tjup{WZQJ*OEUd&l#-LD~)JF4C9pyl$M% z26Q~_c->Suc84_%+QnW8N3V@?hND=(jkFV;u!UK0tXrsGxU?|1a6m904H%EXaK)8~ zSGRcD#5u%5-r`|=3{wW0m;U{~EQgZKq;r)K1aGE?qaBS$n83`gA=8c4B~qO;WD` zj9l@Y;h(f&eTE^rK|5rp&oGPtVNo%kVwPodmAJBJP6v$ZXwFp+PRMq5q=#R?P$hJp z6GkrzSDa6b)9|Uz3N+4r%xj#_UuY1_hn5UKH`adf>s?{i!y7&RB?ms3~QGmbO7(Y;;`@7(cJ1%eDZanA8V0sR{C$p* zjtdL}r4fH$!j8+5&&TT4$c79^iw=LEd$1uG?R71u8I81KHY)y9{5C2uXed}d{5uBy z(AjXzP|2E5z<9C;l11Ag@9SW)DXF=bXkJHKfNvve87H%Fc8yPfIEGKeTUTf%PO5ug zm7`;324}TzHhSW|*MhmY=t@{f%JXIh%-iPc!N_TjcE#OJg*o<4PqIHQZ#izeFK+vM z!hD}UZokjGf5GN6R|xtF2FZm3WJ_&z8>n!bD^47(gdgM<5(2G8$mf>YX4PFwb=#@B zVA#lp>hj8_uJTi-x>n;FTdGeqRUbbZjA~7#>XCR>soQYy8md*P&Y7@&*fzXsoDEa- z?Xso_1^gl>!;^?eMI61I#^KOlYQ@|@juz6xRK=zCELsKuv+O}LX1S&jt{^C7EP<3U zjL4?>KSp3ws0OvN8Y30dsfOyBqirBU4z6ou$>TpDwCLNH3czYrQ;Tm+>`PmXlo%DmJ3h0TITzLmCD z^Ct;=HGdYdR|7%v|3azwEjA1me~zvB_DE}gH zz-se9p|di3Grdxuz;DyN=YFzAP_6=KKT%Rl+-rc-L5zgYJ%Ce{{SAqG4IH_}iAN;| zQQXQia2bL;&<~YX2vize8}7&1jh$rMB|D*H8n*ut!sSpg1ht<0Lj?~Bw~D`8ZXipM zbED|B;Om^~g8fvDDn+3q2}QsKgn4kVq1?cVIvf}?STeJ{=0+(n=FF95Fqen)VO%82 zv;p0~K*(d!#f?2)emg9VwUQelt>jin%SAIhV(9V=A_akWgf*i*h>HcLAZQU33TpyP zxl|LJ=u$EAP1G-YlUyfFsyc~hb>jTWI=S$B)JfUzS||Ma(0EA|HeAmoQt1Q7`CQqf zT_&;8-b1x>yRu$&WdkdbdN?hV>%5ifJY>JK%I!r&*1~Dz zOL`pII7T12r>Xk61w9}WrI|%74Hh!+7|v~Svd=X1>Fa?e$hK$5xgR3&;&Z>D%;zrP zRP{vGm+uZ`fBBl6m!TlOT?xbGJP2Z@p`SMZHw+i6FCSrmGBex^m)xSF4+e7%lmi#5 za`qD(y%4hwxU&@ICN+H8z;Lz^T<#F*5}*4ixJE9QCNxexSrjp+J-TT;3;LrxN1dcM z0iPaRyg-Et%}LNd35f0nN1?Bk!a&0T{SebvkTV|H2?9L{wa?%v+z}MQIRF&ct>x`q z_%7|hSi!thh>j>$kcd@_?8m2zq?%80iN|a?%wa0zhc8VPj`rE}!&k#k9X?Xm*hG7x z@uz)rV|Bx7B+knl8=(fQqT{Oart*sN#_Cnmsl$zpCr&~ay{g}}b4LmDVFDd6ziI}B z-^Fg54JrY++Q12u8(VN-vkemoA-M8=l+3HN`2|bK@L-Q^d>AfOkSO~EZyST->3q#< zY*TYXV_jEyGZbb`b;qk~S1ob^NP#9&7R93g2W@y(-0$8|x)+a{8mg;~Hd2;?CoErj zc3-<1#qI~Q7R^q93=p#_I%bh$VFeK(J6Iq$Hwro6Gwmh`EfGyQDFGL!T&YlbDVif_ z4JQXl)4j%r5^2j6mlI5}o1ZLBE`CY%L~|lhi$MAzWiq6i_|k_S%5k}9yP%97U2u%<5+}GemoMb3e%NB0(eSX~UVz1Y)bq zcXICLcq0Q@JLcO~;^Jp?Gv%*`LmU*h8AQ}Z(@f$wF1fP5dCgt&_U=2oJ%xevVv0IUC3IEIxmbCeE@Zz>>KusSL>#BVH^3&{sh2*7@rNNI62E4S)j)7iou(BcMCN%5(q{G*rldwu!{qhjb@tRa>j8 zqg}nOkwo2xbWJ1%@7H$Fx|epicShGm_xiQx=&W1UE8gR1m)1wu`gH^BjFY}g5*8KgZgC^E>3Jmc>;?>gs?2XYTB9tTsE4}vCgIWX=! z#|y1s5kKQ=J&VE-67wI&D-$c3t)G-a=pQ-hi=eCBit9@=eD} zYKdMsFdz4$l!qxlO!aLp6JDu@ti<<9%SvhiWoRy(YNFN`ljUvoRC+acYl7!3!E+(8 z)E85_n6;3wQ2#+P3-A~Zv{QgTt@$hp^$Pp-_=MX@b4jji{x5tvwR-}Z5?nliR)Jcg9Xsm~&zSne2|)>auAeSCHH=E@qA2*FGgP@#waWZOSpmudR& zc@4I&g}tVIP4}AdHPdU6ubClB_zE+B$+SoNIu@fMb!e!mh+0~#~DJEO%gW1KO~M9zfGnEN9*%d4ho zJ#i&+;C*~S9Kk6)Qp`MuK8Sl)Dm!|w=;0g_toyWxctIRNW69uT4ddqXKTHzWFh5R* zGZshbs(csamGqYO1@8$O6nvfcV47+Oi5rSl{5(Hc_pY?mB#I*}uP)D;LrqJS>G*80 z>`_L{^DY=K(=e$_cVH^YWx&jgnP(zD8_3*IoPcsx_*leug?@n1qM(CMXW@<1PM`a6 znQ}`&ZG;Yyr3w)y`YBcxn3S67h!;GW?TkSBa@59L+~%PW6kAGexy_P{E3cOS-|5>- zFMrPZo-aif9nCZ?psB;!$%F!scn%*CoLdxH8{qzG0tlSoYy+5%UN{*7h|O_ilXVnG z&C3)!VP(j>l@%f}%sAoK%4Tgk(a69Xc=+-l&hcn*NonDB{3|ZYzCv;-nnFz%I%L;= zwldKn9r5z?swECa)I|dRKKCL&-;KB(HfsslkicJzV$`aOan{7AzaX@3)~vQ8$IM)1 zBRAUqf~Ue~jcd`nvk`p$3&IBNlF(!QZrP`LY+JIZ7ECLoo*`9WNAHokE%`cZIcOg>_%j3*H;eQW>)* zm3n^H3ql8_7#Jpqu@1Ir7!*UdiZ~2u@lv(E-QtC}*0ndY_(ONK5;j`|#1Bk2+^BFM_D6Qjd@Hd_HagOYnWc8`_? zgzJHe$r1|7Fo|XCmemR_A3G2-W!AzXVArIJ3b+W*uvc0chOumote3vbJE$*_nUJIrA>$=w!!!ulzfLi4pnRd&WzVs4Vbdsu%sNW4q=KepATH2IhJW%^ z2BZdtw2X#J$;YW9isD3;M}R{_t!$xF=0Hx4yug|ja#Sqi<7w=+&;yc-)G|jroms5~ zsCMaEkw`V3Ph&qOb778GC;(7&SU%ah#zy7Wh>KY%{jB|fq#w&i06Ha~l?=<+$2nRk zg5j|N4DAE*(;PntC})|K(1QikD5Z$TE>ax8bzUJteP9|LXs%hiwvS{nhJ6+k7j|S> zrvZ#($gprUTck}zCJHg2i24G7;4F?AR!u~-Nd>oRU`59N5#^FrjjJ(e^~@1p*Wjpa z5S9Uvtb243WS1u<-_=Og-B1F85ek2$dk`YH=64G6Kd8pO}+YEKxU)%pQRgnPN}t-7?QMp%CtfdW8;7 zNa}lH>Hk~tzL0W!sbXoTkaQ|wssAJ%>Tunr=*!p^z z^sJE77O=F-sf-(Y0$0NsRP}LG?hypRMc?BEoQZBs!PSSt7Aw6HS66{`mmH-^J{YOB zfV2{it;Q?&q()<_fr^L4P!WuVdI(=bo5R>RSciXcZ{&|tTJ}?G3KyFh>yzEo*=X!0 zF%jzs^2pAv>?!CT2J~L+A}<@@rF52AJF-|@1=JJ!cyyhtf&vr=dI_~&I-lyZiI>sK zMt~FL+128Kp69MYhzBqwyE!@rQ;lBgo>^yEBIQ9u>beDD21u4yRzzEN{a@7YOP=rd zCFu8#B-M*qKG`do`$&0mg8_eG|=D*=6p%z;}75Knfy!e<9(I)rs2 zf@yTc)F?E=Wt+y-qq$!GDLF~I-8a|gKK#b0KQ8BS+&*vjdwuu&=4;*^5w`5Re(ayL zafWft=V7${$LuhJ1xSOvP*nEa?eo_8H}6O1ZGW`?iT)HYqchGMT{9%>0aUJUwogdR z@keid9KFq(wH#IKiz*f!>-wYi&uBj}#z?L&yFKA|2HhQAy^vn8p!u=k1A}m|!M~^R z@t$@8aIn%&UnzLHz?web>pJf*9TdhVh3Tu1R$SFolC*+$=$uueFSI&sJxMFBY8nV3 zW@^-~q5#HkUY@=Drp{gdy>N_wDSzs?n>j!3Tenv@05}MXx%0em77SpJr~$Dz>AP@A z7`iN6b_n~fJc)6_=VA1E_&kIJ3&JC57{V*#TMZDenA!}%ktsi5+yc*YrpTGiv!+|e ze8vsH(&`Na6BS|z#3!_4Z0r`IdII`hSq@VF?fvd-pLv6z-|%HJEovT2>Tv=U|6=Mf zE~vNzjTYL8_zQ)c2)QDrMH2fFL6&TE3xd&?#}s0vt4!i;Clxxcp`fZ%eh(BdFONIK z`c;XW1!w!=nC)v6@h0CjeX1djT_5yj|7aPFJJXm)Bc5P~`?;1&l&Y_VT4yh`aF& zjBc?F$CDA#lF|5giUl^A|;1$Zb(^CP$mTu0Ge*HA7=>>PZX51WnURfK4haI zdMe1yq@3HH%p)6t3MyHWK{DN>n zf(keY2-XMkDYt<%)p5F7Ffd3G#L&49(g(1p$HUy{&lx1-m-1zm^Yel@gK|-X z&!ip_GL`C3MD@5{$tkyvvMrYcNY6s&Q(fMWq1c0TL}+KhG>ZTO5SC>s@jD^(QPKAW zg>nxz$@2AS3zr>=bdSp!fG~vHOzDHFKT0B$!8kG<<&5HT={l78CPpF?Me^8SE~3#I zL2$;}eav+1Xq!T*$K`WdxUGnn<(mX#+%~RYz`zwgD3WE;L-}(0mHoTYA-A1CIn!}m zu^dMz@BpXfO61t+9U%FN<1HZkie;l*@a^2rw+y33ZkHSv?(W2B8N=a}ryh2gO9pcLEBynOt+^<-3md!?8 zaa)P;R+?9^@wSRe8Sf0%!p#H;aDmoN^$@bXRVg|JhzIdim`-pe%;|1~gHRk6$-TPi zglSe|xVwe!u1RY(OHYkL+AvfiC0(_1CWtLSu5*-fkmwvbg>9hbQ0Lg7K0^s7+IPv3 z0VqRXox^AeN1Tart7N&Ca?M?4@!kC`3N&nL0dk zYE7(r<&gG$@{t0OL2Bz&*QNHN)D7359@o)UlN9^=}Y2706GZ-?XUP&2#;xb z&epU(ZtZiY*M#i&idG-|mZHSh#&hOtJb8A_J3HS>UDM}T8GqHNHl6n3F_r6ru6G-E zZtbW#o$gE>s+P-E*~_&eem$J&lNx?Ah+u;tF^t&jxNZ>MNHxs$%I$#TxV{0^{7@mv zK-i(`YPH_k4`%=m~4` zF}CKJi)+r-wzhHh&EeVBMsaiYx1!{nJs0c5ns_GHoUM)Zl2ST|L#eCF+!W_f=JW%4 z^>=l8J$L2nmQKHxb2`^!JFUcuv#Uzl`nCO}gu?fTjj9`_H>%Q;&t2oLJ2xn8W+2?T z@xeEg{L}YH)o#9NKPI=#p%e0vvJXX^=M2t+QMTWb+a~)SDf9gxS*Lom1HlkMj~Pwy z3+7$F!<_NKtM5u{D;4ojl;o$8eQAk=$WD@1_REG8b~@u)9ACI*HZIMFxB>f7Iixc~ zZoeRM3QG%nSwBGD2i0S|s!WIESTtenAc&jJxC$%F8$T6~E@^@kMt@W*YS4>OY>voN zLA5vU!UZM@c0^d2w_^Ah!lhMBzJiS8Gcj#(l#{~zoPyNjdd|UvG7sH2BfEiMmvl^U zmE0$BAHZ_ls)ixv!|r4i`Da*0emBKw&HFneg7xF7j=3LzV)+3ZmLmd?!;e$^M(Ic?xh;t&Uq_>bOi0TE= zwd5>LQMT-Pw)qY{@F%o+n>HS7K!tHp6b#kypW%E}Z|5ia9A;63L+lR){3)suJ+2($ z{}nxwAoKVufr_3Ph_2;;BOF#Zr|J$kjFBQ~%K=AZq>Owx;E0P98J>WQZ8=(5-FP6& z5uFt&QM#N0t<(W<#~eLbez>};uJ%OxfmKcMs%ATrbCX}oF}oufiU~V8=`P@`1tZpy z(p|f(1!Gn)Y!wVx9Y$GA$TTX?64nOZURqjeeP)uw6d}0OwpR7qccWrAQK=lJ+?qk3 zL2@vrYr)N(+Z`!IqP`h47>xO(ke-FF2S=17GNW@alSE68l1c;Th#F~hq$n6=s$uE9 z+pLaK(w1Iip1xT>Aw?iueM>K~ZYA9&3FO4s@GQ%IAGc}>f>dDq(}6^b?IGAu;E4j} zJdUKbWl*|(J1egUtH@E}NGK|VXm20TDiffBkuI)h7mKP|tGY zzVP9XLRU-bQ(ajd^UM=;7!M ze;5uf^s#Y`iF8GKEJw#nqo*e^!qK3JS5RE+SxO<}8i8whg&v4`u)_ce#haCNV@HNq zs!}tfqO@+_ESCNlh(g9L@FdoD#D6Im`cMA>sliCqC~B_c#+2P3Y%y6@@kDMPbS5oz zKVGn^Yi(^6iF_zAqOvlZj;yqKL>ofB^T%m(A5Zg)1c+F7m}feX35S%5qNO0y9F)^z z77D`j3M4aLHCI)iI#Ji&MH%JGC;%;=`8L|TP8-JNL%tCihYnL6sJMHGkcX#5x@aTY zK}U_Wd4?Nn2zP9BmF4|E)6r2RZIu-JgyLTfXB_j)&4TdgUg%bCrKT*3IMgF#f-yD7l(NlHV{uEP-=O}sJ z91>Y_{2O+-$ux~@2CeF+w zFiCusYWkCiIAH68q%8Mr$awhuL2n1rv6qo~G-{hDQO%$OfTp++exoU++OxDft7SYS z+j=R%d7O47jTkFLlaDBHNj%Ovu&~M1Fw-)lTS?ytSoZ$M#&w?(84q!Ic+%j6>v7sE z?^_n?e;DJBK7>%9ahRN)bPWe$Ha*=~=;hy=ygxZV9Iic@#}|IlGfdB zn`?7VxCa)>7l!}5&YySUgSz=~YE%wm0`aGprQ14<<8}3yJ=$gF@yjLgJwr(u2_5}H|Bz5~VaB+UK+R|Oom98kQ|jI0-7&0SEG~5-i%X;S998^zaLr)X4fkaTd0F)MeGVotICE;R(k}j*_WCj6 zwh9;G^B$yoqvo4F+`6!Sv3p6sI0=_w_N9KI=?pOu={pJOFg1Gyt##WEVgea0_hUS; z*7&QWM@b;xY!T6|nopzF&75?{y}8d_AVlU}uY9UE&m4B^U6bxMA!)NW zd|nsO?+oHCo<_mG$bI5Yk+)bVIN;lQa8a|E{V2j8bp(wfp2(EqJQYan=KCgpe93GC z*igEk0)C+<*RJ45(ReoBHMy^NvxVfM`Q(L!C&mM?af)958zZ4Tj*+igk6Bo^&D%fU z8_<^p6C_(iQ&g<1+_@ zbw%&%=F8`YAJ+M|R|!ScLbX#cO$YQ>(Ulce46`?To?qmJmeWs+MEUV)BxuQKfb&Og zL}5gN(a6;@>-0xvEJtSgA~XGwo0cQ?TchTUW2d)@uh?m7-ILugD z!q3ny?kusi7&^oMR`giBY|ikRK^^3Q%08E=>?NnP^`6PQ?VjV_2_b_jbV`UmD%7_G z%&pg}$c^FDH>SRI#Z~-`tL`RWMCNB&gXy9M#8NK3tKU8Bt)Abpko<7};<}~crIwGu zxYi|fJA{}k0gIDS&>RX_%F&j(G(8%jhd5V8G!;~)l_G;%g_d<(>an=kB$7l?$3@6< z+5x(F9wbpX8vq~$JG=H2mG=crJ(;3)X&T5(txMBJW@ofx7f(}=PF)WXx!}#ZR}Z6k zCVczo&Dz;oza_&He(#EBMA%Wi$St-A8&05ix3mZJr}5Bv-LUSrtP#2LSP#st{^-Ku zSq$HSrY@oJ9D;POfgDNss1;jLpLUcI$!W)EH>RDWgj~{Hq1~j;Njs;moN6wvf-25X zX;8fkm-Ct^%gtf|G-#)`XFwm!KL|t~`7Rc6!%=rL5)QUcr1|vGM1|qZq6Z>{GpQW}iqfrZe~?Q8GBy95&#(Dz|9n2DR&J)7z}?&0QlQ!huTK3_|}Fm78OzvLUg zghWiCfRPAZQ$<#ayx3ch$RG0-x_1P2Y#`_|v}wQib(bbA|D@)N&)0>mZ`J(vHzi@Q z8NUwGqLF?QlML9`xF`9}l>35^4}LMS81%Q|d($Iy_6n`sa_f+PDgRrFN#fP z!*stW+jnN4?&&@Qj(ZU<_#>4NDcC+INv0aILe8rdFcbulI@mE_Hb}ov+!?aM0T3pA zEt@hs(%JeixAPR%Cx;uV8-vEf2Xd{B7^@SKJv*d42doACB?SNiiBtQ^6U~iH)eT+1 zor}}{{WRAlXpJ~aP3Rq-u)!#&>*7Grikf#KW#6Q^Y8Ju~rAYM5q>#Gv{f_yQiyMWM zdcV10+1%zcxB1Pd1^sCe&?e=Eas(%NQaPN)!OP3x<<=w$%mHPl2hV=k1A`11LD|4} zEG;MCa+G;!wf0@^>$MgPLJVrX2m^0xK{KGB7rTi~w5ywg;gPlAJh1D%-NUp9C7GBb z&05fAEhxvyq_wWLtGc0~uA!^3sqWN)Vz$nMu!xnU<<#M>%DUR7@}srtRT8Mub$AmK z@~UkbR*fi7_DcR6NZ}vh&sKrLkV@0N@j-?+TG-tg=;D^UhJ0NFO7wS)3Y}wUnHNQZ zt97ieHfmd_omyI{%^F&@UoP!ntNSf0Z2&A#$It&1HWt7zHb8Y8+E6j*U4}O+&_0E- z32GkrGKF=}9;m;97Fe8KfldttVTMWy6i^>((E!C;_=BM0Y8u03Ci7+ZFY1a;xfSDHfu02 z-#0!1m*Ha2KCJwvK;;a9dEc-NNIxb%CQx0iqezs%@K^;F6bz1{=ipUp&UR}-wKX^E z;s9fIN3SeHxW3CQTmgu5ku1>0B7qYSSw44*$hXmliH6x)FlsFprOS@EFXhM)BVUrz z4VT}yP}Jh$znqTBXv3E9Y@ts}bF|_Q>8g!3Kc~$CHa0yrw3KCI0lx>0lx=3HwSE)# zfC~g8UifVIjKgnCdu%*5pZTMlhdB%3?-#u3^p?MQZL#Tk`6m&^&$K#I76=oCl%qe* z`PtTww}J=nuM35v?P#*6L9&QlGB_5+^%xp+La$b&?wp#Px^YF=w0q{t-9=Y9&M5e6zlEx(mY*N8;$6Li#x;Id!p zzr?XfZj2A1B%XPQkTy!PSUHU5f|xBB;litk;XC-VQB?yd4q}5{v%6fWFt&bdZhcc+ z_m6;VG;^WmgZv*CeNf~tJMQ0jVtHeOZzGmgE&h$Ig1+_3NaR?=$39?x0~FkAnd+hY zUxK=XMp1OwqPu|B$fqbnhpcO);av{J1__m^C9X5NBp|GGDwkatI7FNgrz8C~Y=MC# z5nXmo>oKh8fq7JhD01K^qm@iJjwCb;-egu|9x-%=sBHz8iMEr=5Nx4B1s%O0lol?7 zqC>WdpcS`h!~J@uLN_&&*{|W^UNw@zqTIq1XH`TDb-hNV4-J+O7k{A&rMg4OZKzPl zYXX-T%4V2LdX*VT%I}nW3Z6)w&HNr*$f%7-VWZBh&w=HnzR7;dXBv)h6XdHPA|k`m zR8?uG!MrkEEO~XD3hGl%1CXF8!FXV!jd<28rk_I;#dE*llfZd$8yMnYgDQ@VEDoy@ z2gU&C?ZZI=nXW9dhTc2s(NF#LId`)^dha)C!&?wV{gk* zrzZ5<(2tZd+N`{7P0oT&TG4K`pP4WB1t_`35~nCs=Gy0%?P!f?WL9@kGXI{od1%r z#q>-tEF9&A?Aax%JS3rnLWkO+3PnA8=d>AkNNSV2?Z5_#zoBjNk+L5uQjX;#)f3z0 zYsx+pj!=I4P!LzAgy%}-yGYHRw+z!36<%(y;>;5ztMp3=kGZC<4>&ETt8h6svv$>4 z1?2#vM1%tur=u13gwANLlB;@9Ew=`g1$vzL*S-MQ}U6r2kJ7`UMrsuHONQGUXBxA?6_X-dB`-* zqV>UfaNJO{5=uVe;^q8(M|&fQVWs2bE7S4Lcp#@RW)MD?tA{m&=7v!0U7YHPL-wUom3!A&SA!ptju!cv|@SL4aD&uCapb4Yl@QA>2=D8pdM|w-ml2NYMkSG(5Iy@8;e^y z1|qjzZbKZ0ysZmWGF+dW&oAd3SXCv4e#DUawPQ%961|xr&5)0x$7DFuLw)rW%p4r1 zhM37J_i1K=d37$F4E_sa>Zh6caedhIdbltR&bw6e)F3qm`qhBS$?18NMcU9=xpbAi z+|IsSmWW{u&d<3XBS;$Yno*fd6Z%qd4Vr<_L(UD@ULd!N+_3r^cq0R2MYsG;dA=^qfS%eUA^`{CL8n`R_N_%7u0}$(CdJ6_q!-fio4IP_sY9-j4j6D@2mU^% zL0-i;ZADuk+nq3)C(dfw3Nqe~$i$y~NA9sw2+fW4K}P8;*>Bx_EdvPD0#-=)0hu}a z5Mo-@UxGU>$qMtjC}#P;aNUtg=m4%K1gXjQkOdpfB_-DA=-NA*T=k+09xwMev*dkl zp?IO?gS|f-TT1lrK6zvO&ZaLIL*lnK6{RQQ#6qdyLVwSVd&mzUlq#gwU;Z7KT2phF9l>dq>zeQQ5K|$nkX&Lpw zr%GQx+*-g`^hxVEoJ#yHF>xA4LOz3#jgq&>k#0B!MPqg3iS0&d7%yr(4pSA#3`inu zb!?+RR3NtV%!QvM3nw%ox&XJ9I+NM_vWkAT9ggLw$@Zc(+w->MivjU&qAp-j0~ZTx zVw{r5=0NVMCYL{rOg@Vf-KWtVF<{oS*ertI0+=NWDCIz$66%>L_`tIWNVx}rpmOYa zQ7RBM2^hsR*ib&e%(7&N__kcfzMykH0?~@W_dzboxV#txtmI^j`E-LXIac6&-|$#B z=QzjF1upkC+%c;_&|pVmGJ(l0sR+oaeWN%RKL9LceZ zPe7tBjYQ89Ex1IzN?<8s*bR}0G>y29dq z#B{P^@E40VoRris#7KDu`(inuX zfrgmiKnM?rHk-BAIt1$jNUc9i=f~v~X;YJJ4GwJtp1}$E(r8QQO5e!xgJ+ z7M4sL4&?H=0RECIU=SV`JJ9|mO`qIozg#X6l8#RTZo=>ZW)5sTTJ!BAMFRWwkyU2! z+lQ&%@5X#46-BNiK9{~A>Q6%!BL)&#(4>##s>c3xK9N5|OR!}kU6LEyhL3kIcDvg= z-JV7vZy#V^I~IBZ>E$2q{(?V9jV4x34Sgn&p6y^7lNFscxz=3Am@=;XZleq<<==k{ zSyR%%Nm-A&q#^oykrNPkvX8|`BE*q2OL^|ck-rJ#oPN>_B_A^sE%DOb*Rc_d&5LSL z{!A@KuvK}&!aTIl!?G&uD<&+D5mrS!7kl`Ak*6O1gj+vo{rBx~-vZ}Y?gV%D?Tz<~ zB(K6rMH+WXk><<3l>dJ&3jQ4=ohO=1{yt>_e_t!pP&9~5@UyV67R^bmZ|tftbO9%o z+A3lfuGAhQ6dvtRhLxbKhAMEm<*cs-aExwHVK2Az;h8V^AK=XnlQj82e?e^oe|j9T zj#z?&U`LFh=AoXU%X4$7y5msYiiel`8veU@n*SV|uanCFl}ZvG5<6N>^~2HmxO=_c z%y;@9j!Lu8q!bOh6GlenewfT693~LuiK8Pjg#nToPB=QPd6#&yo5FZNR#+3TTNn+l ztVH{(s@g^?d3Lo0XJA;A$@4FKM*-_#)(7}x&^K+M`y$PH0okzS|G^85|F_8f*DkW3 zuPw6uXFl#+(tOys8296rk2}BM|2y2y*R7hs$S+Pc8B0Hv9#d+L4B~T-S$ux6f_)b~ zxiYmjLzn*-v<{J#i-gELsXs+zJRwyWz(VZP1XN!&F#ovxHz*|aE>W*NNk>1W%_G`e zp^cL^)3mutn`_v>Q?H%xw+{l0Zl-G__!f2Pd+F$B6vRSi0~O_s)xe1~@a1%!HMI^p zYNQPz-$hz|Qthr9`|;sIGvN6py8AJ0u2IUFL)$uE-L%FoahnLwHqbSCUiQ5#Jf|NA8{*a>4bxr~mHJ`9y#AuGfoQL)R;X$lafqqi^Yi zyR(&&|-o=t9r59mvsaPOjDGf7V+2!r<(kx=Ia$J5vJ=7f>qhRy~ox4 z`u;Z)?&&<0-#5A)LjH-Rrk}Nc-0owmV1LV~|KzA}LPP~g9B4-Dxo5;=Y2J&D_zI-cOJB5_W#q!0$M<*5!GJG{$j-K%~0`u4@7`p=c zb5BhfeiI%5TYq%2ucQ zE$H)a=wIG2?AtKx-vEDq6OT7cz!5BY&=ZpDh1iCGrIF~<%3d#fYDpB5E(ucvH$^_L z$?_%bea&b3u-FS! zRKtKhW270@UZRgQ?U3io0LSAiIV}1`yr4Zq@YU#Y9a0q;bu(`^@79i6%$3HyV_y4w zi$8n6KjDBs^5Ei*kDQC6LeuF0m`hLh34LJIy^KVik;Dr&kzfttIxCMzXLG$9rJ@i+@Zy+fU)```_d4BVcR-{6K9?r zJ_{EW(Q)Wa=;!_Jm-baW1og>28Q6z8-F0HZa6s=Xxiiq`I z?F3y+nWXJiZ6%@6%~d+kJ&I`USFhZvlAUS<5_4AKlCD==pZvzLTMp4e!`l!@p=Kc% ziv#+S&$MB2joRx+pJISWcE9pqzqe4>gQ`2X+|loYNyfmSzhg)^eL=Xu3#m4|(W$AX zLW^eZfTK@PgTWRJCAwC7z2=jaGc!@k#xx)Pr(rSTPiqJm8-yM^oElP{$oXvzw4GgU z>-7N|%=P=*287o0!ubgyc~ZD~jq+2@s>E{Rw43npnWyQM9e_onm3C;EqYB#L)&QJ& zd;in2kq`?l3E;BPb{UdwsEoy_??4zn?!}^`OWfd)RL|e7!nQ z-;CVQ^i>$+m&Y#q#x4s}(}A(8cmZ9-t^vEHq)&F1`6D|X)_BJk`hGn4!QhhSzaL)e z``O^fgF;*TUl02ao`&z2iJL&Nx#Pa=bT_}a{RUlzuB!9GErXf6y3&TGJ%(Y^m0$kGa6tfw_$=F%^IeOeP1-=q2B*BN2vGeBI2 zvoH)1{m2~E5wCxgs>fEyUe}RgT+Ybr$k4ACaw5mluhQh}n$AIJTw+vjw1EZ|o#Pe8mh(AtE z;u0rcm7nDz<+UdWF3kBB$DhG70;I@OY|b7O-ol7<47}S=fMAZ=XAMsMgUf^qFru{% zgA1HQFUnFOk|t7@&XcV!tpR$*E?X}_a@*5K!N{z1;$r?bxS6MLrXh47DYT^u7LXGx4E+u9fO zOIMyZ@yGH7mX(a%Te96IUI001ziLC&{5yDwBbQPrr5N&Fq>i%3_x0o2d0dbS?it)Y zv1s$e*Rjb0=-sbbXMnou>^g1(>-iG6d6s5MElNY2GQtOB3e5#$zk^ zvkl_W>tV2FF-6TZ-E5z2|8@*UHGSlbJ#pthwYAt%JU@8gdRo(W)6Mg1OFqmaX%i6+V`Qp;0;J!|fZCj1!rTSBoXnM}JdX|))SSXi zCSq43Ge%=nca7}brZvwhF-JOsot@#@g56Y1CvAZaH3O??aq5wWD0{4Y2WQbNZm?hu zCCFEmz2X2pHihc<7}<`y=NNlDM7h|1Rr)O)emzHASw zqh1sYf@wQHF!DQu>*c2|LbpK6Q~qDmhLlbGZ8~z(hICE*9c-Yf>nRXZC(`}swrttr zI86ls4}yw&McHE^5o5@8f<&W4NqGW6I>vhFMa<6F@MGO2;`BN2N{m9T`8_)?)v@L zi8>8iCu*=x{8S&US|z?|7bOhdD}jysp6X3ASu>qJvSZEmMPkM5P4Y*ID>i%(`kv_f zzx)ZHL6T(LpZ`~gLSECqmiSuIYss&r{J}m3dc@6s7!02yLB{Z;qp=Eb6ukr`!mvve zzoIWB@-YfHikZRm=qmFKjL-&z*9{QV&x-H>tA~OXaR?y~VF2ZbF&kuW$cR}-G2Z~R z0tB3Tct1r-;9ol2mah6N1qoU)_|aFN{er`)>ccPv7hzRlF5TIk67gEZ>w4uAgC~rX zQ|uWTGN>XYy~67$>;n-ws~1y-!Cv=zqG~Q^R_@>fL$0}^aE19}#&OJSBDS$=T4=!f zK|l;QYDNV#uK;pqQ@D5q0zZ^2Wl6P@W0!}0#Gjx*(YVA1Nph*mAzn{rZwr2Qxa6;V zN22_!7(15&XKPZ4$akhqv&k>>LyUG1dDASlgAA$EBd5(yQ>fP93_8(snkA@Nx-$~3 zYXg8{7LX;I6&m1~WEe-J!Z^b6Wx&^Tl+rpB+TYnAGzZGJsz zCUGWdCS?Yu=3M5hMgWmF$$4;ESlMK~Y6M|1Mqv7(Zvip~O5f;Ew8c)fcP#U4vXdEf zI7riJ2DxeWg)Q&`TS3AVSLV-*6~$6 zU)4Ie0>GT(9u&%Ndaj)n{eJE2|C4bd{`VXw;=lGdkwD{wGlAcvn1gWJRpYa|9R}TB zq8%0kP9DaUq>+hA5Y)Y<9jjy6p|s{T;|j>JmG-6#cM^eete%s;nwS|5aG;yV{S z21WeZP)U>S~Z5t+dNMm%!{ z=EC!iWNRK)+7lxL7z6wYmhB^brm^o(`7GdgRZlnh4^bYXON{5{u@S>S(?O2n$RK%uRFDcTQ@Z>A z(A2gD(ZSj>J`u*N>(bwjz7zfPb$KrRNH zb1t44&Ha@TGeIUaVPG@SJa~tK zp(4-~Ztr)*0u-hadF>+?g}4GJ7iq_NY9T!S*1}Z@n8q4sKW=?j567S39CY(xVXudC z+E8ZjiYYs%Ws^&dVhOBR?4k+3MoriRtJF}jD9zMPpdt1fB2njaKV>MGjo=I~Hj z8mc3_BUU9L3EC2Jag6adgrr0sik=(VgB~GcIhQ;IR0)s#kFU zr_{2she3IMMJp*0TtuCj4Xut$>d`>R0RoAxEMoHbJ`nFR=wCsTwSXtYGyf;ppfBXI zpp{lI2Nzt%1G&G7MP)*4NgbIXDxZb-o}U=KU`1d;en6r_hy4KU^}%T*--CF5MdIye z5#sP=6+aBdGC(NVb1_n2>rN{{8*KUU%&8fFl-{tHHVV#xAP)S$qx8{0DZ(2{=?IIc zu+``$JU=#JufBW{oCzFnr8_k2hyaXZbab3H+1TWpRt+o#z+@n;Holhb5Eem%9ej_* zXdj&tf`PH~*=QQcqzvSGQ3T9W2X~}}9DDk~UUEK?#+^Ng>$l)5utY)sT{Ba@lDO{n z(Yd4UK7V4i$L>qqI#c~L-Rg<^lRd5okQ>E~XjanJznym{@7|7k6YpHPd*%IO3-(3Z zPj~%n-^cqH@|^QQPkg*zI5q$Q!g;9! zmXQrDGn8^rIJ#@@5O>-UZ@stDdrgRa<%#9cZ-0Z&M$WyV<;}17HoqdI9Qt*b28k!{ z_Pe3yOYj`^4lHQyU+|t?$p5HvN%NB$=(=|;o?NbK^;Na{tJ;Oi(?V)TAn{C~vuC;U zyboT`1}`jk4nOW3{%3fILtM{n)b)N)p{oqn{v;b zl$xLlL&PnSj9BcBtcxl=)=W2kNgDrGBX9N;A zKhUEaPu_1|Fa`1|7Hgiyr-DXBH&g#KCe2;!K6B?4Z@Ex-(6{XsV1;6;XY?!a$*yGA zR8WHyvu#FCe1Ze6Zg=?HpsT|Z?y-6Lyv2z2%Kc98Z7x(TCN72xHD?6N*?_V0*G7XW zN9KoSB>Tb#wf^nLg`yL}37fFR?oXXS4h}&zg>0u((^wW$ zqn*)x5}kCb7es)S4-D^_@0;hV{CRtY+%lo8Tgd7WhQPcekb*A>b>+p zOy%A5IZ9#TyzWO44NGX~J#)&G;$ zY%16MW?fiPx_hTTXV-G#?wP6;OWe&9vnSk%0m}xD-mCLg-8cEH#U4FiNHp$g@%km}>ACgwS=lz8F<{zfbTNaKiHZ5&>)V9>SSSvI%3!7R3 z8(IOU5TIZ`$NAbjKZ^)UDh2VTC2lUqZMe1Vp58O*?e;d#Yv127Kk>t|`E3j57wt=x zk1h+R8VOL-D&({UtnHu1#NFEBZt^rfNO~{*e)@d<{FR5D{(RuAK&dz?Y#Q^&UlcA+ zqb8343x=AEJ)uQ2BE-~e*$rUI+$|p4gI(|KyT5PV?%%p!*n)y>6LQ*x?!G6f{jBs{ zWB2+93?iRlGj#G0*6H(UlBf=NbSNrRWJ%`(TIH z_AWR-g`KraHleXuXlh=%v@|FrwgqC_i6`+0ZTrne59zumCvr&g4%8SsUf|G2UmNURCI~?v2>4VIE zP~9qII+CJR!B@}%L@m`*B<@O>HOm#VDmII<3>gY8AmsOwQdcesyd~Qw*u(Gn;>S(3&4Z}ctu zbK1nx2HxiISsdve9CbugG4}&jht7J)3XnJ;4GapN2e<~`OkYF>r-%W=x!O&E!@x{& zYWPnm$S<)OJogz$@FS1(br0LmPaz(M^a!RF95Wsw9y1<(FvJjK{|I#Xg#99D0K0pz zWL^yy-vLV`i+BXT1i(AMjXJ|m^dncRIs!nj*#?_lF#H6YPq1l-GRI&VLYhE`n2Il{ z3I9(@H;G%GR})6kr(AqJ4-8sZE=}#eK){!M$X6nx33!N4tGQb1>bc!N*DtIu^d}dI zSLRE8wEN-ig*^X`O7X(t_+L#tnpkT42?3h$2uu=|hsJzEW5UHrfINL2Js2ggf@o4? zj?D$VcFkPAi##an+!LOY?{A%7ztAlh4+ZoDU%PSP&0cpiX4ecT46g055Bq+6;e!iH zoBRiBg#&e9)=X~}&h`ky7Xy*w6sKus(@ft?|IF5aK6xb~!M(#5vEiOh$Zif;TNIeq z5$}$l>x)EnA4(wjqlFe5W*+0S7?gMvW=u`pDDT+m}3JBO-HmPAa8M1a7Etnp#066eFGoUWg|@EU8MWy8k+l>YsL z14X^Vg9UcD+qM-!blv!FCQ^$3rVi>s4Gcn41jwpgl z*yrt_zIF;>u#EaeTrw8Lv2nKza|Ty8reEbxV?ZhWW>X+0%d2_MaNpn^csJTpz8Vwj zj{8m?CN1$PfI!8@-*n76ZeE?e>hAMY+!^%8*xpkp@b5iD_4YrDV}Kf$p}T83EUY*5E(W71z=)E%S{v9f@izN-^c}L;`Q?1XbpyZOv|yvHwo$6{FZ|I zHepAlP<_^4)Ono;f1)L7F6)*}NFNTQi~wOg(=)T{`U!EAP&ZPV7~u6`pTCc*uNi(P zrb9H|0d$+28kJ{KVm)9D6N*+76s=kk=!;7KkYf7aRDd%ugqI24kEQUCuW1J~;L{Dm z^nf}n`>DE0aLORx!zUbaP3jg-orAVa3M+RJc@MY&IPh*7q*Nm~0~FAQ>A4>lRJx+O zCV^ef#6_l&&((C~EF4d_t4;9cq6aw@?@ zls{|0yP%lp)lM54vDMHK{DnzJvSJ5!!N6g0T&gC1(f^macL9$oyYB>5FG;16RC)^} z0i_qB&&2NCydix{Vc(^*8!tkpZ6 zSRMC%t+qQmB5~qdRAJ#V`6iQbX5;-byB>FE#}P%$GfadolDQQNt0Kk_Yn3o>;VkBHU2sg@uP222(H?}-%i(V_9e0Bn6}-JppAd5h?96|L5gfBz;D~}(6)QAO0ezrNAL03cF&c}G|x=F(SZ|JYlY}NkF&BV7R24+JH?CH z2&b`>`7mq$Oq)zz2{4z9+Ps{=Eb-q=dTSG0JI3cX-kM%;`nHrT*7~;W^T(I_qxWB} z{g3H3M3}!-HtU$LynFP{QOHMgcEh;dG1K#U?OfT_8o{#XC#e~q0tmTYe{*6sb6$5h z`cCvhTfnySgKRWieI(++--8FN)4yIhJApCAJ$P!SdYHId!eaJ*_DgW%NcD8VP5Ogd z(bX2?N2Mv%amMdkA}HOsy*k*ZYPGfLT@mcmr*O>4$x<8;Jy z6qih2*)Mu3TpE|erT0Z>#}Ul1qA5ePW2q#Xh4S-9^y(QSX4Lh35XPDE@Nt{C&7Lgz zh!%ft3;P5}&j-)O7HaOnpoz^;`3;b3(MD-U*;y*~-QZTa7Ih14VYZgshJYXkP~$W| z=zYcSd9gt+M^8ud(VBke%B|3ajSR2pl~U2ooTq*e4d>+%E2GQ+X_TD_1-n!^9r>^7Uy2sTfaM`}A1Pv?ge4 zuI~7PUOYYiC2XX2vUJy)XuQh&T2n(Q%UPysy`T5Q)aRo{9hOH8vFY{q_sGwkiEj2b zdacDSkF$8BZ(!tNF+kLd85!h_tA-)^K<@zHY+!l;OLYc{<=ti5=5P+X5RtL~M(!eu z0)L&t9VI}Ga=RzUzrNDjq!%N&{x7^B zLH&@M|KIRwmyqA}XS#wB&>92k1PT?h3kjO48!M01GTkmiJPSri_(9x=n{UC>4eI&f z&%+Cu70KtO!a@Y>^QQAIvY+4-)B$Rjjg@2*af{9Zp8#+@7bQHLBB|TO(w0K-L3a&& zm|l15Q85T2aF@Vd$~gfbL+(9?nvT_0FiaibY1sil8c8J5Wr>TR4`k@C-W* z^UId!o48%hrEXbz_gWF9DAzB021ZTq#7cY#$I|e7~BSF zkb~hv(E1LHF$qf0L@jlT+GkZ<#Uv;?WENG`78DSSs;oX%+g*FCvf-%u-l_U$QG6V$ zQ9?7mlf*Fm=d=t)iY*=BS(u8T(F^<{#z@{i+TDM=l7%1Vi8(o)QMV(A%F!$D= zUi5s>7AUBi@vz33=Su_DZ7UG;{xm)r5;4)v*cR*n}`iSP50oAERaC@Du+fh0B7AV_d>)1A*B}fgtObrm%z*7|z5d6FfsCg89Xb1>Bc?aij{ZM7%NF9N);? zeVX)-&CxeDT-)HI1)F$Hb2$UJfG{o`Pw@T(VJs}|SuT0M++SQL6xE}MLpp}Dv9X34 zb5!3@sHN=Gi^ z7;VPHSaj(uOj(1ek)oBIGIdh8GHaI!N`6c1jeXblUEe?V(qf`7z4SfjQucfO1T%r? ziMjLr^QVQl9RXvBbmp*ZIdxX(KIc2>@E_?B4m$-WCd(_r1kIO&`qR{sh>moc2vU2J)9roH+sY`@)8WC}C&6kUZcw53ZWWeC9E~nGYD`w}%O>Lfp(G}e(7K;HQKN)~6ae%tbH4&-5Ux_qP}f3ur0RJ% zMJF?;c;Bi8a}*hcm7?&ap>mm3_b23!(sEkQz0bl$55}vt$$Qg^4eE36j(Nrtf!bHT zPu{{!Oq6nfqJ$!tK1S@P>3yPnPTg{GkQYe&DHl(@x*Wm9O-KBOM#}hQMoOL;a(hCy zA!gbbQiK|-`V&9*e#C%z?)Bs^E}wDsDJ*@_+^LjXu9x;nf`s@m(!0RQ+DNHpmaQ8* zmM4wUX%^vuxhugnkV{`vN(SW+uR%T$3Hb!6uGx=C(Y4j^h2Ytubjx zmCHo^nVM3wAaTf6`OY%3 z27c@bgcl^nv=yg`5Tl&ODo=G+HPv)PH=GDWS~r>?XDd=-21)zLtWWTm!f21Ps{=4LUS+6;Z25?5Ml1S*NdU);xiSj0!5=b_PXCgzBLXzzl0+*Q7qCw-1-KC}8 zO}!P`2agQyLdPUnDRTQM_TC1g*lRlj^H^I>udQo8DWw3>S^-u{gdCAB742&bDnM`6YHc9gYwb^q8&CK_%w&hQ4z@Okfor6@0Ia8Y}X3 znx2Se15^$e)u9n^JXGSEnan!m8lm2B#opbNQ5>s6kHkC5jAIF13(bH9O>AUk;~2); z5Q7wq85w0lfo=vzEW(Vuj%m*P&(MewTjP5<8KTdQnZE&p_R6pq6L;4Qd zM4u+gIJ$>2AjNg37^>Ng_^G(!)?cJcw86D0pkiTDYdA~+f!+Eew23}yAT+ilgyW7) z5E66!v0GPTVeDJ5aC#};9}BQtU+hbuZ(}#i?RtCvt^M;}G2*R1y<#=J)|XzpJQ6t3 zA)H3Y@*dx5r~d@!Pj{`R5Bt(7cq!1Jeor|r>HsO7&S4YolYUe^%EkM z+lvs#xYDaXPnT$eYeRa5>?Ogk^#Xp4ia2PXA`THrj}+M&3{Kj(3{HwJoxxGy>5L(w z=aJ&Vu(5_n_j=PI3MO4|I%>it+Ti3d(@ENNn7S#ZbiL^uT^lx@qmxckuXxl;NByQN zw5bZO4o4|f;q~FjNgJFz6y8Fcw(!&8D5Wa=3|;FtouQLw!y6*lQ9}f^?l2u#4@Y2l zBco@-#pv$7xGW*J%okS%ZjhN!e=pV-SGSVni#rC3S6^JKFR~TJq+q1tTA?}>PM!6g z==Q~Rqj)DcLzEcLQu7J@>qe3Biz3FaKrs;Ayr^5;`mTB5(o)t^+lL+RcPwADE+L_Gu{U6>kl2@Exdc?Y|0E6gDMJTsPJ{JA3r>lw z*GWSUo$N6*)6lmVTWQl~JWY?I#dwB}nvI>b=`^;`fX277_|I*K|18Huu770A`0SUd zVS8G2pZQ~3nf~$psD_>49~q(>3S&RA8fia?(g%emq-*4*^o7MD5cbLMeo5X&V-h79 zZ^~Q^1#}H*Q>N&9B+Z%uRvxMr(g=?>#lB;Zc&>FnP%X^qP_9ShQnUbDp(tEgbHSNe zT)Yh`MaV_TC8DG?Mx$EaGcJaKRWL9Hru8JZb_5O{j9b?{9P;|Uu#sra%JdF#s>d4S z$%Z~XVi_v5Elwl4cta9tq=Ai-_n6nh31H%9vN=x zpzTbNTf|pryx%f#=O*gx?QH3+`M9QUvd5je07BT#=J=s6SyTSnnh^6( z(Eu{-VF8P1r)VG{3&@w-59_N!aKyBN;J2pdMVg)$ir2wwN&O2wu6zVnEYBC>NVpx^ zBYTNtE=>k&V)zusj%14Li0e5-Tr?4CE@%I!4LU4QBNjA}Ix7(tgIYqE6n5Tp!o zq?2B=B9iC-n#eW%t{q-|;RSDW?xlW@XRO;f%DFD{e@ML}aRX0s6UG7|D!6WEP`8Vs zgO~0op=8(3X^!!;l-Y?_ENHBQh};`*+cq`;Y(9>KS^)uXXR1AK40I-lz##c;GEZ>_ zS&q}V{F*R)P?k{iX*VKRyKE&pNO6InAQwMXl^TN~;dYiqizYg^i;5gfg-wz{pmt>IX0Q+wNVEuCnotwThSnrQ{@$}vIQ zg@?4Q1V$+OuugNMfJh%1^@yQurfVCU+UlCx8*8Q~Mo0zPHv+y5)iX=a30}N)%)uk5 z1hB2A6>zhO5rwVEBekW6ho$Xl47Q{6|K*|k|M$qr?2>ISxG9Xf1RYsOPiq3Wl?-h` zhAm_2#*!U2Qd`1Gk!DwURM3JM1BlCRI`oqNJ|=Uyf(IvCJNAIjC)_zUCEfij)QDUg zn;WPNaYxohCK5|7WcPq^#e8zZeiF@cT^s|(rHixkzlFyg3>RyawOzgR`Z2bSy{qMP zxStZmHxdx)6Fox%PM##Md;=v!)reXz#^ddy!^4yup=2{9D&(Y%4rk~EXUt!WhWT^T zrSH-udiO=u7PR|p9TL5w3$P^f^oa`^`$k7O(R*?U$8K6xWX_OnMfQ+6R)}5X_u=LV z{J9B{Zk={(4fNV$v*kw0wUpTyAbmeH=gwzet@;`8m3IrtyWgAm?xlAxEtf2x_-LPh z&oNw#v);d+6ulXZHe~J<8}wN7(yr`*L$j9xw2dNO-c}m@;i%bzT1w|0G-!L;FBpq3)UMUk| zngR6u#F{eend@4p4On-+SB)F?4bl*nohO)6(Iao(bNAA>F3mPQGFC1{B6(U(L*H0T z+OSVX@WTE2rj$UYVd|EyIwBW2T_~0Y!fl0tLCw-n_50xTYs?%?(}qw6Yttqb#V(ZD z@GZ|i<4kVhyMB~&j|Mg@>1c;o5E8Sk6qeGub@L4|b@uT%PM zX%}pfzH5Vo&SnCelPc$dX+IgaMR1X!>P3iMEMfFR9*sdI$ZF*Zh)sH~?DsF}53672 zNmI&zDbV^~s4(W5J8bLo!}Krf>2q>lLLsC9&spuM<(o#CU`AN_Jh>zophn9hUIui( z>Bvy#YAWf?#Q=EL#98~4*&){f!$|f|E)8R-5a5GFa((Jn+XlG|d5f^0~w6f*R=0(U#jpof{!ZRnf|5+Ik#%w-GK zJe3X8CK$PH1Ur^O8W*tZ5nTHH40*)x4%grv&X*bszf&lZ=#I^*NinyDv-K&iD8vjy z6maZC+79&*0E)jU1|Zd4Pk%;V)gEL?2F5$qv`FkjJYy$KC^L%61k0NZ@k3UK9yOsy zYsY`>`>bIe!6qAxSTV?@Mh)i<#xRGCmO=C8{r7)YUzVSOy5(~3ZTZ%<8Ay+q~1u`){~)%h^C<7r2VP1 z@wc(m@~2TTf1VPu+T;oIikWTl5wurL7wVA&b=$pL%YpYh#5o;2Cq^f&v~5T1rjZHQ zgievpWyA&lM@}^8jKuBa^;dbcp|X`X<0z=B3>qu(<1JKlG$k=eM0S|}k5m;oZg^qo z>G2Ffy|!Zq%uFSl&_am3@Pz?*iP#z2e_ zzRWDF6$8Y#QF4ZE9HFF@l1@tgnv!%%82NmO_NFPhOvx*hT%qJW8a5a0jZsobiIWnJ zl6A&q49i4ERWe$e=xY8d5_`0UxMr#p6B&xR8D-5U(@>=%35NGLoaZOTM29~@l@Ya~ z-$fB_`b=r*YLTptAgv1-)Yc&P4g9(P7c_VsO|4jrJ&umOTKiMdH9Xh&YoT_v;D8T^ z9c{}y{RMTa1;=~^$5tGV3XcE606V6iA$rY`SC71@e=Fi<#9Prfqi=UCnEY`?OG%H6 zFFj7$_|}n|N8UPiEpo;<(?hTg{j;9g-nptd&)ZYCrWO*uad~bO*hBS;`-Eu1zIYO! zND=7X%D9931)~(OAIYZN6bC#Aq=lLD`&0P!eLjL~y&c&Q>4?s7Q2~ZRJ0npnh z@XEC-b8e8m@3avXl2F_(Y(EjmJt>@X3LRZPP2M=Gdz_f`R@u$6xs-ph z*?i7|al!fCo~8QbcENfi5Pfug6~flti{pz~l7em|!{AMBN&IZ(?5^3~ z+b8E5g!rvLj?VjKa#%vrFJi)yvp&sIaV~1#;R&tk?v*=N-s6d5`|g!@ulV!oSM!>E zdCdYs@#Ho8^IShR_Wr}Bu-GlWn9ZNFnf@oXEl0PTez@IqwA8#tp+F*hO!Kf$1kCd< zLxn4%LCH;tGu(zVD9#$`yWqtizJXuGH&Ek^&7y6D({-XU;j+OHn2DOyittreQk2F_ zjj*-QUAQBfw(=^E8up+D7+P^Lbe@Z4-*<~y;{*?fiD+DB_YEj9B0*y*$_vmw(sj^b z@q`Ep7a*xb>?dm5b{!~0Yf9gU(xp~Cqx3ZS>jX;T8or^4Xej%kR~f9;n^anZLFXyT z6hPw*fR{;9IS7@v2<1>EQ}`H#2rgOf!z4l+t0HEoCBA4pfPXEHmpmR?+6*H6c)16m z+J$>IsIFovnBMgtuZPkDNT9Z-xe)RzC3ES|E}Li@bxb=zI}=17=#;(dYp`!_6yZ`U z@eKjW*EKYp&!+E5H4<;E6j&I+8$AeW%|g(talXBqnX4@~;Fe@yOjbp(8;HJwh=m%o zRaRsjinK=tBU;<5t7}_ZgOOG34M%Gl8ta4Rn%d(>n>uJWx}mY5tpQ*8({vPZvJyww ziEKSuTYH?Hs&1)lJycs0jBTu~t!X8ro0g^%l}EkB!xIP%0IBJ=oB^)96~@^keVO)4 zBDTgbgsLsf(_8eo%R4(Gu0C)MacOM&Cb+}R7HU}L0;Y|Xm z|IjGhXyBa#l8KO0Sm;gZ@!7qJPZbSyz*#iq^Jtmyp-$SPM8bxVDXTORe@qFZEwGMA zNQSUx*2ZGgVx9bqh!c=DDd`opX&a$f#ZnzjtJzLfCskh&~yJ zJoO}g%e-#B;Er`M9%ur-lA2}bO3p_EKaM}njJjtxT)Twt%$9nx&5ncntKJk}_#`P+ z*wBElR7)$+34JXcD=!C<&IGy~0>=X3d0+8eq)%0)o>@RwiJK|lrr_j7*e+vJ3wv~I z4(Zv~?6AIt^z(=Gt#q;;Cd}Bhi6+dXyZ?Jw2o@`WJ9`;0pkKhc!N1XCxXweTnTLG4meyw zKTq-LCZ>FDF9QDw)0YMJD*{iw?t^6qYVU#F{gbrKx83t4-j>>omj3}Ze8kJY83L0tY#g3n055O36V!XcM})>QEoxC z-uV5bjG8_AALz|Br4c`{RBov$j`+CPgoBUwm}_%Qe-f5fYcu_c&4hg?rit>iU=MRA z?2{j3)_pbV%Y2)E9c4d#O`#Z6#)Y;Rx$3y_xT_3ebz)>-~h}^ zp?|nYf@=KRni0g87<5F+ zHR8$@=?|m?k?vFKSB{MgeR*Lr&==07-A`Akf_1sX@yo#YZF;fs3o!zl<&k3}kiV`5 zsU6%F$U@e(Bvl@PK}ZnS|2Ugk>h*sDtwSp<`+?SBigGSh?G+HUG9?x9-{3KNQ?bmu zosQmKib{68g)~eo8jd&en2U$oW}ofn2T&OQ+sGU=P7b&(FxK0f$#NASW^bR1L7CI> zguA-m>~{6YmStzkU#7t2YtYbO__5lS`q~lB)pBdza(cse;qQ~J zNHo|Q_)t?c^lQLSms}g8;M%wjwHuMmA$FIbJAo364!c7jTrg;JR(A;3#vjo4iJqkd z+nwC$%Re*k0I1{cH_u~N40`=rvGF>{CoBz(-JnP6TZq63Z*H$`2c;5myta|iIgz!e zjzb4rTl1UiA%BXRM}wrG9$}FV6VckA)uoTubt%c|+uj58&*r(UZ=S*KXZ&g8KzAZY z3E$pQ8#Gr}HdfajJ&N9#NrPR}4IOsST3OxJ(A3!7Qrq0#(1HgUFAlK`r;K^Q3@h9@xzvpfHF2TV8NN(TJ=`S1n(!XaTdoW>mZM02{s zWPX>#>^$R-lBr~T3z_F3{RC;7VaD`0EAMXpo%{u-KWpb=&r;UnDPLB_62GhyGOK)% zn`aC&9dqGuZkpZp`pdU#gv^pa#?E(ZpClyv*~CZcPw0eJGCs-ArVUbmymKb}Czkj( zcL^J}FYH{%S?F0zS&S4?_XQFOEb~cp?5q*Gy?O+9N)yWJ1Z(}u#G~l;C)W7cQbhHg zoaYzzE}mTKUG5hWn*7$|tJV`f>j}U0l+ejNv~q#oLE*eR(ChI{URqz)idnFd(Be__ zIl`8Tx)wFN^J}pnk782+XR5$ol2PH)8aC24;pv2ZGH3j9y#4d&2Tp^i)=SZ&42*{r z!~aEc1{MCPmn6`Lu&J7;_3F8BmFL4zgsLsVQKX6si6gTbv=2v7s@5w+tritzUaB?P zlY*YJAMTD(W%W3URb|#VvT952i-D0NLBg;fM~IVnN$JP5Ay}zN8^S(R4E9^Gw5)pygn%7eb#P1^z>6*)X=sl5?wDcj~$_lH_})B_}Yr zfZ;WyA0!PY(Y9zt_k088(JHd;=e~jS^wC-O3)vGz*W&hgw#hdPZkPAe?VzH$QqRd! z2~d^A@>u|wC~Z|WR%9?aNMFNsKg_a?G|S|BKux8oy$z)ulIt8KzHZ%5zQ(09eTlYJ z8FCGvb@$1)4VKG$>QD$~5Oi&BGv${10(w{B1vD;8kqSVX84gQ%5)JsjV?yvO z-r!>t!8`EYi2-l^3+-7kV0;cGWt99Wog>8!-%N)bCBKD3Ks>N=*oiot@a90ebDrrn zY}*Eg9eu9C(Gloh$Wf+%1Pdf}u=5U7>5V97&MUScV(P%U55R)_u-6W4fQ>XGOkYE$ zb1+1NYYaKbjE=xNA2w(X#MXsHVN4xmqp-dTkcen?m?WF`;g$<8+s;fCH+I?huTt|8 znDhm*4i!$Mu1$_{%V1Ui9d&g#bubqR`N4Ce{B}D2AqwD2>15>aKpza_T%30wg_M+F zXNwfn4@o`(Q!7`mhqX>)DG47W1aqL660C3MAUiooC0A3zL}oSEleI#OG>M}&u?W0V zv_}~24BnMF+B1I@W|YK&>M~Ud6NNB(0Tjl-5XMX-Zm%c&?>(l>;J_YsA z^bqbnKZL|h(nyuu5fwrVi(VRCo{X&i9DnXOB+n&xVmI6_5MuI{N|xH*-?Pm9@yiQ2 z?}gvnOfrd+Jppc9H~}EIS3ta1!gmBT%>)9DKaNeg9VNtM&-dQ%7jpM3<$b(Q*atDj zslaJYXmUL+-m#c;@4`~fA6%Iky4~oD$zQHnDGR{l>hS5uu^EBQMM6yRV%`V)gp%47 zYoNm^)N_zp#PsT})&j~iJ{2ZA?W=Lw@U=)x6}3q~3l|c%`7GNOwjx?FTe+3OYaNq0AVYr|sBcMIPwT-{mi+gZJQG_bSzNn*-d`)=-AP0aQs zW(N{;-*F51CjxmVpIDP-FVDB#?YPsiFuu6)-n2jWfRF>w@@65q#cyq0wRZTd9e(SX zRjb2ib@;7Lp>Ob^buchAiUA}X0u1LcT#cT@B(KIm^|1w}Qj>Q&7J-w$lHsdt2Ohi7 zf5CT-Fb5g{6o3uceqnP?^wSvz(z#YqUe*qX1I*%9J+nEx0*C`pv~+|ljru0q9Mm(fqbk^~fzCZB3g0LTa!NOh z;6CK^>Mzk|8lF)o(~F+qhF*OwQOR_KYZId5QJ=n_at`Q6smwn8n0R{)<&Lh)7w(1;*js!b=nO38~TF zLYe)#%Q_EEh`&$(KcQ@pRO(Xx#_$_8FhSs&*Z-crg8)k*nSx@PxxNsSv+k!Nt`n@N zDjNxDzZEav`~|&9l=G=uwQXv!Xt`hVmgI4vthrRV9NO3i3RIEIsO3YNBvI$|?UqXf zb$A8z1)X9)Q0N*tjht((mPJDohr@vY&yaqgLlnAKrA}24JCgCqx;o^|7Vm;Xph{tf zbC%}=ivd6&)X3SDvJ@&bR4?R~%Ds}epq64($OQQ;Xp~sH)qAYPRY+m1#R%49UtS2L zBH*eI4UJx)h;Zx+T2{JirwGOhTZ<{1g1QPG-+4X{07pG6W_A&uNgtTgXm2pm-9wm1 z9ygTxIa zn1$CvR2UJE)rnK0p$D@7VPEk#>D3rx%d1ftU-T;KliVY6Y-*A;%Dlv);`&~?QY+lzPrPBhTm%l zY<=l^%Z=05PTw{MVzQR29>&zczQYntaW!W99$7X&iBEoO)njY?jmc}1vxASUne%0L_ubjI(6ShH@9a{Iudwn_b`=yNvGqE9 zlueOBRPH;y3+=x*Jl`!umIaKvMUTX|odM%6cr9M95#sIs=LPf5RTEnsDypOBhhLav{4%I22&pPQ+4#}I>f;xQ1iAc8U%HHMrjIwyyl$)lx-se zqvJ2405hVY&cG@|H;FHk0jm2elmNfTm#txApl$Af$^A%l8ka*b-PBHqg6kI4Tkt! zuF0)o-cUC)+`yLMcXN@Fxd#iLEwPBqScCTIqlHgw8Hr0YiXrmaWQG{D{KAY>3Yo1{ zil`r37;eNN4+~Db#E)v@k!f*Pnz-xlt&5%r^=^_nN|=P!hSHV$ONjc@@Y|3 zJE}n|gjWeqnKGDOFqp#ip15sd?iRy9TM;^_C3< z&sWeh$+fJUzsw9Kf)Qd-9DkhZdKJ~c2#-|kJYxtMlbS<^j5B3R`5)7vs$93xAuZdY ze@ys#{_H=|D*pSFypK!_5?f@(L`IA`yojtSe-&-`ulRHS9dbUG4~s~d>zPlzb>0_Y z2P3lmf8PFq6U$QYY*(c0APj>fE>W+&?DofUwvv|>gCdRa^7(-l>FZ00=M9v zpI*%>^<|Yptz7y!Q`CGvCZlS<{`>pQ)w!l07*nflrXSc$*!ONg@C3I6{tgI50zE{i zjq|`=#8@dmWg#Lxnsy=bNUw@YDTboJW$9TcPYU@OWT?v063Q((lyfWquVH!hO-Pid z^{-~DF1ew+2vn%+P_&`k3MPF<5E3etK(lY_;Ugg}kXV+U-$J4}*8Q*;h2K|bU)cPc zG_6#m&FW3+LgIv93H?(Sqt>UalcxaX#%z-#8@(CiU+MS^>y|Nedqn1L#v%`U#C0JNm1>NgQtF60h5g7+NGC; z&Jmw|6gIY&%um8}mT}$H>YtgdH#T3}ylUR!GjEwUEVKybEdle6#mW!34+q{KSkZlN zXw|#}{~wxnh?bMz-<(`kVfcQ9sX9Dj4M$7#4Z0Qf$tffhjzg5as$3xXLIB1VGnzh$ zeHB&11n(Myb&dPb{6d_ zDf&4*7XC2aT(7>U1hGwim%axiMGc0JAJQ@7ljx{BE&-XSF}IW;OM&FZJ-sE`Ms15Z5GjTUmf{ zuNi-Adaan&c3Ylk*fLyjxw~?gm{5c6T=$cgMH#1%VG=@(ppD%!tx?~}ixtERNvg4I zsO20;gc>zj7icO{l7(F46^in=4cC*6Vb?2-SVkG*Ayi<`GXX-Y!*3Xz2G;6Q$$1T7 z>0sUyE*kx+EXgx^k)i2D+`7TRA($Z) zurT6b*rR)XE}-8C}#(=7-lmp*0W}kx8BKKkh97f1_s^lccKKCIN^#iJ0h0EX)rz0@>NvbLm zEV3e)3Z)+96h$hG$rM#7m%_?P#i9@6ujxo|JJS1$@>G?n#*qA}d(+i6zy(khuVvuL zz!^<`4p7%^qUoeO14OjoHq#kITUg(oEozzc_Br(fK;!DNAun(jL(UGx{EN#ZRVZ`G z3+J+^e7vVj@%VN9^(?&e(TGH{iHjTBFdTn51`d!pYy@(rV?wHbBe|_`?ULpVMtmE? zIkA5;;&Loza~X1?VSpB*$MKJxvqhbw6?Y%udJ$Ut?jOoTC%4<|8(EY`}RQyst}@lxoLdT{qS4ZS!UTRM746*pyP4Wgx-+ z6>(w|2T{5jFJ|B%W0l5I({S|Ucx7wr$)=XI)@k^-iuQM}U`Gi+fGGvR3x-YnP3ttg zk@U#vvk3bT9sBrfq;NfKZ?pOt3I*Xy)Sx_RB)dKcE~X4DWP#CZk=ZwT8vh10d9%iV z1_?kG_qLbI$A|3V^E+GKm2WTKcINCsY!5a1tPA#4FR5aDtHx&D@gf`Sh0jQ&BVYN4YV5%WMW4CptNWGY3f-KsLibJleJi~}*BCSb%LNMR zI%-`8H1f){(mXRJ8TZ~GnMol5{ZPOINJcO&E>EHSxJi;i6U6~lJz}O|s(JeP(TTo( z_WrDWKj2$1Hs-dG`~`0d3qMHa03Kw80$nb#z&0_0cMLC?^1|8R(8&+ry&Le@f4+g* zY?kJ+yV#phEThpB%S8lD$1B^a4+SIT6W+aS_z|PQe=ix1Y_hTce8*b5yx8^ zj#ajFH1dB$)9pH@>zA8uTWGpvqLpmA32}#S)yhQ*vlX6GN7c@Fx>Jf~VtSGv9@Ks>7W>Fh|ld6nXRvsWo z1!Kh{x`(O`O6!t*H%{3z_>ZVB|3JyQSND&oNe}44Un2>cDayn!^EY~vPBNF+9NPOE zx@;JOo5??7NP-dl4ziNqdB)4XNzICw7#W`!^|*po0NuK~VYkxVH#FMg7z##+kvZML z7$zSA%Ipy9J@M&GWEYIasiA?MBJthaPZhpO$s`iHRb(CAbmBi#@^ec53<;U1^t$=K zrDHKDFm>mv)EXn8V~3doZqP_iE~uXv3mT|q66lJeJ|z`pexxsBgUK(Vgjkv`P1v~_ z_Os{>0GB*)Ys*5W0N2{>6>#y)c>jt&y6I}|V_Pn%WZsGW6r5PC{!Z*%$$Q(Enm;x? z+FA3%o*$ima9%hqdSq6U6J`_qTUO#{DsR+ZtDmj>+F`73bZX}CeC*X4;!JDxkByOY zWrP)ay8>mx&)`$>fTIQx=)VCgBMPDo z3O^xg_Bp+aayICD^{+S2Rba_PxyWVe(_gK59A&wYdo6d?0vXA}s9p2=yWw}j=P&tf zy8z^3iG5;;o$jVg8~&?|`tiVySt_v(&rXC`7eh zt$qw=@P=0#=BgKaRMzwz{9cj zP=^un^ocQcW_&i~+SFWD01dFjT|N5QC;2p?Kl?;a)9tfgZVrnEyGJa>A0!`14F4#s z2INREF{EZoaZm#P5F?lS1!g1(O1>m{Pu+$x^FTBN zsgLWoj8jwO2Wly*0~8=_NLj;v(-EEwd0!?{5iC1mFjNVO?hP>#BT$NB2!b(7U>u*0 zOO&=rw3T^U29*=4#5lS2n3Cb(s3NchxC57r(lh0D5vr=(HjHVud|ovo;Wo(S5caBE zE6PdNJk1RDG&iYyJ)Y-g&GjwJOdE#X%JTvBzo}ZVqhcdASVHavd6;4+iNx)XEn8-}^ za+F36Q5>oT+zv<@q6SOE{MuBOJ*ZNM^{v2j)V84HnNMXWdup0kf{M{~QS?H*~nSK98QEmwY91ODRy^CT@jBp& z5M2k@&Ug>mip?G5DsLP5nDS*hmV4;{dZz*oOvi9l^4LJN7pLhTWnA}zY$=YbzF#B1 zA6Tv81|dsd{{ud*^u~n%`Kpw2%TI`_m0N&$qJD+yD4a z-z6y(8zlP#W?vsf#r(g*MDUswd4qRjE1+HrDfR>`6%=DOX2|{r$nT9qusNUvfMG=l z^05J1Ia4)x!wX1wRql-{ki}NzjNBK)Q_Nuf;1Os!EEo%yLAVJr?c&KFg*Vz(d#bIru@#Pll674$ z3gO!k;SYYLZm*F{@4WdT?d_iG9kXTPe?lX4m!2UJZfgiJa7ZHW--#WC_XoT9#k{{e zMv`HfNk#=(mWec$Or{0D5=k1c7197BlLnYRH&#?syu><8I2J99gkE8)ZE0z0Y2;Vw zao$5apB4z@l0BZ>Gr%OkSTckpYA^A6Bybf)1BuhVEyZ)0izVMZ@a}=-@W8J6m3_jg zjt4IZUA+jW=R<_G(MiF3>EY<5H!G3#GZs+eD_I4)-;XHJd5ypuwt1t9W9>uJa%eLzu=@`ELSvJ6Y(TCBxB!Fa-cKGf9O)?L{M=UBYV!Hvo( z3JBCtUEAG)7lmC)R4yGp*@k#Q)h)Gc!8GOMag;;e#ENh>n5mS7By2QPSV&Iz-jAYC5oQ$@p894qoHNFhW7I1%7Y z4WML0eRhKg8my5qkm2E8+Yut+$0^5CPeW)ngVnRC5d$A|9fDZ5HQ* zhhUfOnHn1{ECXTcD2nE`PCCa?;-cgXC9RZvosu^xVe+Xn)c23*kiJVA+uVLyjLPX8 z5ez(09iqMNL%Q%P9g@gMgp0dGrS#C@C?#W*yoSUczup)OI-xM0+4Ua5UeJU`NP&>u z!=5ow;*?MKE+Ju2bVh>u^OKkt9GJEDLo;pxSQ{~@06 zc6wWMSO=bOToyT#^y-D#?GGa|ZaYP1lldbH(};4yy)UVl z&yRoO{7f!n3o$8j1rYXw_ZRcG3e5NwX2bYqT!k0#c)JDIh3GaY8#uw8K?=s-p1@iX# zW4it3ZXk$D!myl$+9ghCZ5K`q_*w_it}CJjs!HEQkLC&xgC5(mgzUXO+rE_+;rNj6 z*f4S_Zv)F!O^@23vy#kHXF8|<*&6Yf48j;OpYzC=_arusK7%)gt_|JZBjar^CJQ-x z{IPoxVJkO}5E1X5y>oW4K9GChdeV)J*EYUcIv4TvihwzDISWm z=w$LS{ne_gQ?DMwSk3g!x&!9)x$$>$?%MCz7pfL@OPP zEhOaxjJa%?*)9Wq3uR64MO>lUPUsvoqanD3JQ`xd&wd#ZmV8|IAI#~3IbF0k{bAn5 z#(l;=*BL3z+TQq*;V)h?HASqohl|59i={K{69U>?D+FX-1au8QhfX8N3u&+}vrN#y zDn0~YftZ=Qa!s!l=dEF|pK}kVF-BK~fMBakDh@#?;asTjkeWLb=3ML{rJ-1Ax$PlE zcnX3xL^2RcszyI+aS~PJ0<=1c*|sWof@xHRAqc#xVI~#foJP>1F!LORQ8__Q1`K!tU^IqmXBC^$I`Q zgMm?o3vbX1aP@J-)dO?QkQN4B-b@M;#@m}DJ{DnVrJ=TCs6|bGIRA%uLW~h@l2{{l%v^`(4b_0tUq=g_ctfDg@{U`}%#I{lXA0OkQ|6 zbV1AF=N9W0B8qhbhd%x1fBrmX&7_4+pEyMh6W+c-jLdSEI-`26kXBjbh%TW)&&I1x zr>pc7)^k7W7$3$jB~w1e{k(=f*a@t|Az)040jt1z{K)vHCM*AAG_alq?DsIo!HX1? zEpozvqbn|dQQKS-l(92yH#)9$ykWgsxdv|Oi7|Snc~<{g)9vjdU*bVU^L0C2{Os}q-f6PNLN z(Tj-p(FG+rh*+0?P<-bGiC#G`M$B~S50a<5^sd$z7+Fq7AY$ouY$nCcpss-m8xTRH z4jVjllsZA_6+=Cd)s{oXHPq}y4K;hwA9YcfdPSHb(*a8*_Y zO~5`=9AZBS3gkHXeszn0f64IfNkNX(Z(7!U>l5XC5d5dD`$p5d$$qj(oztK!;`^K- zipgP67f_Z4_ObmChgez{qS==nr|}+dY=1jmNJM^Z$<`~=>uKUe(ZP!zR5LJ)fL`U zZQ;_l8wQ&d%zg>7?#9}a-6YPd;Hi@kUXx6&yQ%Rg#MvG6QKArPU-T)H8nW(FHZc3Q zYgT2-1!y{T0cHzk4xWf8jXFhpuTw%3ENCV}t!{$r5k*R&*HdO|^axFV{(4Gv^FKwl zR$5PO;(Geb1@xye`)2^^FS>C%zBY9`dp>5tvCt~)=oB`0JxV+K3n2F&_?d)WH*m9J zws<}TV0HWM#mrw>91oZqr&!g9Fr-QmSfzh7poFSVj%vYL`9Zn(3X%4;WK4%h@Ai&eN_q^i(i{VW0f!H@%o;{Yw|K zH9%@PYnC!277_^>4VD}Lo0FEcNUiov)w5}qGPAsV5=5=KlrgAxxx^TCVGFfHp+05_ zAeV%tiWWTP%`7raIFs;&Ww3=%E#sS!U}lsBj-t%6;5jKMIaTW21}vFLct-1fsBI(I zA_8-zCx{>17gNPO0xy&6z=BN$v7fR?YJ%Zlk=!ILk|uGHWb5O%P$>TqCCq*7G4{Nh zsz=ABXvLG|)1y6P@(wgDGHI8WAGQpV{kpgm<`Cv9gy-8v5M7fDy&V*S(Bl|@L%!@e zZO0NhHR6P{oyon6h6mkGvoGs1@MmciOGcD?k$YLA@XJ!n;9+-dQ}W2l45&CNcG%_?IAVhqc7?ksc+fr}((bPTDI!6ubS znlMB@<>4kgWrIFc3Id8DAF>sW*h+T{EHB`>-o=#oTxA=jvA4&($ZD>V$!uM>Xlm4~ z>`!@wvz0v`_3~_aCr3b0d@l;`lh)A7QiJ&afalajOZ=5DT;flKUg9^GJWAV1>wAX| z3%n4Ohjo4bJ*$wncPVOl=kmZu70bm!^Mnuy;D-w#*7(1Q&cynD=1#GYxl`EL7HB`c z+J4U04jDcS9WVGC7ya#1LYtQ@?#vqVw7!#8^)7J%KP>|@jnHy_8u(1O)*&wEW58L$ z(p_gbV!*;*XJ{swGHsBh#n48Zc7uyfwitSe_N_DY(aBmvKW!`nwB(Z-4s>jg@hZ0d-aE@9NOUIl>P{9ju^UVlX{lYe#o-vJHpG~k=G6@ z9}ppFE5xYJ`GQ=BnC>@94*z%ff?)k)!A!7at3I}jG*(tnBP>~9UlJ+^;J4WdD#3=r z@{d7=!W&5a%1no=aHq1UPV|_9kDm5xrJs%fENH80t1=iu;K-RZ_e8;Z%YtJGYXeU8 zO3dd_P*R#ZBav6SPi_-=D~1$VDgs2Fmqn3wqyHza|`4|9U0(6x=GURN)?qy%a zXWpw=8cO#vk)v`ivo*xM1};%K$LtN+xnzu7&NJGiK#=l@ns1&WPUTY1*GTPSf#Fzr z8&KXx)&oXOuo~kv7L>$Z;X2YZEvgCp(ZZQ^5`lY~afHy4WLAWuIM6H^(qsu6+$`^@ z+rdnAU*7Xzwu?Xc8;u&%HP&5`Ur=VQe80M#R&3Z{-KB_U2l0&7|5S0RdXG#$FzYV! z0K5)E&V!5r#rN>{b<-yNnYj!a32m925)cQLSW%j`6s&IvC>Jw%Fal~7%Rc>Ji4`@d zh+@Talxm^rxlH;e&u>sXC2~H^JWwbijBJ)>l(V^%5;Umm_ac1}WRw@5RdCG7%&X1Q7i+rBsM#F#3Dsz2~(AcC6AUW85Xn zSh+NA8@KUTz~P0~wg`1DwkZ(?5Q6a)1olU=7IP^Aw1vM^gn+ zM6oe*mWdHJ;PbhOA#%%etyO8jvDL`~wRUi{2LZovdCGQyIgXBEpT95wFS+wB*BE^7 z;FJW8f(jsfC3_JgKY>f4NNMagwl8twmUO33QV z+oZ-w=MXb}5?+SjQzV9o`V8l){ct>#poobz0!8oacOW(rIX{v&qf$NY6s?(d|32>Nu7lUASXwEXA z;(7GQyS-h0;PMk=oR_S{(mrwq{^s5F6h|}w&Ir&YDpMqT?v>Jv3yeTC#^Jg?p?+e^j3e=KG_C9cjJ zqZYv7ky2$EmQJ!6^A>_pykG-g%7CN?z_5>A7*R}ui)__v9N4{$#IYkF*Tjei9OF3= zq6^k+680EoE9MhTUJj6wJ);PRY6GenbKs@3Yu$3~MfTi~9OXx7EAxn4?=NYgdCAjN z9qlpyNE;oVp`?`(`nd9{?U|DfIZ9lVsHSL4&GR>Oj-gtssM5cnH)tQdro_K$%-tAx z647j;8JjjNshiF+4wgJ5gZj})<|PTp=}G=isi;4rWP);$Mu^#2irlT3hT?IhC&d3} z+&Vx!ZVHmCVV{PF!6qcMCg`Uz=f8l<9nwA5o6>B7F-u(XTb7%aw_>gtXLK_qPpmL$ zyWJYFX3p<@91}YO_rbW?^lPI+%;vcv!IHng`z$3ChBW17%G^Fg#jxDz7IMlK&k7rA zRw9LFgdgkjHJ=rZcMEmrgdWZx?^=x?_Qeki<8Hxo1zaaD+V_l$c3f2=So5fkfgv}` z23*Xlrx{i~;BynY^&;%%p7I*=W)5SFlS&uC-H+Em!EhJKtAm_ z2pG*#;q)2*iB6&0@$iI0=!a{j*VlhZZ~_(^*@2?H zAF8vK70XB64-d8lPMj9bb_-qSgmVs|$0;~CFxD=>4Peu$n;$Qmg5x*JnKOmp}HLaA=UY=`t`^2pi^G%B_{!M#@w3nBvkigRDiqDw(;-*Fs z*LThEZ|s?|3yGzQf!>8>&%dAgXe!VQ(7V(A<}?2KPNA+#h&&rGc0V!3zqxHSA;*`H zgYb5B_w0d$(txoHK8x2cu39sFm|NouDR-t8xqx+lzr_^Y9? zJm;%+2<47|)fq5yk0Z=qoqTn2W+V`?i9q_I_XZ;OJ&8p~TOp$_!-}EQQ$u8 z>m2bP9~GL$Fy!O9Au^ClAEtD|=q0V7^%7aoSu0JLmq;<$tktF&1N?AjcL|$IeTk)r zX*U}#q#gUAOY|=86uQp)P7nDThlOK=moO@-F*h>(V>65d{~q4HCk-&boUI_IPy~E^ z6b+lRgxOucT`{*c5Syu$qXb}%ro|Sof(+{XM=uLitth%p$8f?ClL2L#IQ>7Qhec)h z$Zjhdi^@vE_QrjNzu0GLGDJ9Wto$OehczVZlTs{bS}{y$Of3(V8S&?lcfvxUTin;n z#P((2p$#fpMdc=-pm9c^@JXx)vtOVkb=?mru~^IL7Ydw56`KH!Ps#_jPZvrIqe3%< zEGbT`B`oX(N>Y>pAxlz3GA~e4l-z0t)s-x_LES>>p!Q22N@Sx(zC+LTAzPFpm-hlK zQbak=Y>@)|#Oe#xX27*_@%Izt8Su<@Z3xvaSd68~=Mj@9Nflq-p=VR{CB`b}H_)owr&<-N?OUJ973w+f_!4c;`;u)B zRo~7pQQx*NSzoA8us<~lp>l11Dp#nsmxgLPm+w*BfU$lRsBXJhvGo6+Td@j%17lkB zLZd}0L-u=tcU5t+{WVsQ9WT@>l2pHFt4e+YW3|)sl3XLV>wf7Mep?U9xnE$^%6}TobtFx zvFpQM37?`E^Vn(t-*-3icW^7v@_=hJ+84Ay4(j9w#wbF2&z;#!Se25rx2A4wrWKq0~g0&PvN!!CTqkt(90w-Vo*}VGZDkml0h4U zSP))@lf()gF!sBK#^7$(KYBqEh>gWyTNiYVIh6mOVn6;5l4$%n$x2wrHYOmP!yycD zO(O(B#R9ldaMY2}LUM#GWbss4L^tZ5h>!1$DJ&f58$ovphemPFpQ40dgdBn^26fPo zuqbGMLKXfeO8j&>Zap9z{}VcGLCG*AV{za3zoav$L2H6Qm{NE+gdWD1QS)D={^ao_ z01C!d@_iG8s(75C)CD6DqHNUJ-5pGCaa^cTANnDTSe^XXgR$`CgqL%V2%qH+M$xf& zKsaKaZqC)~m>5DNmy`|lu(R$7FbP; z$MzT0ekQNlMi-dS>d)xh1RauZBamO#W)m}JHpj1; zZ9cPY{`?0Q0;kTco|^WZnhuyRL*5h>y&9G7i%OqQUQ80A(*04p2^lHktBtQV&X)U) zn^%qbK4bp(o6d2k*Ue;ri4DtL_p=m-> zwqR4w!@y!-*{W~dk3MvP#{t&a$A)65fZlkOU5lr$&8%LV!C7s`_*hI(6a}r&$EzN{ ziJc#%$4A-NczSGVb&T=n~4trm)X(_$IC5kUUJQIE0osxQcYad)Ezj(0pkK zZPE+Rys0VMQ0~EpvBlVOuU<{gd9lYay}LgZ)58M=wkb{7ifp?dh8M%j)p}K9YWK(C z$Kj_zc5WmU)|)S}=;d@Yu^KJ-{GudXa<9yy34HuzI76b5QwoZfw*aLJ-WxOWyyZ)M zazf5*tk!FqSXna*ck8D{SnDOtJDSe~`2W2(CaEk6&+|Wm8BGbQZA+ey0*?YIt6qC> z#q&wv3FbH)Vq=MwfZjHV5gAadY4pFv8KVn|^$jERT)s3717FVAEBm{^M&i^TW+R-y z{w55QSh`?;orWWCzBGY;Ir*t&zFJMGLjl`9iad&>_USthue5yU$NN>^z8;zz%Css$vvvShd#TLsoI)SoA|L1aj+gM7m zczw8dv@6}3Y<&;I&}E8CW_?NDJ?{@T|MO3F-)H99`@=u2S{VFE^@Fkbu@}r-YdC5? z3wYk#B|Y0!-0L$vYxMS(n*LB~L7Eb4UO^kmfdN!ENMWkAoQ|B1L~2cl7~h}@zY9hR zrdl}?&)`35RyajL^O;kiSMC#lD7f*(>bN7RpqI!}Nf9w||!H1Zjk|B24-5HjXp?XR7)e!50J=9Ziah5D8^O2^E5LN#l^~ zCQ+mw8tQ}49PU%yBEchqpA-C&AVnZF3@?o@K=6X<8i|S!^biQu<0etJ2o4b3CJk#=bK>`iS|$( z*C<1QA&XB#sUZ!6-VHjS#T>H2F|eE_g7y~Il$I-3$^0N3H=`DUW0cI*I@ns2vO)S#L(4ktwwojsmBj%nbhHOCIMn10mzllx~|k}c>-ihCF>}Q%G_%UCp23~Ey_53sy(YnC9aqJOR^ym)?v20+5Tkz-0`JKt>kN}ZOz`k zdST?L_vsX#kFoLCs{Ja8=_;L_Ois?5<~kl6n?JVX*Zd8dYme&4D9*dncdyKan6*MR zT>wDo1A5UW5{lYRi;*KMcMfG!B8-_5)gJqw30(1XUYX zBsdIf(xEk}JtqTnLe5$Q!Ca{V-kjGAZv%Ho&;eYQ;WTio8Ctkah8eDa`;J%Ja1FTMgfn0(hCntTxl3mg$%Jv>$0}Pi zSF7sCnaywvS6HTw@lEIkZuWyG8>(plbQ!w32m*v`E+sNw!?=;X?~VH4D4 a4@l_!c;hqEYkxE=-#dba+~!Tf5cyvs#?6ud diff --git a/dev2/dev2-site b/dev2/dev2-site index 34da3fa..e261376 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -300,6 +300,59 @@ def registry(): SITES_D = os.path.join(HERE, "sites.d") +# ── Secrets ───────────────────────────────────────────────────────────────── +# sites.d/ is committed, and cli-tools is a public repository, so a value that +# is a credential never goes in it. It goes here -- 0600, outside every +# checkout, the same file `cli-tools config` already writes -- and sites.d +# carries "secret:" in its place. +CREDENTIALS = os.path.expanduser("~/.config/cli-tools/credentials.json") + +def credentials(): + try: + with open(CREDENTIALS) as f: return json.load(f) + except FileNotFoundError: return {} + +def cred(*names): + """First of `names` present in credentials.json, then the environment. The + file holds both shapes it has accumulated: a bare string and {"value": ...}.""" + c = credentials() + for n in names: + v = c.get(n) + if isinstance(v, dict): v = v.get("value") + if v: return v + return os.environ.get(names[-1]) + +def put_cred(name, value): + c = credentials(); c[name] = value + os.makedirs(os.path.dirname(CREDENTIALS), exist_ok=True) + fd = os.open(CREDENTIALS, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: json.dump(c, f, indent=1, sort_keys=True); f.write("\n") + os.chmod(CREDENTIALS, 0o600) + +SECRET_REF = re.compile(r"^secret:([A-Za-z0-9_]+)$") + +# What must never be written into sites.d. The cutover also marks the values it +# knows to be secret by provenance; this is the backstop for everything else, +# because the cost of a wrong guess is one extra entry in credentials.json and +# the cost of the opposite is a published key. +SECRET_KEY = re.compile(r"SECRET|PASSWORD|PASSWD|TOKEN|APIKEY|CREDENTIAL|DATABASE_URL|_DSN|_KEY$", re.I) + +def resolved_env_overrides(s): + """env_overrides with every "secret:" replaced by its value.""" + out = {} + for k, v in (s.get("env_overrides") or {}).items(): + m = SECRET_REF.match(v) if isinstance(v, str) else None + if not m: out[k] = v; continue + name = m.group(1) + val = cred(f"{s['site']}.{name}", name) + if not val: + die(f"{s['site']}: env_overrides[{k}] is {v!r}, but neither " + f'"{s["site"]}.{name}" nor "{name}" is in {CREDENTIALS}.\n' + f" Add it there (0600, outside every checkout). sites.d/ is committed to a\n" + f" public repository, so the value itself must not go back into it.") + out[k] = val + return out + def with_overrides(s): """sites.d/.json is merged over the registry entry: one small file per site, so parallel migrations never edit a shared file. Keys are the entry's keys @@ -508,7 +561,7 @@ def render_env(vars_, s, st, service_name=None): if k in ("REDISPORT",): out[k] = "6379" if k in ("REDISPASSWORD", "REDIS_PASSWORD"): out[k] = "" if k in ("REDISUSER",): out[k] = "default" - for k, v in (s.get("env_overrides") or {}).items(): out[k] = v + for k, v in resolved_env_overrides(s).items(): out[k] = v lines = [f"# {s['site']}{' ' + service_name if service_name else ''}: rendered by dev2-site provision from Railway on {dt.datetime.now(dt.timezone.utc).isoformat(timespec='seconds')}"] lines += [f"{k}={env_quote(v)}" for k, v in out.items()] return "\n".join(lines) + "\n", out @@ -656,7 +709,15 @@ def sslparams_for(s, st): return "sslmode=require" def db_name_for(s): - return s.get("db_name") or re.sub(r"[^a-z0-9]+", "_", s["site"].lower()).strip("_") + """The database name is interpolated into `create database` and into a DSN, and + `db_name` in sites.d can set it to anything. Constrain it at the one place it is + derived rather than quoting it at each use: a name this tool would ever generate + is already a plain identifier, so anything else is a mistake worth stopping on.""" + db = s.get("db_name") or re.sub(r"[^a-z0-9]+", "_", s["site"].lower()).strip("_") + if not re.fullmatch(r"[a-z_][a-z0-9_]{0,62}", db): + die(f"{s['site']}: db_name {db!r} is not a plain Postgres identifier " + f"([a-z_][a-z0-9_]*, at most 63 characters)") + return db def cmd_db(args): s = site(args.site); st = load_state(s["site"]) @@ -814,16 +875,8 @@ def cmd_deploy(args): mark(s["site"], "deploy", deployed_ref=ref) def porkbun_keys(): - f = os.path.expanduser("~/.config/cli-tools/credentials.json") - c = json.load(open(f)) - def get(*names): - for n in names: - v = c.get(n) - if isinstance(v, dict): v = v.get("value") - if v: return v - return os.environ.get(names[-1]) - k, sk = get("porkbun", "PORKBUN_API_KEY"), get("porkbun_secret", "PORKBUN_SECRET_API_KEY") - if not (k and sk): die("no Porkbun keys in ~/.config/cli-tools/credentials.json (cli-tools config pull)") + k, sk = cred("porkbun", "PORKBUN_API_KEY"), cred("porkbun_secret", "PORKBUN_SECRET_API_KEY") + if not (k and sk): die(f"no Porkbun keys in {CREDENTIALS} (cli-tools config pull)") return k, sk def site_domains(s): @@ -1069,8 +1122,7 @@ def cmd_supabase_stack(args): slug, ports = sb_slug(s), sb_ports(s) host = f"supabase.{s['site']}" log(f"Self-hosted Supabase for {s['site']}: {slug}-supabase, api :{ports['api']}, db :{ports['db']}, pooler :{ports['pooler']}, {ports['subnet']}") - creds = json.load(open(os.path.expanduser("~/.config/cli-tools/credentials.json"))) - smtp = creds.get("RESEND_API_KEY"); smtp = smtp.get("value") if isinstance(smtp, dict) else smtp + smtp = cred("RESEND_API_KEY") vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) smtp = vars_.get("RESEND_API_KEY") or smtp or "" ssh("root", "install -d -m 700 /root/dev2-kit") @@ -1134,23 +1186,33 @@ def cmd_supabase_cutover(args): if not st.get(k): die(f"state has no {k}") vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) ov = {} + # Which of the new values are credentials, known by where they came from + # rather than by the spelling of the key. + secret = set() for k, v in vars_.items(): if not isinstance(v, str): continue nv = v if f"{st['cloud_ref']}.supabase.co" in nv: nv = re.sub(rf"https?://{st['cloud_ref']}\.supabase\.co", f"https://{st['sb_host']}", nv).replace(f"{st['cloud_ref']}.supabase.co", st["sb_host"]) - if nv.startswith("postgres"): nv = st["sb_database_url"] - if st.get("cloud_anon_key") and v == st["cloud_anon_key"]: nv = st["sb_anon_key"] - if st.get("cloud_service_key") and v == st["cloud_service_key"]: nv = st["sb_service_key"] - if k == "SUPABASE_DB_PASSWORD": nv = st["sb_pg_password"] - if k in ("SUPABASE_JWT_SECRET", "JWT_SECRET") and st.get("sb_jwt_secret") and len(v) > 20: nv = st["sb_jwt_secret"] + if nv.startswith("postgres"): nv = st["sb_database_url"]; secret.add(k) + if st.get("cloud_anon_key") and v == st["cloud_anon_key"]: nv = st["sb_anon_key"]; secret.add(k) + if st.get("cloud_service_key") and v == st["cloud_service_key"]: nv = st["sb_service_key"]; secret.add(k) + if k == "SUPABASE_DB_PASSWORD": nv = st["sb_pg_password"]; secret.add(k) + if k in ("SUPABASE_JWT_SECRET", "JWT_SECRET") and st.get("sb_jwt_secret") and len(v) > 20: nv = st["sb_jwt_secret"]; secret.add(k) if k in ("SUPABASE_DB_REF", "SUPABASE_PROJECT_REF") and v == st["cloud_ref"]: warn(f"{k}={v}: the app derives URLs from the ref; it needs a code change to use SUPABASE_URL") if nv != v: ov[k] = nv sd_path = os.path.join(SITES_D, f"{s['site']}.json") sd = json.load(open(sd_path)) if os.path.exists(sd_path) else {} - sd["env_overrides"] = {**sd.get("env_overrides", {}), **ov} + env_ov, vaulted = dict(sd.get("env_overrides") or {}), [] + for k, v in ov.items(): + if k in secret or SECRET_KEY.search(k): + put_cred(f"{s['site']}.{k}", v); env_ov[k] = f"secret:{k}"; vaulted.append(k) + else: + env_ov[k] = v + sd["env_overrides"] = env_ov with open(sd_path, "w") as f: json.dump(sd, f, indent=1); f.write("\n") log(f"{len(ov)} variables re-pointed in sites.d/{s['site']}.json: {', '.join(sorted(ov))}") + if vaulted: note(f"{len(vaulted)} kept out of the repository, in {CREDENTIALS} as {s['site']}.: {', '.join(sorted(vaulted))}") cmd_provision(argparse.Namespace(site=s["site"])) cmd_deploy(argparse.Namespace(site=s["site"], ref=None)) cmd_verify(argparse.Namespace(site=s["site"], public=("dns" in load_state(s["site"])["steps"]))) diff --git a/dev2/sites.d/threatcrush.com.json b/dev2/sites.d/threatcrush.com.json index 12723b6..150c502 100644 --- a/dev2/sites.d/threatcrush.com.json +++ b/dev2/sites.d/threatcrush.com.json @@ -7,9 +7,9 @@ } ], "env_overrides": { - "SUPABASE_DB_PASSWORD": "5df2829f245804448a8eb604a4417d54", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.threatcrush.com", - "SUPABASE_SERVICE_ROLE_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3OTAzMjk0ODgsImV4cCI6MTk0ODAwOTQ4OH0.VRGW__PDSjJQkilfS6AEjFwbe3dnc-ffC57lAxQ2uwk", - "NEXT_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzkwMzI5NDg4LCJleHAiOjE5NDgwMDk0ODh9.3dJiBKet1EGSJCZoQaMFVD861UxKCboB7GcD-gI0EXw" + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY" } } diff --git a/dev2/sites.d/tipoffwatch.com.json b/dev2/sites.d/tipoffwatch.com.json index a85cffb..1b556a3 100644 --- a/dev2/sites.d/tipoffwatch.com.json +++ b/dev2/sites.d/tipoffwatch.com.json @@ -1,6 +1,6 @@ { "env_overrides": { - "PLAYLIST_SECRET": "postgresql://postgres:JApsRyjafaEFHDqXQHWFAJTQoNxADLOo@postgres-q7ea.railway.internal:5432/railway" + "PLAYLIST_SECRET": "secret:PLAYLIST_SECRET" }, - "notes": "PLAYLIST_SECRET pins the playlist encryption key to the old Railway DATABASE_URL: tipoffwatch derives the key from PLAYLIST_SECRET || DATABASE_URL (packages/config/src/index.js), and the kit rewrote DATABASE_URL, which made every stored playlist URL unreadable. Contains the retired Railway Postgres credential; do not commit." + "notes": "PLAYLIST_SECRET pins the playlist encryption key to the old Railway DATABASE_URL: tipoffwatch derives the key from PLAYLIST_SECRET || DATABASE_URL (packages/config/src/index.js), and the kit rewrote DATABASE_URL, which made every stored playlist URL unreadable. The value is the retired Railway Postgres credential, so it lives in credentials.json as tipoffwatch.com.PLAYLIST_SECRET, never here." } From 7e0ecc77bb9857d2b4a0ee8ca4a1a38f68384bdf Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 12:58:50 +0000 Subject: [PATCH 041/104] dev2-site provision: inside port from the Dockerfile HEALTHCHECK/EXPOSE when Railway set none Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index e261376..658155e 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -428,6 +428,18 @@ def find_dockerfile(d, cfg): if os.path.exists(os.path.join(d, ".nixpacks", "Dockerfile")): return ".nixpacks/Dockerfile" return None +def dockerfile_port(checkout, dockerfile): + """The port the image expects when Railway set none: HEALTHCHECK's localhost: + first (that is what Docker will probe), else the first EXPOSE.""" + if not checkout or not dockerfile: return None + p = os.path.join(checkout, dockerfile) + if not os.path.exists(p): return None + txt = open(p).read() + m = re.search(r"HEALTHCHECK[^\n]*?(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d{2,5})", txt, re.S) + if m: return m.group(1) + m = re.search(r"^EXPOSE\s+(\d{2,5})", txt, re.M) + return m.group(1) if m else None + def workflow_name(s): return "deploy-dev2.yml" if not s.get("shared_repo") else f"deploy-dev2-{s['site'].replace('.', '-')}.yml" @@ -627,9 +639,9 @@ def cmd_provision(args): if s["db"] in ("supabase", "turso") and not st.get("database_url") and not s.get("env_overrides"): warn(f"db kind {s['db']}: app.env keeps pointing at the current {s['db']} until its own migration; set env_overrides in sites.overrides.json when ready") vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) - inside = s.get("port_var") or "3000" - app_env, merged = render_env(vars_, s, st) image_only = bool(s.get("image")) and not s.get("repo") + inside = s.get("port_var") or ("3000" if image_only else dockerfile_port(st.get("checkout") or checkout_dir(s), st.get("dockerfile")) or "3000") + app_env, merged = render_env(vars_, s, st) if image_only: if not s.get("start_command"): die(f"{s['site']} runs a stock image; set start_command (Railway's deployment manifest has it) in sites.d/{s['site']}.json") cfg, branch, hp = {}, "", s.get("health_path") or "/" From 69d8bb34169a499b1d8f9f4e98dad7e6ef841058 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:01:50 +0000 Subject: [PATCH 042/104] dev2-site deploy: default to the default branch once the scaffold PR is merged Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index 658155e..96f8c1c 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -879,7 +879,10 @@ def cmd_volumes(args): def cmd_deploy(args): s = site(args.site); st = load_state(s["site"]) - ref = args.ref or st.get("scaffold_branch") or st.get("branch") or s.get("branch") or ("image" if not s.get("repo") else "main") + # Before the merge the scaffold branch carries the workflow; after it the branch is + # gone and the default branch is what CI deploys. + merged = "merge" in st["steps"] + ref = args.ref or (None if merged else st.get("scaffold_branch")) or st.get("branch") or s.get("branch") or ("image" if not s.get("repo") else "main") log(f"Deploy {s['site']} @ {ref} on dev2 (as {DEPLOY_USER})") out = ssh(DEPLOY_USER, f"{WWW}/{s['site']}/deploy-app.sh {shlex.quote(ref)} 2>&1", check=False, timeout=3600) print(out[-4000:]) From dae6486b194492b09a3193e60725b232f55f5e97 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:02:13 +0000 Subject: [PATCH 043/104] dev2-site verify: probe wildcards through a sample label Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dev2/dev2-site b/dev2/dev2-site index 96f8c1c..87fc6cf 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -958,6 +958,8 @@ def cmd_verify(args): die(f"no nginx vhost for {s['site']} yet: run `dev2-site vhost {s['site']}` first") ok = True for d in s["domains"]: + # A wildcard is probed through a sample label (the cert and vhost cover it). + if d.startswith("*."): d = "dev2-probe." + d[2:] resolve = [] if args.public else ["--resolve", f"{d}:443:{DEV2_IP}"] for attempt in range(12 if args.public else 1): # resolvers cache the old record for up to TTL 600 curl = ["curl", "-sk", "-o", "/dev/null", "-m", "30", "-w", "%{http_code}", *resolve, f"https://{d}{hp}"] From e0bc53d79fd93bb701e15d0be49f19e83cd60837 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:03:15 +0000 Subject: [PATCH 044/104] dev2-site: HTTP-01 certificates and manual DNS for zones not at Porkbun Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/dev2/dev2-site b/dev2/dev2-site index 87fc6cf..1f84e0b 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -904,9 +904,37 @@ def site_domains(s): wild = [n[2:] for n in names if n.startswith("*.")] return [n for n in names if n.startswith("*.") or not any(n.endswith("." + z) and n.count(".") == z.count(".") + 1 for z in wild)] +def zone_at_porkbun(domain): + zone = ".".join(domain.split(".")[-2:]) + ns = sh(["dig", "+short", "NS", zone], check=False).lower() + return "porkbun" in ns + +def cmd_cert_http(s, domains): + """HTTP-01 for zones whose DNS we do not control (Cloudflare etc.): DNS must already + point at dev2. An http-only server block serves the challenge from /var/www/acme.""" + cert_dir = f"/etc/nginx/ssl/{s['site']}" + for d in domains: + if d.startswith("*."): die(f"{d}: a wildcard needs DNS-01; move the zone to Porkbun or drop the wildcard") + ip = sh(["dig", "+short", d], check=False).split() + if DEV2_IP not in ip: die(f"{d} resolves to {ip or 'nothing'}, not {DEV2_IP}: point the zone's A record at dev2 first (DNS-only, not proxied, for issuance)") + log(f"Certificate for {', '.join(domains)} (acme.sh, HTTP-01 through nginx, zone not at Porkbun)") + conf = "server {\n listen 80;\n listen [::]:80;\n server_name " + " ".join(domains) + ";\n location ^~ /.well-known/acme-challenge/ { root /var/www/acme; default_type \"text/plain\"; }\n location / { return 301 https://$host$request_uri; }\n}\n" + ssh_put(conf, f"/etc/nginx/sites-available/{s['site']}", "0644") + ssh("root", f"install -d -m 0755 /var/www/acme && ln -sf /etc/nginx/sites-available/{s['site']} /etc/nginx/sites-enabled/{s['site']} && nginx -t 2>&1 | tail -n1 && systemctl reload nginx") + ds = " ".join(f"-d {shlex.quote(d)}" for d in domains) + ssh("root", "test -x /root/.acme.sh/acme.sh || curl -fsSL https://get.acme.sh | env HOME=/root sh -s email=" + ACME_EMAIL + " >/dev/null") + out = ssh("root", f"install -d -m 0750 {cert_dir} && env HOME=/root /root/.acme.sh/acme.sh --issue --server letsencrypt --webroot /var/www/acme {ds} --force >/tmp/acme-{s['site']}.log 2>&1 || {{ tail -n 25 /tmp/acme-{s['site']}.log; exit 1; }}", check=False, timeout=1800) + if out.strip() and "Cert success" not in out: die(out) + ssh("root", f"env HOME=/root /root/.acme.sh/acme.sh --install-cert -d {shlex.quote(domains[0])} --fullchain-file {cert_dir}/fullchain.pem --key-file {cert_dir}/privkey.pem --reloadcmd 'systemctl reload nginx' >/dev/null") + note(ssh("root", f"openssl x509 -in {cert_dir}/fullchain.pem -noout -subject -enddate | tr '\\n' ' '").strip()) + mark(s["site"], "cert", cert_domains=domains, cert_mode="http-01") + def cmd_cert(args): s = site(args.site) domains = site_domains(s) + if not zone_at_porkbun(s["site"]): + note(f"{s['site']}'s zone is not at Porkbun (NS: {sh(['dig', '+short', 'NS', '.'.join(s['site'].split('.')[-2:])], check=False).strip().replace(chr(10), ' ')})") + return cmd_cert_http(s, domains) k, sk = porkbun_keys() cert_dir = f"/etc/nginx/ssl/{s['site']}" log(f"Certificate for {', '.join(domains)} (acme.sh, Porkbun DNS-01, issued before DNS moves)") @@ -981,6 +1009,10 @@ def cmd_verify(args): def cmd_dns(args): s = site(args.site) domains = site_domains(s) + if not zone_at_porkbun(s["site"]): + warn(f"{s['site']}'s DNS is not at Porkbun; point every A record ({', '.join(domains)}) at {DEV2_IP} by hand, then re-run"); + if not all(DEV2_IP in sh(["dig", "+short", d], check=False).split() for d in domains if not d.startswith("*.")): die("DNS not pointing at dev2 yet") + mark(s["site"], "dns", dns_flipped="manual"); return zones = {} for d in domains: zone = ".".join(d.split(".")[-2:]); zones.setdefault(zone, []).append(d) From 49fec2d9f573a004052740ff5b68ff885363b47b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:12:41 +0000 Subject: [PATCH 045/104] dev2-site: nixpacks images get the start command as one bash -c string Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index 1f84e0b..d99e61d 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -594,7 +594,11 @@ def render_compose(s, st, app_port_inside, build_args=()): y.append(" args:"); y.extend(f" {k}: \"${{{k}:-}}\"" for k in build_args) y.append(f" image: {img}-{name}:latest\n restart: unless-stopped\n env_file: {envfile}") y.append(f" environment:\n PORT: \"{app_port_inside}\"\n HOST: \"0.0.0.0\"\n NODE_ENV: production") - if command: y.append(f" command: {json.dumps(shlex.split(command))}") + if command: + # A nixpacks image's entrypoint is `/bin/bash -l -c`, and bash -c takes ONE script + # argument: an argv list would run only the first word (Bun's help text, exit 0, + # restart loop). Hand it the whole string. Other images exec the argv directly. + y.append(f" command: {json.dumps([command] if (df or '').startswith('.nixpacks/') else shlex.split(command))}") if ports: y.append(f" ports:\n - \"127.0.0.1:${{APP_PORT}}:{app_port_inside}\"") if vols: y.append(" volumes:") From f1a6b116033566c4653a4112d3dcae8bb5e8a376 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:15:56 +0000 Subject: [PATCH 046/104] sites.d: app.moshcode.sh runs the pwa server, not the CLI Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/app.moshcode.sh.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/app.moshcode.sh.json diff --git a/dev2/sites.d/app.moshcode.sh.json b/dev2/sites.d/app.moshcode.sh.json new file mode 100644 index 0000000..b631a9f --- /dev/null +++ b/dev2/sites.d/app.moshcode.sh.json @@ -0,0 +1 @@ +{"start_command": "node apps/pwa/src/server.mjs", "health_path": "/healthz", "notes": "Railway ran this service from apps/pwa (its railway.json: npm start -> node src/server.mjs, /healthz); the repo root start is the moshcode CLI"} From 2c657e6e13751c3ea9d3f5b24ee06fd7f76ccf84 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:17:10 +0000 Subject: [PATCH 047/104] dev2-site: env_remove for settings a ported app refuses; README for the Turso second pass Co-Authored-By: Claude Fable 5.1 --- dev2/README.md | 11 ++++++++--- dev2/dev2-site | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/dev2/README.md b/dev2/README.md index 6c429fc..cd44731 100644 --- a/dev2/README.md +++ b/dev2/README.md @@ -81,7 +81,8 @@ small file per site, so parallel migrations never edit a shared file: { "health_path": "/healthz", "port_var": "8080", "start_command": "bun run start", "companion_commands": { "genrewatch-worker": "bun run worker" }, "env_overrides": { "SITE_URL": "https://example.com" }, - "pg_service": "Postgres-iVtY", "max_body": "256m", "branch": "master" } + "pg_service": "Postgres-iVtY", "max_body": "256m", "branch": "master", + "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"] } ``` ### Secrets in `env_overrides` @@ -116,8 +117,12 @@ reference is written back into `sites.d/`. `pull-cloud.sh`, `load-selfhost.sh`, `sync-storage.mjs`). Then `env_overrides` + `provision` + `deploy` re-point the app. - `turso`: the app keeps talking to Turso after the move; the database moves - in a second pass with a Postgres port of the app's db layer (the pattern is - rssamplifier.com `packages/db/src/pg.js`: serve the libSQL surface over pg). + in a second pass once the app's Postgres port (on `@profullstack/libsql-pg`) + is merged: `dev2-site db-create ` (empty database in the shared cluster, + DATABASE_URL into state), apply the app's Postgres schema, `npx libsql-pg copy + --from --token ... --to --verify`, then + `provision` (libsql:// values become the new DATABASE_URL; list the retired + Turso settings in `env_remove`) and `deploy `. ## State diff --git a/dev2/dev2-site b/dev2/dev2-site index d99e61d..2ef8db4 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -574,6 +574,7 @@ def render_env(vars_, s, st, service_name=None): if k in ("REDISPASSWORD", "REDIS_PASSWORD"): out[k] = "" if k in ("REDISUSER",): out[k] = "default" for k, v in resolved_env_overrides(s).items(): out[k] = v + for k in s.get("env_remove") or []: out.pop(k, None) # settings a ported app refuses outright lines = [f"# {s['site']}{' ' + service_name if service_name else ''}: rendered by dev2-site provision from Railway on {dt.datetime.now(dt.timezone.utc).isoformat(timespec='seconds')}"] lines += [f"{k}={env_quote(v)}" for k, v in out.items()] return "\n".join(lines) + "\n", out From 20bdde8ccb9c7a3f2c54d2b8e03e169497e75889 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:19:24 +0000 Subject: [PATCH 048/104] dev2-site db: copy the database the app's URL names, not the instance default Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dev2/dev2-site b/dev2/dev2-site index 2ef8db4..98c1aa6 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -772,6 +772,14 @@ def cmd_db(args): user = rw_ssh_user(s["project_id"], s["environment_id"], pg["name"]) pgvars = rw_variables(s["project_id"], s["environment_id"], pg["id"]) src_db = pgvars.get("PGDATABASE") or pgvars.get("POSTGRES_DB") or "railway" + # Several apps can share one Railway Postgres instance, each in its own database + # (mynaposter-api used /myna on the instance whose default is /railway): the app's own + # URL is the authority on which database to copy. + appvars = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + for v in appvars.values(): + m = re.match(r"postgres(?:ql)?://[^@]+@[a-z0-9.-]+\.railway\.internal(?::\d+)?/([A-Za-z0-9_]+)", str(v)) + if m and m.group(1) != src_db: note(f"app uses database {m.group(1)} on that instance (instance default is {src_db})"); src_db = m.group(1); break + if s.get("pg_database"): src_db = s["pg_database"] dump = f"/root/dumps/{db}-{dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.dump" ssh("root", "install -d -m 700 /root/dumps") log(f"pg_dump {src_db} via ssh.railway.com -> {DEV2_HOST}:{dump}") From e75d4462e601e66300a967a50bf77b47d99c7185 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:20:39 +0000 Subject: [PATCH 049/104] dev2-site db: companions' variables may name the database Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 4 +++- dev2/sites.d/nightcell7.com.json | 4 ++++ dev2/sites.d/ringhuman.com.json | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 dev2/sites.d/nightcell7.com.json create mode 100644 dev2/sites.d/ringhuman.com.json diff --git a/dev2/dev2-site b/dev2/dev2-site index 98c1aa6..f2c81e3 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -775,7 +775,9 @@ def cmd_db(args): # Several apps can share one Railway Postgres instance, each in its own database # (mynaposter-api used /myna on the instance whose default is /railway): the app's own # URL is the authority on which database to copy. - appvars = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + appvars = {} + for sid in [s["service_id"]] + [c["id"] for c in s.get("companions", [])]: # a companion may be the one that talks to Postgres + appvars.update(rw_variables(s["project_id"], s["environment_id"], sid)) for v in appvars.values(): m = re.match(r"postgres(?:ql)?://[^@]+@[a-z0-9.-]+\.railway\.internal(?::\d+)?/([A-Za-z0-9_]+)", str(v)) if m and m.group(1) != src_db: note(f"app uses database {m.group(1)} on that instance (instance default is {src_db})"); src_db = m.group(1); break diff --git a/dev2/sites.d/nightcell7.com.json b/dev2/sites.d/nightcell7.com.json new file mode 100644 index 0000000..d385ef2 --- /dev/null +++ b/dev2/sites.d/nightcell7.com.json @@ -0,0 +1,4 @@ +{ + "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"], + "db_sslparams": "sslmode=no-verify" +} diff --git a/dev2/sites.d/ringhuman.com.json b/dev2/sites.d/ringhuman.com.json new file mode 100644 index 0000000..6d2a6e3 --- /dev/null +++ b/dev2/sites.d/ringhuman.com.json @@ -0,0 +1 @@ +{ "branch": "master", "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"] } From 148ffdf8b5789860cef292a35cb412d8f1a62020 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:25:32 +0000 Subject: [PATCH 050/104] dev2-site: a published companion listens on its own inside port Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index f2c81e3..04a6c77 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -585,7 +585,8 @@ def render_compose(s, st, app_port_inside, build_args=()): y = [f"# {s['site']} on dev2: rendered by cli-tools dev2/dev2-site provision. Re-run provision to change.", "# nginx on the host terminates TLS and proxies to 127.0.0.1:${APP_PORT}.", "services:"] image_only = bool(s.get("image")) and not s.get("repo") - def service(name, envfile, command=None, ports=True, vols=()): + def service(name, envfile, command=None, ports=True, vols=(), port_inside=None): + port_inside = port_inside or app_port_inside y.append(f" {name}:") if image_only: # Railway ran a stock image with a start command; no repo, nothing to build y.append(f" image: {s['image']}\n restart: unless-stopped\n env_file: {envfile}") @@ -594,7 +595,7 @@ def render_compose(s, st, app_port_inside, build_args=()): if build_args: # deploy-app.sh exports app.env before `compose build`, so ${K} resolves y.append(" args:"); y.extend(f" {k}: \"${{{k}:-}}\"" for k in build_args) y.append(f" image: {img}-{name}:latest\n restart: unless-stopped\n env_file: {envfile}") - y.append(f" environment:\n PORT: \"{app_port_inside}\"\n HOST: \"0.0.0.0\"\n NODE_ENV: production") + y.append(f" environment:\n PORT: \"{port_inside}\"\n HOST: \"0.0.0.0\"\n NODE_ENV: production") if command: # A nixpacks image's entrypoint is `/bin/bash -l -c`, and bash -c takes ONE script # argument: an argv list would run only the first word (Bun's help text, exit 0, @@ -612,7 +613,7 @@ def render_compose(s, st, app_port_inside, build_args=()): # companion_ports {"name": {"host": 3301, "inside": 3000}} publishes a companion on # loopback so a `proxies` entry can give it its own hostname (an API the web app calls). cp = (s.get("companion_ports") or {}).get(c["name"]) - service(c["name"], f"{c['name']}.env", command=(s.get("companion_commands") or {}).get(c["name"]), ports=False, vols=c.get("volumes", [])) + service(c["name"], f"{c['name']}.env", command=(s.get("companion_commands") or {}).get(c["name"]), ports=False, vols=c.get("volumes", []), port_inside=(cp or {}).get("inside")) if cp: y.insert(len(y) - (2 if s["redis"] else 1), f" ports:\n - \"127.0.0.1:{cp['host']}:{cp['inside']}\"") if s["redis"]: y.append(" redis:\n image: redis:8-alpine\n restart: unless-stopped\n command: [\"redis-server\", \"--appendonly\", \"yes\", \"--maxmemory\", \"1gb\", \"--maxmemory-policy\", \"noeviction\"]\n volumes:\n - redisdata:/data\n logging:\n driver: json-file\n options: { max-size: \"20m\", max-file: \"2\" }") From 0a6469961f354168a4038a0fdc0928d854289e84 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:28:03 +0000 Subject: [PATCH 051/104] dev2: tronbrowser.dev on Postgres, retire the Turso settings Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/tronbrowser.dev.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/tronbrowser.dev.json diff --git a/dev2/sites.d/tronbrowser.dev.json b/dev2/sites.d/tronbrowser.dev.json new file mode 100644 index 0000000..50d2709 --- /dev/null +++ b/dev2/sites.d/tronbrowser.dev.json @@ -0,0 +1 @@ +{ "env_remove": ["TRONBROWSER_DB_URL", "TRONBROWSER_DB_AUTH_TOKEN", "TRONBROWSER_DB_PATH"] } From 585c13a496560e93e6526874caa6b9cc417a20ba Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:28:14 +0000 Subject: [PATCH 052/104] sites.d: app.moshcode.sh uses its own Dockerfile Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/app.moshcode.sh.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dev2/sites.d/app.moshcode.sh.json b/dev2/sites.d/app.moshcode.sh.json index b631a9f..85ec5f4 100644 --- a/dev2/sites.d/app.moshcode.sh.json +++ b/dev2/sites.d/app.moshcode.sh.json @@ -1 +1,4 @@ -{"start_command": "node apps/pwa/src/server.mjs", "health_path": "/healthz", "notes": "Railway ran this service from apps/pwa (its railway.json: npm start -> node src/server.mjs, /healthz); the repo root start is the moshcode CLI"} +{ + "health_path": "/healthz", + "notes": "web app is apps/pwa (own deps); plain Dockerfile on the deploy branch, health /healthz" +} \ No newline at end of file From 0b635b5bb50c93a1086b54a089d77dc941039414 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:31:13 +0000 Subject: [PATCH 053/104] dev2-site cert: drop stale _acme-challenge TXT records before issuing Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 4 ++++ dev2/sites.d/goingbroke.now.json | 5 +++++ dev2/sites.d/meshhook.com.json | 10 +++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 dev2/sites.d/goingbroke.now.json diff --git a/dev2/dev2-site b/dev2/dev2-site index 04a6c77..36a41a8 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -963,6 +963,10 @@ def cmd_cert(args): for r in recs: if r.get("type") == "CNAME" and r.get("name", "").startswith("_acme-challenge") and "railwaydns" in r.get("content", ""): sh(["porkbun", "rm", zone, r["name"][: -len(zone) - 1], "--type", "CNAME", "--yes"], check=False); note(f"removed Railway acme CNAME {r['name']}") + # An interrupted acme.sh run leaves its TXT challenge behind, and the Porkbun hook + # then fails the next issuance with DUPLICATE_RECORD. + if r.get("type") == "TXT" and r.get("name", "") in (host + "." + zone if host != "_acme-challenge" else "_acme-challenge." + zone,): + sh(["porkbun", "rm", zone, r["id"], "--yes"], check=False); note(f"removed stale acme TXT {r['name']}") ssh("root", "test -x /root/.acme.sh/acme.sh || curl -fsSL https://get.acme.sh | env HOME=/root sh -s email=" + ACME_EMAIL + " >/dev/null") ds = " ".join(f"-d {shlex.quote(d)}" for d in domains) # A certificate acme.sh issued in the last day is reused (a retry after a dropped diff --git a/dev2/sites.d/goingbroke.now.json b/dev2/sites.d/goingbroke.now.json new file mode 100644 index 0000000..c6bdbd9 --- /dev/null +++ b/dev2/sites.d/goingbroke.now.json @@ -0,0 +1,5 @@ +{ + "branch": "master", + "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN", "DATA_DIR"], + "db_sslparams": "sslmode=require" +} diff --git a/dev2/sites.d/meshhook.com.json b/dev2/sites.d/meshhook.com.json index f381a45..56abca3 100644 --- a/dev2/sites.d/meshhook.com.json +++ b/dev2/sites.d/meshhook.com.json @@ -1 +1,9 @@ -{"node_version": "24", "notes": "pnpm in the lockfile needs Node >= 22.13; nixpacks pinned nixpkgs has 22.11"} +{ + "node_version": "24", + "notes": "pnpm in the lockfile needs Node >= 22.13; nixpacks pinned nixpkgs has 22.11", + "branch": "master", + "env_remove": [ + "TURSO_DATABASE_URL", + "TURSO_AUTH_TOKEN" + ] +} From 61c8113a498beaf9c38e4ed48141d5e62a8092ff Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:37:36 +0000 Subject: [PATCH 054/104] dev2: feedback.profullstack.com off Turso onto the shared Postgres cluster Retire TURSO_DATABASE_URL and TURSO_AUTH_TOKEN, deploy master, and pin sslmode=require for the @profullstack/libsql-pg pool (no-verify would connect in the clear and pg_hba rejects that). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/feedback.profullstack.com.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 dev2/sites.d/feedback.profullstack.com.json diff --git a/dev2/sites.d/feedback.profullstack.com.json b/dev2/sites.d/feedback.profullstack.com.json new file mode 100644 index 0000000..b7a699a --- /dev/null +++ b/dev2/sites.d/feedback.profullstack.com.json @@ -0,0 +1,5 @@ +{ + "branch": "master", + "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"], + "db_sslparams": "sslmode=require" +} From b7ae547149de1da91843c8fd82e2ec5867f4d697 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:38:36 +0000 Subject: [PATCH 055/104] dev2: reeleel.com registry on Postgres, retire the Turso replica settings Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/reeleel.com.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/reeleel.com.json diff --git a/dev2/sites.d/reeleel.com.json b/dev2/sites.d/reeleel.com.json new file mode 100644 index 0000000..8b124a6 --- /dev/null +++ b/dev2/sites.d/reeleel.com.json @@ -0,0 +1 @@ +{ "branch": "master", "env_remove": ["REELEEL_DB_URL", "REELEEL_DB_AUTH_TOKEN", "REELEEL_DB_REPLICA_PATH", "REELEEL_DB_SYNC_INTERVAL"] } From 5e02e643000b11d26ffbfdc573e232334cd0bc3e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:42:23 +0000 Subject: [PATCH 056/104] dev2: opensourcelegends.com off Turso onto the shared Postgres cluster Retire TURSO_DATABASE_URL and TURSO_AUTH_TOKEN, deploy master, and pin sslmode=require for the @profullstack/libsql-pg pool. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/opensourcelegends.com.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 dev2/sites.d/opensourcelegends.com.json diff --git a/dev2/sites.d/opensourcelegends.com.json b/dev2/sites.d/opensourcelegends.com.json new file mode 100644 index 0000000..b7a699a --- /dev/null +++ b/dev2/sites.d/opensourcelegends.com.json @@ -0,0 +1,5 @@ +{ + "branch": "master", + "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"], + "db_sslparams": "sslmode=require" +} From 3133d8e5fa35129e11ccdf96cc37ccd090ca2457 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:52:24 +0000 Subject: [PATCH 057/104] dev2-site: detect the Postgres driver on origin's default branch; libsql-pg apps take sslmode=require Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index 36a41a8..af42338 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -717,13 +717,19 @@ def pg_admin(sql, db="postgres"): return ssh("root", f"docker exec -i {PG_CONTAINER} psql -U supabase_admin -h localhost -d {shlex.quote(db)} -X -At -v ON_ERROR_STOP=1", input=sql) def sslparams_for(s, st): - """`sslmode=no-verify` for node-postgres repos, `sslmode=require` for everything else.""" + """`sslmode=no-verify` for repos that use node-postgres directly (pg reads `require` as + verify-full and rejects the cluster's self-signed cert); `sslmode=require` for Bun.SQL, + postgres.js and @profullstack/libsql-pg (whose own parser turns `no-verify` into a plain + connection that pg_hba rejects). Read from the repo's DEFAULT BRANCH on origin, not the + working tree: local checkouts lag behind merged ports.""" d = st.get("checkout") or (checkout_dir(s) if s.get("repo") else None) - if d: - try: - out = sh(f"grep -rls --include=package.json -E '\"pg\"\\s*:' {shlex.quote(d)} --exclude-dir=node_modules --exclude-dir=.git | head -n 1", check=False) - if out.strip(): return "sslmode=no-verify" - except RuntimeError: pass + if d and s.get("repo"): + branch = st.get("branch") or s.get("branch") or default_branch(s["repo"]) + sh(["git", "-C", d, "fetch", "-q", "origin", branch], check=False) + pkgs = sh(["git", "-C", d, "grep", "-l", "-E", r'"pg"\s*:', f"origin/{branch}", "--", "package.json", "*/package.json", "*/*/package.json"], check=False) + uses_pg = bool(pkgs.strip()) + uses_shim = bool(sh(["git", "-C", d, "grep", "-l", "@profullstack/libsql-pg", f"origin/{branch}", "--", "package.json", "*/package.json", "*/*/package.json"], check=False).strip()) + if uses_pg and not uses_shim: return "sslmode=no-verify" return "sslmode=require" def db_name_for(s): From 42fa4b3d25af2522bc5af6acabf68407e8cab1a7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:55:33 +0000 Subject: [PATCH 058/104] dev2-site: fall back to the live app.env on dev2 when Railway has no variables; box-tune raises inotify limits Retiring a Railway service deletes its variables, and the Supabase second pass re-renders app.env from them; a cutover after retire would have deployed an empty environment. Envoy in a new stack segfaults once 128 inotify instances are taken (about 130 containers). Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 39 +++++++++++++++++++++++++++++++++----- dev2/templates/box-tune.sh | 7 +++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index af42338..08f77de 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -148,6 +148,35 @@ def rw_variables(pid, eid, sid): return gql("query($p:String!,$e:String!,$s:String!){variables(projectId:$p,environmentId:$e,serviceId:$s)}", {"p": pid, "e": eid, "s": sid})["variables"] or {} +def parse_env_file(text): + """A compose env file as `render_env` writes it (double-quoted values may hold newlines).""" + out, key, buf = {}, None, None + for line in text.split("\n"): + if key is not None: + buf += "\n" + line + if line.endswith('"') and not line.endswith('\\"'): + out[key] = buf[1:-1].replace('\\"', '"').replace("\\\\", "\\"); key, buf = None, None + continue + if not line or line.startswith("#") or "=" not in line: continue + k, v = line.split("=", 1) + if v.startswith('"') and not (v.endswith('"') and len(v) > 1 and not v.endswith('\\"')): key, buf = k, v; continue + if v.startswith('"') and v.endswith('"') and len(v) > 1: v = v[1:-1].replace('\\"', '"').replace("\\\\", "\\") + out[k] = v + return out + +def site_variables(s, sid=None, envfile="app.env"): + """The service's environment: Railway while the service exists, else the live app.env on + dev2 (a retired service has no variables left anywhere else). Values from dev2 are already + the rendered ones, which is what a re-render should start from.""" + vars_ = {} + try: vars_ = rw_variables(s["project_id"], s["environment_id"], sid or s["service_id"]) + except RuntimeError as e: warn(f"railway variables: {str(e)[:120]}") + if vars_: return vars_ + txt = ssh("root", f"cat {WWW}/{shlex.quote(s['site'])}/{shlex.quote(envfile)} 2>/dev/null", check=False) + if not txt.strip(): die(f"{s['site']}: no variables on Railway and no {envfile} on dev2 to fall back to") + note(f"{s['site']}: Railway has no variables (service retired); using the live {envfile} from dev2") + return parse_env_file(txt) + def rw_ssh_user(pid, eid, service_name): out = sh(["railway", "ssh", "config", "--dry-run", "-i", RAILWAY_SSH_KEY, "--project", pid, "--environment", eid, "--service", service_name]) @@ -644,7 +673,7 @@ def cmd_provision(args): if s["db"] == "pg" and not st.get("database_url"): die("run `dev2-site db` first (the app env needs the new DATABASE_URL)") if s["db"] in ("supabase", "turso") and not st.get("database_url") and not s.get("env_overrides"): warn(f"db kind {s['db']}: app.env keeps pointing at the current {s['db']} until its own migration; set env_overrides in sites.overrides.json when ready") - vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + vars_ = site_variables(s) image_only = bool(s.get("image")) and not s.get("repo") inside = s.get("port_var") or ("3000" if image_only else dockerfile_port(st.get("checkout") or checkout_dir(s), st.get("dockerfile")) or "3000") app_env, merged = render_env(vars_, s, st) @@ -676,7 +705,7 @@ def cmd_provision(args): own = f"{DEPLOY_USER}:{DEPLOY_USER}" ssh_put(app_env, f"{root}/app.env", "0600", own) for c in s["companions"]: - cv = rw_variables(s["project_id"], s["environment_id"], c["id"]) + cv = site_variables(s, c["id"], f"{c['name']}.env") cenv, _ = render_env(cv, s, st, c["name"]) ssh_put(cenv, f"{root}/{c['name']}.env", "0600", own) ssh_put(deploy_env, f"{root}/deploy.env", "0640", own) @@ -1165,7 +1194,7 @@ def sb_slug(s): return re.sub(r"[^a-z0-9]+", "-", s["site"].lower()).strip("-") def sb_cloud(s, st): """Cloud project ref + db password + pooler host, from the app's Railway variables and the management API.""" - vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + vars_ = site_variables(s) refs = {m.group(1) for v in vars_.values() if isinstance(v, str) for m in re.finditer(r"https?://([a-z]{20})\.supabase\.co", v)} refs |= {v for k, v in vars_.items() if k in ("SUPABASE_DB_REF", "SUPABASE_PROJECT_REF", "SUPABASE_REF") and isinstance(v, str) and re.fullmatch(r"[a-z]{20}", v)} ref = s.get("cloud_ref") or st.get("cloud_ref") @@ -1198,7 +1227,7 @@ def cmd_supabase_stack(args): host = f"supabase.{s['site']}" log(f"Self-hosted Supabase for {s['site']}: {slug}-supabase, api :{ports['api']}, db :{ports['db']}, pooler :{ports['pooler']}, {ports['subnet']}") smtp = cred("RESEND_API_KEY") - vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + vars_ = site_variables(s) smtp = vars_.get("RESEND_API_KEY") or smtp or "" ssh("root", "install -d -m 700 /root/dev2-kit") ssh_put(tmpl("supabase-stack.sh"), "/root/dev2-kit/supabase-stack.sh", "0700") @@ -1259,7 +1288,7 @@ def cmd_supabase_cutover(args): s = site(args.site); st = load_state(s["site"]) for k in ("cloud_ref", "sb_host", "sb_anon_key", "sb_service_key", "sb_database_url", "sb_pg_password"): if not st.get(k): die(f"state has no {k}") - vars_ = rw_variables(s["project_id"], s["environment_id"], s["service_id"]) + vars_ = site_variables(s) ov = {} # Which of the new values are credentials, known by where they came from # rather than by the spelling of the key. diff --git a/dev2/templates/box-tune.sh b/dev2/templates/box-tune.sh index 9d8aa24..6bceea4 100755 --- a/dev2/templates/box-tune.sh +++ b/dev2/templates/box-tune.sh @@ -6,3 +6,10 @@ set -euo pipefail f=/etc/ssh/sshd_config.d/00-dev2-fleet.conf printf '# managed by cli-tools dev2/templates/box-tune.sh\nMaxStartups 100:30:300\nMaxSessions 100\nLoginGraceTime 60\n' > "$f.tmp" if ! cmp -s "$f.tmp" "$f" 2>/dev/null; then mv "$f.tmp" "$f"; sshd -t && systemctl reload ssh 2>/dev/null || systemctl reload sshd; echo "sshd: $(tr '\n' ' ' < "$f" | sed 's/# managed[^M]*//')"; else rm -f "$f.tmp"; echo "sshd: already tuned"; fi + +# - inotify: every container's Envoy/Node watchers take an inotify instance; the kernel +# default of 128 per uid runs out around 130 containers and Envoy then segfaults with +# "assert failure: inotify_fd_ >= 0". Persisted, because a reboot drops a plain sysctl -w. +s=/etc/sysctl.d/90-dev2-fleet.conf +printf 'fs.inotify.max_user_instances = 8192\nfs.inotify.max_user_watches = 1048576\nfs.file-max = 2097152\n' > "$s.tmp" +if ! cmp -s "$s.tmp" "$s" 2>/dev/null; then mv "$s.tmp" "$s"; sysctl -q -p "$s"; echo "sysctl: $(tr '\n' ' ' < "$s")"; else rm -f "$s.tmp"; echo "sysctl: already tuned ($(sysctl -n fs.inotify.max_user_instances) instances)"; fi From f000a2b14b3d8bd49879e1ba9eef05470ff55aac Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 13:57:15 +0000 Subject: [PATCH 059/104] dev2-site supabase-load --reset: replace an earlier load with a fresh dump Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 4 ++-- dev2/templates/supabase-load.sh | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index 08f77de..4e38778 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -1266,7 +1266,7 @@ def cmd_supabase_load(args): if not st.get(k): die(f"state has no {k}: run supabase-stack and supabase-pull first") log(f"Loading {st['sb_dump']} into {st['sb_slug']}-supabase-db") ssh_put(tmpl("supabase-load.sh"), "/root/dev2-kit/supabase-load.sh", "0700") - res = ssh("root", f"env DUMP={st['sb_dump']} DBC={st['sb_slug']}-supabase-db CLOUD_REF={st['cloud_ref']} NEW_HOST={st['sb_host']} POST_ONLY={'1' if args.post_only else '0'} bash /root/dev2-kit/supabase-load.sh 2>&1", timeout=6 * 3600) + res = ssh("root", f"env DUMP={st['sb_dump']} DBC={st['sb_slug']}-supabase-db CLOUD_REF={st['cloud_ref']} NEW_HOST={st['sb_host']} POST_ONLY={'1' if args.post_only else '0'} RESET={'1' if getattr(args, 'reset', False) else '0'} bash /root/dev2-kit/supabase-load.sh 2>&1", timeout=6 * 3600) print(res[-6000:]) mark(s["site"], "supabase-load") @@ -1353,7 +1353,7 @@ def main(): for name, fn in (("supabase-stack", cmd_supabase_stack), ("supabase-cutover", cmd_supabase_cutover)): p = sub.add_parser(name); p.add_argument("site"); p.set_defaults(fn=fn) p = sub.add_parser("supabase-pull"); p.add_argument("site"); p.add_argument("--reset-password", action="store_true"); p.set_defaults(fn=cmd_supabase_pull) - p = sub.add_parser("supabase-load"); p.add_argument("site"); p.add_argument("--post-only", action="store_true"); p.set_defaults(fn=cmd_supabase_load) + p = sub.add_parser("supabase-load"); p.add_argument("site"); p.add_argument("--post-only", action="store_true"); p.add_argument("--reset", action="store_true", help="drop the earlier load first (fresh dump before cutover)"); p.set_defaults(fn=cmd_supabase_load) p = sub.add_parser("supabase-storage"); p.add_argument("site"); p.add_argument("--concurrency", type=int, default=8); p.set_defaults(fn=cmd_supabase_storage) p = sub.add_parser("retire"); p.add_argument("site"); p.add_argument("--yes", action="store_true"); p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_retire) args = ap.parse_args() diff --git a/dev2/templates/supabase-load.sh b/dev2/templates/supabase-load.sh index 826a200..62469ba 100755 --- a/dev2/templates/supabase-load.sh +++ b/dev2/templates/supabase-load.sh @@ -6,6 +6,10 @@ set -euo pipefail : "${DUMP:?}" "${DBC:?}" "${CLOUD_REF:?}" "${NEW_HOST:?}" POST_ONLY=${POST_ONLY:-0} +# RESET=1 drops the app schemas (public and any other non-system schema the dump carries) plus +# the auth/storage ROWS before loading, so a fresh dump can replace an earlier load without +# duplicate-key errors. The stack's own auth/storage structure is untouched. +RESET=${RESET:-0} log() { printf '\n===> %s\n' "$*" >&2; } die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } [ -d "$DUMP" ] || die "no dump at $DUMP" @@ -17,6 +21,15 @@ docker exec "$DBC" pg_isready -U postgres -h localhost >/dev/null || die "$DBC n log "Snapshot BEFORE" strict -At -c "select 'tables='||(select count(*) from information_schema.tables where table_schema='public')||' users='||(select count(*) from auth.users)" +if [ "$POST_ONLY" = 0 ] && [ "$RESET" = 1 ]; then + log "RESET: dropping app schemas and auth/storage rows from the earlier load" + while read -r sch; do [ -n "$sch" ] || continue + psql_admin -v ON_ERROR_STOP=1 -c "drop schema if exists \"$sch\" cascade; create schema \"$sch\"; grant usage on schema \"$sch\" to anon, authenticated, service_role; grant all on schema \"$sch\" to postgres" >/dev/null && echo " dropped+recreated schema $sch" + done < "$DUMP/schemas.txt" + psql_admin -v ON_ERROR_STOP=1 -c "truncate auth.users cascade" -c "truncate storage.buckets cascade" >/dev/null && echo " auth.users / storage.buckets truncated" + psql_admin -c "select cron.unschedule(jobname) from cron.job" >/dev/null 2>&1 || true +fi + if [ "$POST_ONLY" = 0 ]; then log "Extensions the cloud had" while read -r e; do [ -n "$e" ] || continue; case $e in plpgsql|pg_graphql|pgsodium|supabase_vault|pg_stat_statements|pgjwt) continue;; esac From 9d32dc776aff579e54ae4059d5c2e4daaf9a3570 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:07:44 +0000 Subject: [PATCH 060/104] dev2-site: sites.d skip flag; community gardens stays on Railway Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 2 ++ dev2/sites.d/communitygardensasappleseeds.info.json | 4 ++++ dev2/sites.d/sh1pt.com.json | 13 +++++++++++++ dev2/sites.d/smshub.dev.json | 9 +++++++++ dev2/sites.d/ugig.net.json | 9 +++++++++ 5 files changed, 37 insertions(+) create mode 100644 dev2/sites.d/communitygardensasappleseeds.info.json create mode 100644 dev2/sites.d/sh1pt.com.json create mode 100644 dev2/sites.d/smshub.dev.json create mode 100644 dev2/sites.d/ugig.net.json diff --git a/dev2/dev2-site b/dev2/dev2-site index 4e38778..f11b668 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -1129,6 +1129,7 @@ def cmd_merge(args): def cmd_migrate(args): s = site(args.site); st = load_state(s["site"]) + if s.get("skip"): die(f"{s['site']} is marked skip in sites.d: {s.get('notes', '')}") steps = [("scaffold", cmd_scaffold), ("db", cmd_db) if s["db"] == "pg" else None, ("provision", cmd_provision), ("volumes", cmd_volumes), ("deploy", cmd_deploy), ("cert", cmd_cert), ("vhost", cmd_vhost), ("verify", cmd_verify), ("refresh-db", cmd_refresh_db) if s["db"] == "pg" else None, ("dns", cmd_dns), @@ -1165,6 +1166,7 @@ def cmd_box_tune(args): def cmd_retire(args): s = site(args.site); st = load_state(s["site"]) + if s.get("skip"): die(f"{s['site']} is marked skip in sites.d: {s.get('notes', '')}") if "verify-public" not in st["steps"] and not args.force: die("site not verified public on dev2; refuse to delete Railway services (or --force)") if not args.yes: die("--yes required") targets = [{"name": s["service"], "id": s["service_id"]}] + s["companions"] + s["infra"] diff --git a/dev2/sites.d/communitygardensasappleseeds.info.json b/dev2/sites.d/communitygardensasappleseeds.info.json new file mode 100644 index 0000000..618b5fc --- /dev/null +++ b/dev2/sites.d/communitygardensasappleseeds.info.json @@ -0,0 +1,4 @@ +{ + "skip": true, + "notes": "Anthony 2026-09-25: stays on Railway (DNS at Cloudflare). Never retire, never migrate." +} diff --git a/dev2/sites.d/sh1pt.com.json b/dev2/sites.d/sh1pt.com.json new file mode 100644 index 0000000..293362a --- /dev/null +++ b/dev2/sites.d/sh1pt.com.json @@ -0,0 +1,13 @@ +{ + "proxies": [ + { + "host": "supabase.sh1pt.com", + "port": 8244, + "max_body": "512m" + } + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY" + } +} diff --git a/dev2/sites.d/smshub.dev.json b/dev2/sites.d/smshub.dev.json new file mode 100644 index 0000000..d223e5b --- /dev/null +++ b/dev2/sites.d/smshub.dev.json @@ -0,0 +1,9 @@ +{ + "proxies": [ + { + "host": "supabase.smshub.dev", + "port": 8256, + "max_body": "512m" + } + ] +} diff --git a/dev2/sites.d/ugig.net.json b/dev2/sites.d/ugig.net.json new file mode 100644 index 0000000..d4b237c --- /dev/null +++ b/dev2/sites.d/ugig.net.json @@ -0,0 +1,9 @@ +{ + "proxies": [ + { + "host": "supabase.ugig.net", + "port": 8249, + "max_body": "512m" + } + ] +} From e08613348aab8a8b475ac9257ba510f7e91e2690 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:08:51 +0000 Subject: [PATCH 061/104] dev2-site cert: reuse a fresh certificate only when it covers every name Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 6 +++++- dev2/sites.d/smshub.dev.json | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index f11b668..7c0ee4e 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -1007,8 +1007,12 @@ def cmd_cert(args): # A certificate acme.sh issued in the last day is reused (a retry after a dropped # ssh session must not burn Let's Encrypt's duplicate-certificate quota). fresh = ssh("root", f"find /root/.acme.sh/{shlex.quote(domains[0])}_ecc -name fullchain.cer -mmin -1440 2>/dev/null | head -n1", check=False).strip() + if fresh: # only if it covers every name we need now (a stack adds supabase. later in the day) + sans = ssh("root", f"openssl x509 -in {shlex.quote(fresh)} -noout -ext subjectAltName 2>/dev/null | tr ',' '\\n' | sed -n 's/.*DNS://p'", check=False).split() + missing = [d for d in domains if d not in sans] + if missing: note(f"fresh certificate lacks {', '.join(missing)}; issuing a new one"); fresh = "" if fresh: - note("certificate issued within the last day; installing it") + note("certificate issued within the last day covers every name; installing it") else: out = ssh("root", f"install -d -m 0750 {cert_dir} && env HOME=/root PORKBUN_API_KEY={shlex.quote(k)} PORKBUN_SECRET_API_KEY={shlex.quote(sk)} /root/.acme.sh/acme.sh --issue --server letsencrypt --dns dns_porkbun {ds} --force >/tmp/acme-{s['site']}.log 2>&1 || {{ tail -n 25 /tmp/acme-{s['site']}.log; exit 1; }}", check=False, timeout=1800) if out.strip() and "Cert success" not in out: die(out) diff --git a/dev2/sites.d/smshub.dev.json b/dev2/sites.d/smshub.dev.json index d223e5b..18eaabb 100644 --- a/dev2/sites.d/smshub.dev.json +++ b/dev2/sites.d/smshub.dev.json @@ -5,5 +5,9 @@ "port": 8256, "max_body": "512m" } - ] + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY" + } } From a55a52dd3f4c9cd6215fee2023c7b26fa2eaabea Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:15:03 +0000 Subject: [PATCH 062/104] dev2: ugig.net onto its own self-hosted Supabase stack Gateway supabase.ugig.net -> :8249; the app's URL, anon/service keys and db password re-pointed (keys as secret: references, values in credentials.json). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/ugig.net.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dev2/sites.d/ugig.net.json b/dev2/sites.d/ugig.net.json index d4b237c..964c788 100644 --- a/dev2/sites.d/ugig.net.json +++ b/dev2/sites.d/ugig.net.json @@ -5,5 +5,11 @@ "port": 8249, "max_body": "512m" } - ] + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.ugig.net", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY" + } } From 5506d7e1e8a85d008a13d83daa3f58eb9b9c93f2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:17:31 +0000 Subject: [PATCH 063/104] dev2: sh1pt.com off Supabase cloud onto its own self-hosted stack supabase.sh1pt.com gateway proxy, NEXT_PUBLIC_SUPABASE_URL re-pointed, keys as secret references (the app carries sb_publishable/sb_secret keys the cutover cannot match by value). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/sh1pt.com.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev2/sites.d/sh1pt.com.json b/dev2/sites.d/sh1pt.com.json index 293362a..6534fa5 100644 --- a/dev2/sites.d/sh1pt.com.json +++ b/dev2/sites.d/sh1pt.com.json @@ -8,6 +8,7 @@ ], "env_overrides": { "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", - "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY" + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.sh1pt.com" } } From ba3d64ed5bb9243f0276a1b5c0905320b1e7c429 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:19:28 +0000 Subject: [PATCH 064/104] dev2: smshub.dev off Supabase cloud onto its own self-hosted stack supabase.smshub.dev gateway proxy, NEXT_PUBLIC_SUPABASE_URL re-pointed, keys as secret references. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/smshub.dev.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev2/sites.d/smshub.dev.json b/dev2/sites.d/smshub.dev.json index 18eaabb..1494586 100644 --- a/dev2/sites.d/smshub.dev.json +++ b/dev2/sites.d/smshub.dev.json @@ -8,6 +8,7 @@ ], "env_overrides": { "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", - "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY" + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.smshub.dev" } } From 25ae321c444bd0cd3428526b30840c93d6c1379e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:23:34 +0000 Subject: [PATCH 065/104] dev2: b1dz.com onto its own self-hosted Supabase stack Gateway supabase.b1dz.com -> :8240; URL, publishable/secret keys and db password re-pointed (keys as secret: references, values in credentials.json). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/b1dz.com.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 dev2/sites.d/b1dz.com.json diff --git a/dev2/sites.d/b1dz.com.json b/dev2/sites.d/b1dz.com.json new file mode 100644 index 0000000..94860cb --- /dev/null +++ b/dev2/sites.d/b1dz.com.json @@ -0,0 +1,15 @@ +{ + "proxies": [ + { + "host": "supabase.b1dz.com", + "port": 8240, + "max_body": "512m" + } + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY": "secret:NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", + "SUPABASE_SECRET_KEY": "secret:SUPABASE_SECRET_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.b1dz.com", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD" + } +} From 0c2a64442807a3ffc9118196bba14207f48064ce Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:25:25 +0000 Subject: [PATCH 066/104] sites.d: keep api.smshub.dev as an alias of the smshub Supabase gateway Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/smshub.dev.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dev2/sites.d/smshub.dev.json b/dev2/sites.d/smshub.dev.json index 1494586..c35cad3 100644 --- a/dev2/sites.d/smshub.dev.json +++ b/dev2/sites.d/smshub.dev.json @@ -4,11 +4,17 @@ "host": "supabase.smshub.dev", "port": 8256, "max_body": "512m" + }, + { + "host": "api.smshub.dev", + "port": 8256, + "max_body": "512m" } ], "env_overrides": { "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.smshub.dev" - } + }, + "notes": "api.smshub.dev was the Railway-hosted Supabase gateway name; kept as an alias of supabase.smshub.dev so old clients keep working." } From bb8d1c277a266a990ce3a0eaf5e073183c55f73b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:25:38 +0000 Subject: [PATCH 067/104] dev2: marksyncr.com off Supabase cloud onto its own self-hosted stack supabase.marksyncr.com gateway proxy; NEXT_PUBLIC_SUPABASE_URL re-pointed, SUPABASE_JWT_SECRET and the keys as secret references. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/marksyncr.com.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 dev2/sites.d/marksyncr.com.json diff --git a/dev2/sites.d/marksyncr.com.json b/dev2/sites.d/marksyncr.com.json new file mode 100644 index 0000000..d402f86 --- /dev/null +++ b/dev2/sites.d/marksyncr.com.json @@ -0,0 +1,15 @@ +{ + "proxies": [ + { + "host": "supabase.marksyncr.com", + "port": 8260, + "max_body": "512m" + } + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.marksyncr.com", + "SUPABASE_JWT_SECRET": "secret:SUPABASE_JWT_SECRET" + } +} From 38e43a9c54f0a73c559fb398e4e413b239200e97 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:29:55 +0000 Subject: [PATCH 068/104] dev2: qaaas.dev onto its own self-hosted Supabase stack Gateway supabase.qaaas.dev -> :8266; both URLs, anon/service keys and db password re-pointed (keys as secret: references, values in credentials.json). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/qaaas.dev.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 dev2/sites.d/qaaas.dev.json diff --git a/dev2/sites.d/qaaas.dev.json b/dev2/sites.d/qaaas.dev.json new file mode 100644 index 0000000..42f7521 --- /dev/null +++ b/dev2/sites.d/qaaas.dev.json @@ -0,0 +1,17 @@ +{ + "proxies": [ + { + "host": "supabase.qaaas.dev", + "port": 8266, + "max_body": "512m" + } + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_ANON_KEY": "secret:SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", + "SUPABASE_URL": "https://supabase.qaaas.dev", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.qaaas.dev" + } +} From 52c77ca290c4e901242be15933ed39f5882cf123 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:38:36 +0000 Subject: [PATCH 069/104] dev2: qrypt.chat onto its own self-hosted Supabase stack Gateway supabase.qrypt.chat -> :8239; both URL pairs, anon/service keys, JWT discovery URL and db password re-pointed (secrets as secret: references). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/qrypt.chat.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 dev2/sites.d/qrypt.chat.json diff --git a/dev2/sites.d/qrypt.chat.json b/dev2/sites.d/qrypt.chat.json new file mode 100644 index 0000000..44eea61 --- /dev/null +++ b/dev2/sites.d/qrypt.chat.json @@ -0,0 +1,18 @@ +{ + "proxies": [ + { + "host": "supabase.qrypt.chat", + "port": 8239, + "max_body": "512m" + } + ], + "env_overrides": { + "PUBLIC_SUPABASE_URL": "https://supabase.qrypt.chat", + "PUBLIC_SUPABASE_ANON_KEY": "secret:PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", + "SUPABASE_JWT_DISCOVERY_URL": "https://supabase.qrypt.chat/auth/v1/.well-known/jwks.json", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.qrypt.chat" + } +} From d94e6a23522c89625cbc8b111b52e1925b84f63f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:43:05 +0000 Subject: [PATCH 070/104] dev2: icemap.app off Supabase cloud onto its own self-hosted stack supabase.icemap.app gateway proxy; SUPABASE_URL re-pointed, the service key as a secret reference; extensions: postgis so the stack step installs it (the load's own extension loop never reaches it). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/icemap.app.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 dev2/sites.d/icemap.app.json diff --git a/dev2/sites.d/icemap.app.json b/dev2/sites.d/icemap.app.json new file mode 100644 index 0000000..ca93013 --- /dev/null +++ b/dev2/sites.d/icemap.app.json @@ -0,0 +1,16 @@ +{ + "proxies": [ + { + "host": "supabase.icemap.app", + "port": 8262, + "max_body": "512m" + } + ], + "env_overrides": { + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "SUPABASE_URL": "https://supabase.icemap.app" + }, + "extensions": [ + "postgis" + ] +} From 2acf153984cc4c852d9feb5e8f6799fafe185e4b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:52:21 +0000 Subject: [PATCH 071/104] dev2: d0rz.com off Supabase cloud onto its own self-hosted stack supabase.d0rz.com gateway proxy; NEXT_PUBLIC_SUPABASE_URL and SUPABASE_URL re-pointed, keys as secret references. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/d0rz.com.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 dev2/sites.d/d0rz.com.json diff --git a/dev2/sites.d/d0rz.com.json b/dev2/sites.d/d0rz.com.json new file mode 100644 index 0000000..c9acc2f --- /dev/null +++ b/dev2/sites.d/d0rz.com.json @@ -0,0 +1,15 @@ +{ + "proxies": [ + { + "host": "supabase.d0rz.com", + "port": 8228, + "max_body": "512m" + } + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.d0rz.com", + "SUPABASE_URL": "https://supabase.d0rz.com" + } +} From 4fddf03dd636f41ef40eeca21b91a353e54a9359 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 14:57:33 +0000 Subject: [PATCH 072/104] dev2: saasrow.com self-hosted Supabase gateway (stack up, app NOT cut over) The app's backend is 36 Edge Functions called at SUPABASE_URL; none are ported, so the cutover was rolled back and the app stays on the cloud project. Only the supabase.saasrow.com proxy entry is kept. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/saasrow.com.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 dev2/sites.d/saasrow.com.json diff --git a/dev2/sites.d/saasrow.com.json b/dev2/sites.d/saasrow.com.json new file mode 100644 index 0000000..17cecb9 --- /dev/null +++ b/dev2/sites.d/saasrow.com.json @@ -0,0 +1,9 @@ +{ + "proxies": [ + { + "host": "supabase.saasrow.com", + "port": 8276, + "max_body": "512m" + } + ] +} From 262c37433c8f8791a14f07affc1a8d63457f5917 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:02:21 +0000 Subject: [PATCH 073/104] dev2: postammo.com off Supabase cloud onto its own self-hosted stack supabase.postammo.com gateway proxy; SUPABASE_URL and SUPABASE_JWT_JWKS_URL re-pointed, the db password and service key as secret references. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/postammo.com.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 dev2/sites.d/postammo.com.json diff --git a/dev2/sites.d/postammo.com.json b/dev2/sites.d/postammo.com.json new file mode 100644 index 0000000..86531e7 --- /dev/null +++ b/dev2/sites.d/postammo.com.json @@ -0,0 +1,15 @@ +{ + "proxies": [ + { + "host": "supabase.postammo.com", + "port": 8246, + "max_body": "512m" + } + ], + "env_overrides": { + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", + "SUPABASE_URL": "https://supabase.postammo.com", + "SUPABASE_JWT_JWKS_URL": "https://supabase.postammo.com/auth/v1/.well-known/jwks.json" + } +} From cc5c698fd910de7ee9e5c8b46da600c8b386c165 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:06:37 +0000 Subject: [PATCH 074/104] dev2: logicsrc.com onto its own self-hosted Supabase stack Gateway supabase.logicsrc.com -> :8272; SUPABASE_URL, publishable/secret keys and db password re-pointed (keys as secret: references). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/logicsrc.com.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 dev2/sites.d/logicsrc.com.json diff --git a/dev2/sites.d/logicsrc.com.json b/dev2/sites.d/logicsrc.com.json new file mode 100644 index 0000000..358131a --- /dev/null +++ b/dev2/sites.d/logicsrc.com.json @@ -0,0 +1,16 @@ +{ + "proxies": [ + { + "host": "supabase.logicsrc.com", + "port": 8272, + "max_body": "512m" + } + ], + "env_overrides": { + "SUPABASE_ANON_KEY": "secret:SUPABASE_ANON_KEY", + "SUPABASE_PUBLISHABLE_KEY": "secret:SUPABASE_PUBLISHABLE_KEY", + "SUPABASE_SECRET_KEY": "secret:SUPABASE_SECRET_KEY", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", + "SUPABASE_URL": "https://supabase.logicsrc.com" + } +} From 28b576d5ff4503ed303bc43a8b7f79bef43569f1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:10:34 +0000 Subject: [PATCH 075/104] dev2: weedforcrypto.com off Supabase cloud onto its own self-hosted stack supabase.weedforcrypto.com gateway proxy; NEXT_PUBLIC_SUPABASE_URL re-pointed, the db password and all four key variables as secret references. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/weedforcrypto.com.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 dev2/sites.d/weedforcrypto.com.json diff --git a/dev2/sites.d/weedforcrypto.com.json b/dev2/sites.d/weedforcrypto.com.json new file mode 100644 index 0000000..7af8eb2 --- /dev/null +++ b/dev2/sites.d/weedforcrypto.com.json @@ -0,0 +1,17 @@ +{ + "proxies": [ + { + "host": "supabase.weedforcrypto.com", + "port": 8243, + "max_body": "512m" + } + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY": "secret:NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", + "SUPABASE_SECRET_KEY": "secret:SUPABASE_SECRET_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.weedforcrypto.com" + } +} From 3c15d0bdaff8cfa432773ce9a6805e0e528d8e48 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:12:28 +0000 Subject: [PATCH 076/104] dev2: infernetprotocol.com onto its own self-hosted Supabase stack Gateway supabase.infernetprotocol.com -> :8227; both URLs, DATABASE_URL and the publishable/secret keys re-pointed (secrets as secret: references). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/infernetprotocol.com.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 dev2/sites.d/infernetprotocol.com.json diff --git a/dev2/sites.d/infernetprotocol.com.json b/dev2/sites.d/infernetprotocol.com.json new file mode 100644 index 0000000..a6ca493 --- /dev/null +++ b/dev2/sites.d/infernetprotocol.com.json @@ -0,0 +1,16 @@ +{ + "proxies": [ + { + "host": "supabase.infernetprotocol.com", + "port": 8227, + "max_body": "512m" + } + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "DATABASE_URL": "secret:DATABASE_URL", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.infernetprotocol.com", + "SUPABASE_URL": "https://supabase.infernetprotocol.com" + } +} From 4186565e4f5633bcc56e106cb6adca4df796a28c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:21:56 +0000 Subject: [PATCH 077/104] dev2: phonenumbers.bot self-hosted Supabase stack staged (cutover after PR #10) supabase.phonenumbers.bot gateway proxy; SUPABASE_SECRET_KEY as a secret reference and SUPABASE_URL staged for the app change in profullstack/phonenumbers.bot#10 (the app still derives its URL from SUPABASE_DB_REF on master). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/phonenumbers.bot.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 dev2/sites.d/phonenumbers.bot.json diff --git a/dev2/sites.d/phonenumbers.bot.json b/dev2/sites.d/phonenumbers.bot.json new file mode 100644 index 0000000..fbc15cd --- /dev/null +++ b/dev2/sites.d/phonenumbers.bot.json @@ -0,0 +1,13 @@ +{ + "proxies": [ + { + "host": "supabase.phonenumbers.bot", + "port": 8268, + "max_body": "512m" + } + ], + "env_overrides": { + "SUPABASE_SECRET_KEY": "secret:SUPABASE_SECRET_KEY", + "SUPABASE_URL": "https://supabase.phonenumbers.bot" + } +} From cbd788a494a8c35ff13d074a4f20ffc2b7a4e973 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:23:18 +0000 Subject: [PATCH 078/104] dev2: pairux.com onto its own self-hosted Supabase stack Gateway supabase.pairux.com -> :8274; URL, publishable/secret keys, db password and the LiveKit recording S3 endpoint/credentials re-pointed at the stack's own S3 protocol (secrets as secret: references). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/pairux.com.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 dev2/sites.d/pairux.com.json diff --git a/dev2/sites.d/pairux.com.json b/dev2/sites.d/pairux.com.json new file mode 100644 index 0000000..d52b5f3 --- /dev/null +++ b/dev2/sites.d/pairux.com.json @@ -0,0 +1,19 @@ +{ + "proxies": [ + { + "host": "supabase.pairux.com", + "port": 8274, + "max_body": "512m" + } + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "SUPABASE_S3_ACCESS_KEY": "secret:SUPABASE_S3_ACCESS_KEY", + "SUPABASE_S3_SECRET_KEY": "secret:SUPABASE_S3_SECRET_KEY", + "SUPABASE_S3_ENDPOINT": "https://supabase.pairux.com/storage/v1/s3", + "SUPABASE_S3_REGION": "stub", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.pairux.com", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD" + } +} From d02f59c95b6595eca60a82bd20eefa41806e4c1e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:24:30 +0000 Subject: [PATCH 079/104] dev2: installer.pairux.com onto pairux.com's self-hosted Supabase stack Shares the cloud project with pairux.com, so it shares the dev2 stack too: URL, publishable/secret keys and db password re-pointed at supabase.pairux.com (secrets as secret: references). Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/installer.pairux.com.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dev2/sites.d/installer.pairux.com.json diff --git a/dev2/sites.d/installer.pairux.com.json b/dev2/sites.d/installer.pairux.com.json new file mode 100644 index 0000000..f1d2f2f --- /dev/null +++ b/dev2/sites.d/installer.pairux.com.json @@ -0,0 +1,8 @@ +{ + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.pairux.com", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD" + } +} From a5d9536d5a8e7032392b4fd1884e96e39c504bbc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:25:16 +0000 Subject: [PATCH 080/104] dev2: bl0ggers.com off Supabase cloud onto its own self-hosted stack supabase.bl0ggers.com gateway proxy (the wildcard already routes it); NEXT_PUBLIC_SUPABASE_URL and SUPABASE_URL re-pointed, the db password and the three key variables as secret references. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/bl0ggers.com.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 dev2/sites.d/bl0ggers.com.json diff --git a/dev2/sites.d/bl0ggers.com.json b/dev2/sites.d/bl0ggers.com.json new file mode 100644 index 0000000..2d1e080 --- /dev/null +++ b/dev2/sites.d/bl0ggers.com.json @@ -0,0 +1,17 @@ +{ + "proxies": [ + { + "host": "supabase.bl0ggers.com", + "port": 8257, + "max_body": "512m" + } + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_ANON_KEY": "secret:SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.bl0ggers.com", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", + "SUPABASE_URL": "https://supabase.bl0ggers.com" + } +} From 3f408e8c7613c65ee8ccc0a8399d2fd25062fec4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:28:47 +0000 Subject: [PATCH 081/104] dev2: brisk.news onto its own self-hosted Supabase stack Gateway supabase.brisk.news -> :8238; URL and publishable/secret keys re-pointed (keys as secret: references). Loaded from a fresh dump with supabase-load --reset right before the cutover. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/brisk.news.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dev2/sites.d/brisk.news.json b/dev2/sites.d/brisk.news.json index 4fe726e..11b2a67 100644 --- a/dev2/sites.d/brisk.news.json +++ b/dev2/sites.d/brisk.news.json @@ -5,5 +5,10 @@ "port": 8238, "max_body": "512m" } - ] + ], + "env_overrides": { + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.brisk.news" + } } From 03cb3d6f115b1b431defd833e4e9850c0bf7d4a1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:30:24 +0000 Subject: [PATCH 082/104] supabase-load/pull: count psql-prefixed errors, stop docker exec eating loop input, grant functions by signature - load: 'psql -f /dev/stdin' prefixes messages with psql:/dev/stdin:N:, so the error counts were always 0; count and list them properly. - load: the schemas/extensions/buckets loops read from fd 3 -- the docker exec -i in the body swallowed the rest of stdin, so only the first entry ran (PostGIS was never created for icemap). - pull: function grants were emitted without 'function' and without the signature, so all of them failed; emit them from pg_proc with regprocedure, and revoke PUBLIC where the cloud had done so. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/phonenumbers.bot.json | 4 +++- dev2/templates/supabase-load.sh | 21 ++++++++++++--------- dev2/templates/supabase-pull.sh | 5 ++++- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/dev2/sites.d/phonenumbers.bot.json b/dev2/sites.d/phonenumbers.bot.json index fbc15cd..5768e42 100644 --- a/dev2/sites.d/phonenumbers.bot.json +++ b/dev2/sites.d/phonenumbers.bot.json @@ -8,6 +8,8 @@ ], "env_overrides": { "SUPABASE_SECRET_KEY": "secret:SUPABASE_SECRET_KEY", - "SUPABASE_URL": "https://supabase.phonenumbers.bot" + "SUPABASE_URL": "https://supabase.phonenumbers.bot", + "SUPABAsE_URL": "https://supabase.phonenumbers.bot", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD" } } diff --git a/dev2/templates/supabase-load.sh b/dev2/templates/supabase-load.sh index 62469ba..d16680b 100755 --- a/dev2/templates/supabase-load.sh +++ b/dev2/templates/supabase-load.sh @@ -16,6 +16,9 @@ die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } psql_db() { docker exec -i "$DBC" psql -U postgres -h localhost -d postgres -X "$@"; } psql_admin() { docker exec -i "$DBC" psql -U supabase_admin -h localhost -d postgres -X "$@"; } strict() { psql_db -v ON_ERROR_STOP=1 "$@"; } +# psql -f /dev/stdin prefixes every message with "psql:/dev/stdin:N: ", so match ERROR after that too. +nerr() { grep -cE '^(psql:[^ ]*:[0-9]+: )?ERROR' "$1" 2>/dev/null || true; } +lerr() { grep -E '^(psql:[^ ]*:[0-9]+: )?ERROR' "$1" 2>/dev/null | sed -E 's/^psql:[^ ]*:[0-9]+: //' | sort | uniq -c | sort -rn | head -15 | sed 's/^/ /' || true; } docker exec "$DBC" pg_isready -U postgres -h localhost >/dev/null || die "$DBC not ready" log "Snapshot BEFORE" @@ -23,29 +26,29 @@ strict -At -c "select 'tables='||(select count(*) from information_schema.tables if [ "$POST_ONLY" = 0 ] && [ "$RESET" = 1 ]; then log "RESET: dropping app schemas and auth/storage rows from the earlier load" - while read -r sch; do [ -n "$sch" ] || continue + while read -r sch <&3; do [ -n "$sch" ] || continue psql_admin -v ON_ERROR_STOP=1 -c "drop schema if exists \"$sch\" cascade; create schema \"$sch\"; grant usage on schema \"$sch\" to anon, authenticated, service_role; grant all on schema \"$sch\" to postgres" >/dev/null && echo " dropped+recreated schema $sch" - done < "$DUMP/schemas.txt" + done 3< "$DUMP/schemas.txt" psql_admin -v ON_ERROR_STOP=1 -c "truncate auth.users cascade" -c "truncate storage.buckets cascade" >/dev/null && echo " auth.users / storage.buckets truncated" psql_admin -c "select cron.unschedule(jobname) from cron.job" >/dev/null 2>&1 || true fi if [ "$POST_ONLY" = 0 ]; then log "Extensions the cloud had" - while read -r e; do [ -n "$e" ] || continue; case $e in plpgsql|pg_graphql|pgsodium|supabase_vault|pg_stat_statements|pgjwt) continue;; esac - psql_admin -v ON_ERROR_STOP=1 -c "create extension if not exists \"$e\" cascade" >/dev/null 2>&1 && echo " $e" || echo " $e: NOT AVAILABLE (dump may fail on objects using it)"; done < "$DUMP/extensions.txt" + while read -r e <&3; do [ -n "$e" ] || continue; case $e in plpgsql|pg_graphql|pgsodium|supabase_vault|pg_stat_statements|pgjwt) continue;; esac + psql_admin -v ON_ERROR_STOP=1 -c "create extension if not exists \"$e\" cascade" >/dev/null 2>&1 && echo " $e" || echo " $e: NOT AVAILABLE (dump may fail on objects using it)"; done 3< "$DUMP/extensions.txt" log "Schema" grep -qE 'CREATE SCHEMA "?(auth|storage)"?' "$DUMP/schema.sql" && die "schema.sql contains auth/storage DDL" psql_admin -f /dev/stdin < "$DUMP/schema.sql" > "$DUMP/schema.load.log" 2>&1 || true - echo " schema errors: $(grep -c '^ERROR' "$DUMP/schema.load.log" || true)"; grep '^ERROR' "$DUMP/schema.load.log" | sort | uniq -c | sort -rn | head -15 | sed 's/^/ /' || true + echo " schema errors: $(nerr "$DUMP/schema.load.log")"; lerr "$DUMP/schema.load.log" log "Data (auth before app schemas)" a=$(grep -m1 -n 'COPY "auth"' "$DUMP/data.sql" | cut -d: -f1 || echo 0); p=$(grep -m1 -n 'COPY "public"' "$DUMP/data.sql" | cut -d: -f1 || echo 0); a=${a:-0}; p=${p:-0} if [ "$a" -gt 0 ] && [ "$p" -gt 0 ] && [ "$a" -gt "$p" ]; then die "data.sql has public before auth"; fi psql_admin -f /dev/stdin < "$DUMP/data.sql" > "$DUMP/data.load.log" 2>&1 || true - echo " data errors: $(grep -c '^ERROR' "$DUMP/data.load.log" || true)"; grep '^ERROR' "$DUMP/data.load.log" | sort | uniq -c | sort -rn | head -15 | sed 's/^/ /' || true + echo " data errors: $(nerr "$DUMP/data.load.log")"; lerr "$DUMP/data.load.log" log "Grants for anon/authenticated/service_role" psql_admin -f /dev/stdin < "$DUMP/grants.sql" > "$DUMP/grants.load.log" 2>&1 || true - echo " grant errors: $(grep -c '^ERROR' "$DUMP/grants.load.log" || true)" + echo " grant errors: $(nerr "$DUMP/grants.load.log")"; lerr "$DUMP/grants.load.log" log "Ownership: app schemas to postgres (they were created by supabase_admin)" psql_admin -At -v ON_ERROR_STOP=1 <<'SQL' do $$ declare r record; begin @@ -60,8 +63,8 @@ SQL fi log "Buckets" -while IFS=$'\t' read -r id name pub limit mimes; do [ -n "$id" ] || continue; case "$pub" in t|true) ps=true;; *) ps=false;; esac - strict -c "insert into storage.buckets (id, name, public) values ('$id','$name',$ps) on conflict (id) do update set public=excluded.public" >/dev/null; done < "$DUMP/buckets.tsv" +while IFS=$'\t' read -r id name pub limit mimes <&3; do [ -n "$id" ] || continue; case "$pub" in t|true) ps=true;; *) ps=false;; esac + strict -c "insert into storage.buckets (id, name, public) values ('$id','$name',$ps) on conflict (id) do update set public=excluded.public" >/dev/null; done 3< "$DUMP/buckets.tsv" log "Rewriting absolute https://$CLOUD_REF.supabase.co URLs to https://$NEW_HOST in every text/json column" strict -At < "$OUT/grants.sql" -psqlc -c "select 'grant '||privilege_type||' on '||quote_ident(routine_schema)||'.'||quote_ident(routine_name)||' to '||quote_ident(grantee)||';' from information_schema.role_routine_grants where grantee in ('anon','authenticated','service_role') and routine_schema not in ($SYS) order by 1" >> "$OUT/grants.sql" || true +# Functions: "grant execute on function schema.name(argtypes)" -- routine_name alone is a relation to psql, so every +# grant failed before. A function with no PUBLIC execute on the cloud was locked down on purpose: revoke PUBLIC there too. +psqlc -c "select 'revoke execute on function '||p.oid::regprocedure::text||' from public;' from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname not in ($SYS) and n.nspname not like 'pg_%' and p.prokind in ('f','p') and p.proacl is not null and not exists (select 1 from aclexplode(p.proacl) a where a.grantee=0 and a.privilege_type='EXECUTE') order by 1" >> "$OUT/grants.sql" || true +psqlc -c "select 'grant execute on function '||p.oid::regprocedure::text||' to '||quote_ident(r.rolname)||';' from pg_proc p join pg_namespace n on n.oid=p.pronamespace join aclexplode(p.proacl) a on true join pg_roles r on r.oid=a.grantee where n.nspname not in ($SYS) and n.nspname not like 'pg_%' and p.prokind in ('f','p') and a.privilege_type='EXECUTE' and r.rolname in ('anon','authenticated','service_role') order by 1" >> "$OUT/grants.sql" || true psqlc -c "select 'grant usage, select on all sequences in schema '||quote_ident(nspname)||' to anon, authenticated, service_role;' from pg_namespace where nspname not in ($SYS) and nspname not like 'pg_%'" >> "$OUT/grants.sql" log "pg_cron jobs" From 0cea1ee429f03620fdf004da60e3147a6dd5723b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:42:50 +0000 Subject: [PATCH 083/104] dev2: coinpayportal.com self-hosted Supabase gateway (stack up, app NOT cut over) supabase.coinpayportal.com -> :8273. The load lost public.payments (3405 rows) to a NOT VALID check constraint the dump re-creates as enforced, so the cutover is on hold until supabase-load handles NOT VALID constraints. Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/coinpayportal.com.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/dev2/sites.d/coinpayportal.com.json b/dev2/sites.d/coinpayportal.com.json index 8114183..82af887 100644 --- a/dev2/sites.d/coinpayportal.com.json +++ b/dev2/sites.d/coinpayportal.com.json @@ -1 +1,10 @@ -{ "health_path": "/api/health" } +{ + "health_path": "/api/health", + "proxies": [ + { + "host": "supabase.coinpayportal.com", + "port": 8273, + "max_body": "512m" + } + ] +} From 0148c32cf646dff858609c0353938f0c8ceceb5a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:50:11 +0000 Subject: [PATCH 084/104] dev2-site supabase: new-style keys, Edge Functions step, NOT VALID constraints, vault, S3 host, bucket limits - cutover maps sb_publishable_/sb_secret_ keys onto the stack's anon/service JWTs (they were never re-pointed before), rewrites .storage.supabase.co and swaps in the stack's S3 protocol credentials, and refuses to run while the cloud project still has Edge Functions the stack lacks. - new supabase-functions: ports supabase/functions/* from the checkout onto the stack's edge runtime, copies the cloud function secrets into volumes/functions/secrets.env (env_file on the functions service), probes one. - pull dumps NOT VALID check/fk constraints; load drops them before COPY and re-adds them NOT VALID after (coinpayportal's payments table failed on one). - pull dumps vault secrets, load recreates them; buckets keep file_size_limit and allowed_mime_types; storage FILE_SIZE_LIMIT raised to 5 GiB. - cron commands get the cloud URL and API keys rewritten before scheduling; the cloud cron probe no longer parses cron.job when pg_cron is absent. - transient auth.one_time_tokens / auth.scim_* excluded from the data dump. - stack no longer chowns an existing site root to root (CI deploys broke). Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 69 +++++++++++++++++++++++++++++--- dev2/templates/supabase-load.sh | 21 +++++++++- dev2/templates/supabase-pull.sh | 11 ++++- dev2/templates/supabase-stack.sh | 6 ++- 4 files changed, 97 insertions(+), 10 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index 7c0ee4e..1eae436 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -1217,10 +1217,14 @@ def sb_cloud(s, st): pooler = sb_api(f"projects/{ref}/config/database/pooler") prim = next((p for p in pooler if p.get("database_type") == "PRIMARY"), pooler[0]) host = prim["db_host"] - keys = {k["id"]: k["api_key"] for k in sb_api(f"projects/{ref}/api-keys?reveal=true") if k.get("type") == "legacy"} + allk = sb_api(f"projects/{ref}/api-keys?reveal=true") + keys = {k["id"]: k["api_key"] for k in allk if k.get("type") == "legacy"} import urllib.parse as up url = f"postgres://postgres.{ref}:{up.quote(pw, safe='')}@{host}:5432/postgres" - st.update({"cloud_ref": ref, "cloud_db_password": pw, "cloud_db_url": url, "cloud_anon_key": keys.get("anon"), "cloud_service_key": keys.get("service_role")}) + st.update({"cloud_ref": ref, "cloud_db_password": pw, "cloud_db_url": url, "cloud_anon_key": keys.get("anon"), "cloud_service_key": keys.get("service_role"), + # new-style keys (sb_publishable_/sb_secret_): the cutover maps them onto the stack's anon/service JWTs + "cloud_publishable_keys": [k["api_key"] for k in allk if k.get("type") == "publishable" and k.get("api_key")], + "cloud_secret_keys": [k["api_key"] for k in allk if k.get("type") == "secret" and k.get("api_key")]}) save_state(s["site"], st) return vars_ @@ -1272,7 +1276,8 @@ def cmd_supabase_load(args): if not st.get(k): die(f"state has no {k}: run supabase-stack and supabase-pull first") log(f"Loading {st['sb_dump']} into {st['sb_slug']}-supabase-db") ssh_put(tmpl("supabase-load.sh"), "/root/dev2-kit/supabase-load.sh", "0700") - res = ssh("root", f"env DUMP={st['sb_dump']} DBC={st['sb_slug']}-supabase-db CLOUD_REF={st['cloud_ref']} NEW_HOST={st['sb_host']} POST_ONLY={'1' if args.post_only else '0'} RESET={'1' if getattr(args, 'reset', False) else '0'} bash /root/dev2-kit/supabase-load.sh 2>&1", timeout=6 * 3600) + keyenv = " ".join(f"{k}={shlex.quote(st.get(v) or '')}" for k, v in (("CLOUD_ANON_KEY", "cloud_anon_key"), ("CLOUD_SERVICE_KEY", "cloud_service_key"), ("SELF_ANON_KEY", "sb_anon_key"), ("SELF_SERVICE_KEY", "sb_service_key"))) + res = ssh("root", f"env {keyenv} DUMP={st['sb_dump']} DBC={st['sb_slug']}-supabase-db CLOUD_REF={st['cloud_ref']} NEW_HOST={st['sb_host']} POST_ONLY={'1' if args.post_only else '0'} RESET={'1' if getattr(args, 'reset', False) else '0'} bash /root/dev2-kit/supabase-load.sh 2>&1", timeout=6 * 3600) print(res[-6000:]) mark(s["site"], "supabase-load") @@ -1295,22 +1300,37 @@ def cmd_supabase_cutover(args): for k in ("cloud_ref", "sb_host", "sb_anon_key", "sb_service_key", "sb_database_url", "sb_pg_password"): if not st.get(k): die(f"state has no {k}") vars_ = site_variables(s) + # An app whose backend is Edge Functions 500s the moment it points at a stack without them (saasrow). + if "supabase-functions" not in st["steps"]: + try: fns = [f.get("slug") or f.get("name") for f in sb_api(f"projects/{st['cloud_ref']}/functions")] + except Exception: fns = [] + if fns: die(f"cloud project has {len(fns)} Edge Functions ({', '.join(fns[:8])}{'...' if len(fns) > 8 else ''}); run `dev2-site supabase-functions {s['site']}` first") ov = {} # Which of the new values are credentials, known by where they came from # rather than by the spelling of the key. - secret = set() + secret = set(); s3 = False for k, v in vars_.items(): if not isinstance(v, str): continue nv = v + if f"{st['cloud_ref']}.storage.supabase.co" in nv: # S3-protocol endpoint (LiveKit egress etc.) + nv = nv.replace(f"{st['cloud_ref']}.storage.supabase.co", st["sb_host"]); s3 = True if f"{st['cloud_ref']}.supabase.co" in nv: nv = re.sub(rf"https?://{st['cloud_ref']}\.supabase\.co", f"https://{st['sb_host']}", nv).replace(f"{st['cloud_ref']}.supabase.co", st["sb_host"]) if nv.startswith("postgres"): nv = st["sb_database_url"]; secret.add(k) if st.get("cloud_anon_key") and v == st["cloud_anon_key"]: nv = st["sb_anon_key"]; secret.add(k) if st.get("cloud_service_key") and v == st["cloud_service_key"]: nv = st["sb_service_key"]; secret.add(k) + if v in (st.get("cloud_publishable_keys") or []): nv = st["sb_anon_key"]; secret.add(k) + if v in (st.get("cloud_secret_keys") or []): nv = st["sb_service_key"]; secret.add(k) if k == "SUPABASE_DB_PASSWORD": nv = st["sb_pg_password"]; secret.add(k) if k in ("SUPABASE_JWT_SECRET", "JWT_SECRET") and st.get("sb_jwt_secret") and len(v) > 20: nv = st["sb_jwt_secret"]; secret.add(k) if k in ("SUPABASE_DB_REF", "SUPABASE_PROJECT_REF") and v == st["cloud_ref"]: warn(f"{k}={v}: the app derives URLs from the ref; it needs a code change to use SUPABASE_URL") if nv != v: ov[k] = nv + if s3: # the S3 protocol has its own per-stack credentials + s3env = dict(l.split("=", 1) for l in ssh("root", f"grep -E '^S3_PROTOCOL_ACCESS_KEY_(ID|SECRET)=' {WWW}/{s['site']}/supabase/.env", check=False).splitlines() if "=" in l) + for k in vars_: + if re.search(r"S3.*(ACCESS_)?KEY_ID$", k) and s3env.get("S3_PROTOCOL_ACCESS_KEY_ID"): ov[k] = s3env["S3_PROTOCOL_ACCESS_KEY_ID"]; secret.add(k) + elif re.search(r"S3.*SECRET", k) and s3env.get("S3_PROTOCOL_ACCESS_KEY_SECRET"): ov[k] = s3env["S3_PROTOCOL_ACCESS_KEY_SECRET"]; secret.add(k) + note(f"S3 endpoint re-pointed; stack S3 credentials: {', '.join(k for k in ov if 'S3' in k) or 'NONE FOUND (set them by hand)'}") sd_path = os.path.join(SITES_D, f"{s['site']}.json") sd = json.load(open(sd_path)) if os.path.exists(sd_path) else {} env_ov, vaulted = dict(sd.get("env_overrides") or {}), [] @@ -1328,7 +1348,8 @@ def cmd_supabase_cutover(args): cmd_verify(argparse.Namespace(site=s["site"], public=("dns" in load_state(s["site"])["steps"]))) if st.get("cloud_db_url"): log("Unscheduling the CLOUD project's cron jobs") - out = ssh("root", f"docker run --rm -i --network host postgres:17 psql {shlex.quote(st['cloud_db_url'])} -At -X -c \"select case when to_regclass('cron.job') is null then 'no pg_cron' else (select coalesce(string_agg(jobname, ', '), 'none') from cron.job where active) end\" 2>&1 | tail -n 3", check=False).strip() + has = ssh("root", f"docker run --rm -i --network host postgres:17 psql {shlex.quote(st['cloud_db_url'])} -At -X -c \"select to_regclass('cron.job') is not null\" 2>&1 | tail -n 1", check=False).strip() + out = "no pg_cron" if has != "t" else ssh("root", f"docker run --rm -i --network host postgres:17 psql {shlex.quote(st['cloud_db_url'])} -At -X -c \"select coalesce(string_agg(jobname, ', '), 'none') from cron.job where active\" 2>&1 | tail -n 1", check=False).strip() if out and out not in ("no pg_cron", "none"): ssh("root", f"docker run --rm -i --network host postgres:17 psql {shlex.quote(st['cloud_db_url'])} -At -X -c \"select cron.unschedule(jobname) from cron.job where active\" >/dev/null 2>&1", check=False) note(f"unscheduled: {out}") @@ -1336,6 +1357,42 @@ def cmd_supabase_cutover(args): mark(s["site"], "supabase-cutover") log(f"{s['site']} now runs on its own Supabase. Cloud project {st['cloud_ref']} stays until `retire` (delete it in the dashboard or with the management API).") +def cmd_supabase_functions(args): + """Port the repo's supabase/functions/* onto the stack's edge runtime and copy the cloud + project's function secrets. The self-hosted `functions` service serves /functions/v1/ + from volumes/functions//index.ts (its `main` router); workers inherit the container env.""" + import base64, io, tarfile + s = site(args.site); st = load_state(s["site"]) + for k in ("sb_slug", "sb_ports"): + if not st.get(k): die(f"state has no {k}; run supabase-stack first") + src = os.path.join(st.get("checkout") or "", "supabase", "functions") + if not os.path.isdir(src): die(f"no {src}; the app keeps its Edge Functions elsewhere (set `checkout` or port by hand)") + names = sorted(d for d in os.listdir(src) if os.path.isdir(os.path.join(src, d)) and not d.startswith(".") and d not in ("node_modules", "main")) + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for d in names + [d for d in os.listdir(src) if os.path.isfile(os.path.join(src, d))]: + tar.add(os.path.join(src, d), arcname=d, filter=lambda ti: None if "/node_modules/" in ti.name or ti.name.endswith((".env", ".env.local")) else ti) + fdir = f"{WWW}/{s['site']}/supabase/volumes/functions" + log(f"Edge Functions -> {fdir}: {', '.join(n for n in names if not n.startswith('_'))}") + ssh("root", f"mkdir -p {fdir} && base64 -d | tar -xzf - -C {fdir} && chmod -R a+rX {fdir}", input=base64.b64encode(buf.getvalue()).decode()) + # Function secrets: everything the cloud had except the SUPABASE_* the stack injects itself. + secrets = {} + if st.get("cloud_ref"): + try: secrets = {x["name"]: x["value"] for x in sb_api(f"projects/{st['cloud_ref']}/secrets") if not x["name"].startswith("SUPABASE_")} + except Exception as e: warn(f"could not read cloud function secrets: {e}") + ssh_put("".join(f"{k}={v}\n" for k, v in sorted(secrets.items())), f"{fdir}/secrets.env", "0600") + note(f"{len(secrets)} function secrets written to secrets.env: {', '.join(sorted(secrets)) or '-'}") + # Older overlays predate the env_file line; add it, then restart the runtime. + ov = f"{WWW}/{s['site']}/supabase/docker-compose.{st['sb_slug']}.yml" + ssh("root", f"grep -q 'functions/secrets.env' {ov} || sed -i '/^ functions:$/a\\ env_file:\\n - .env\\n - ./volumes/functions/secrets.env' {ov}; cd {WWW}/{s['site']}/supabase && docker compose up -d --force-recreate functions >/dev/null 2>&1; sleep 4; docker logs --tail 5 {st['sb_slug']}-supabase-functions 2>&1 | tail -n 3") + probe = next((n for n in names if not n.startswith("_")), None) + if probe: + code = ssh("root", f"curl -s -o /dev/null -w '%{{http_code}}' -m 20 -H 'Authorization: Bearer {st.get('sb_anon_key', '')}' -H 'apikey: {st.get('sb_anon_key', '')}' -X OPTIONS http://127.0.0.1:{st['sb_ports']['api']}/functions/v1/{probe}", check=False).strip() + body = ssh("root", f"curl -s -m 20 -H 'Authorization: Bearer {st.get('sb_anon_key', '')}' -H 'apikey: {st.get('sb_anon_key', '')}' http://127.0.0.1:{st['sb_ports']['api']}/functions/v1/{probe} | head -c 200", check=False) + if "InvalidWorkerCreation" in body or "entrypoint" in body: die(f"/functions/v1/{probe} still fails: {body}") + note(f"/functions/v1/{probe}: OPTIONS {code}, GET -> {body[:120]!r}") + mark(s["site"], "supabase-functions", functions=names) + # ----------------------------------------------------------------- main def main(): @@ -1356,7 +1413,7 @@ def main(): p = sub.add_parser("verify"); p.add_argument("site"); p.add_argument("--public", action="store_true"); p.add_argument("--ignore-logs", action="store_true"); p.set_defaults(fn=cmd_verify) p = sub.add_parser("refresh-db"); p.add_argument("site"); p.set_defaults(fn=cmd_refresh_db) p = sub.add_parser("migrate"); p.add_argument("site"); p.add_argument("--yes", action="store_true"); p.add_argument("--redo", action="store_true"); p.set_defaults(fn=cmd_migrate) - for name, fn in (("supabase-stack", cmd_supabase_stack), ("supabase-cutover", cmd_supabase_cutover)): + for name, fn in (("supabase-stack", cmd_supabase_stack), ("supabase-cutover", cmd_supabase_cutover), ("supabase-functions", cmd_supabase_functions)): p = sub.add_parser(name); p.add_argument("site"); p.set_defaults(fn=fn) p = sub.add_parser("supabase-pull"); p.add_argument("site"); p.add_argument("--reset-password", action="store_true"); p.set_defaults(fn=cmd_supabase_pull) p = sub.add_parser("supabase-load"); p.add_argument("site"); p.add_argument("--post-only", action="store_true"); p.add_argument("--reset", action="store_true", help="drop the earlier load first (fresh dump before cutover)"); p.set_defaults(fn=cmd_supabase_load) diff --git a/dev2/templates/supabase-load.sh b/dev2/templates/supabase-load.sh index d16680b..b1d12e9 100755 --- a/dev2/templates/supabase-load.sh +++ b/dev2/templates/supabase-load.sh @@ -41,6 +41,10 @@ if [ "$POST_ONLY" = 0 ]; then grep -qE 'CREATE SCHEMA "?(auth|storage)"?' "$DUMP/schema.sql" && die "schema.sql contains auth/storage DDL" psql_admin -f /dev/stdin < "$DUMP/schema.sql" > "$DUMP/schema.load.log" 2>&1 || true echo " schema errors: $(nerr "$DUMP/schema.load.log")"; lerr "$DUMP/schema.load.log" + if [ -s "$DUMP/notvalid-drop.sql" ]; then + log "Dropping the cloud's NOT VALID constraints before data (COPY enforces them; re-added NOT VALID after)" + psql_admin -f /dev/stdin < "$DUMP/notvalid-drop.sql" > "$DUMP/notvalid-drop.log" 2>&1 || true; echo " dropped $(grep -c '^ALTER' "$DUMP/notvalid-drop.log" || true), errors $(nerr "$DUMP/notvalid-drop.log")" + fi log "Data (auth before app schemas)" a=$(grep -m1 -n 'COPY "auth"' "$DUMP/data.sql" | cut -d: -f1 || echo 0); p=$(grep -m1 -n 'COPY "public"' "$DUMP/data.sql" | cut -d: -f1 || echo 0); a=${a:-0}; p=${p:-0} if [ "$a" -gt 0 ] && [ "$p" -gt 0 ] && [ "$a" -gt "$p" ]; then die "data.sql has public before auth"; fi @@ -49,6 +53,15 @@ if [ "$POST_ONLY" = 0 ]; then log "Grants for anon/authenticated/service_role" psql_admin -f /dev/stdin < "$DUMP/grants.sql" > "$DUMP/grants.load.log" 2>&1 || true echo " grant errors: $(nerr "$DUMP/grants.load.log")"; lerr "$DUMP/grants.load.log" + if [ -s "$DUMP/notvalid-add.sql" ]; then + psql_admin -f /dev/stdin < "$DUMP/notvalid-add.sql" > "$DUMP/notvalid-add.log" 2>&1 || true; echo " NOT VALID constraints re-added: $(grep -c '^ALTER' "$DUMP/notvalid-add.log" || true), errors $(nerr "$DUMP/notvalid-add.log")"; lerr "$DUMP/notvalid-add.log" + fi + if [ -s "$DUMP/vault.tsv" ]; then + log "Vault secrets" + psql_admin -c "create extension if not exists supabase_vault cascade" >/dev/null 2>&1 || true + while IFS=$'\t' read -r vn vs vd <&3; do [ -n "$vn" ] || continue + psql_admin -v n="$vn" -v s="$vs" -v d="$vd" -At -c "select vault.create_secret(:'s', :'n', :'d') where not exists (select 1 from vault.secrets where name = :'n')" >/dev/null && echo " $vn" || echo " $vn: FAILED"; done 3< "$DUMP/vault.tsv" + fi log "Ownership: app schemas to postgres (they were created by supabase_admin)" psql_admin -At -v ON_ERROR_STOP=1 <<'SQL' do $$ declare r record; begin @@ -64,7 +77,7 @@ fi log "Buckets" while IFS=$'\t' read -r id name pub limit mimes <&3; do [ -n "$id" ] || continue; case "$pub" in t|true) ps=true;; *) ps=false;; esac - strict -c "insert into storage.buckets (id, name, public) values ('$id','$name',$ps) on conflict (id) do update set public=excluded.public" >/dev/null; done 3< "$DUMP/buckets.tsv" + strict -c "insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('$id','$name',$ps, nullif('$limit','')::bigint, case when '$mimes' = '' then null else string_to_array('$mimes', ',') end) on conflict (id) do update set public=excluded.public, file_size_limit=excluded.file_size_limit, allowed_mime_types=excluded.allowed_mime_types" >/dev/null; done 3< "$DUMP/buckets.tsv" log "Rewriting absolute https://$CLOUD_REF.supabase.co URLs to https://$NEW_HOST in every text/json column" strict -At <&1 | sed 's/^/ /' || true -log "pg_cron jobs"; psql_db -f /dev/stdin < "$DUMP/cron-jobs.sql" > "$DUMP/cron.load.log" 2>&1 || true +log "pg_cron jobs (cloud URL and API keys inside the commands rewritten to this stack)" +sedargs=(-e "s#https://$CLOUD_REF.supabase.co#https://$NEW_HOST#g" -e "s#$CLOUD_REF.supabase.co#$NEW_HOST#g") +[ -n "${CLOUD_ANON_KEY:-}" ] && [ -n "${SELF_ANON_KEY:-}" ] && sedargs+=(-e "s#$CLOUD_ANON_KEY#$SELF_ANON_KEY#g") +[ -n "${CLOUD_SERVICE_KEY:-}" ] && [ -n "${SELF_SERVICE_KEY:-}" ] && sedargs+=(-e "s#$CLOUD_SERVICE_KEY#$SELF_SERVICE_KEY#g") +sed "${sedargs[@]}" "$DUMP/cron-jobs.sql" | psql_db -f /dev/stdin > "$DUMP/cron.load.log" 2>&1 || true strict -At -c "select count(*)||' cron jobs active' from cron.job where active" 2>/dev/null || true log "Snapshot AFTER (vs the cloud's estimates in counts-estimate.tsv)" diff --git a/dev2/templates/supabase-pull.sh b/dev2/templates/supabase-pull.sh index d37e3d3..8bce031 100755 --- a/dev2/templates/supabase-pull.sh +++ b/dev2/templates/supabase-pull.sh @@ -28,7 +28,7 @@ log "Data (auth + app schemas + storage buckets)" pg pg_dump --dbname="$CLOUD_DB_URL" --data-only --no-owner --no-privileges --quote-all-identifiers --disable-triggers \ --schema=auth "${SCHEMA_ARGS[@]}" --schema=storage \ --exclude-table-data='storage.objects' --exclude-table-data='storage.migrations' --exclude-table-data='storage.s3_multipart_uploads*' --exclude-table-data='storage.prefixes' \ - --exclude-table-data='auth.schema_migrations' --exclude-table-data='auth.audit_log_entries' --exclude-table-data='auth.refresh_tokens' --exclude-table-data='auth.sessions' --exclude-table-data='auth.flow_state' \ + --exclude-table-data='auth.schema_migrations' --exclude-table-data='auth.audit_log_entries' --exclude-table-data='auth.refresh_tokens' --exclude-table-data='auth.sessions' --exclude-table-data='auth.flow_state' --exclude-table-data='auth.one_time_tokens' --exclude-table-data='auth.scim_*' \ ${EXCLUDE_TABLE_DATA:-} > "$OUT/data.sql" log "Grants and RLS policies (the schema dump drops privileges; PostgREST needs them back)" @@ -39,6 +39,14 @@ psqlc -c "select 'revoke execute on function '||p.oid::regprocedure::text||' fro psqlc -c "select 'grant execute on function '||p.oid::regprocedure::text||' to '||quote_ident(r.rolname)||';' from pg_proc p join pg_namespace n on n.oid=p.pronamespace join aclexplode(p.proacl) a on true join pg_roles r on r.oid=a.grantee where n.nspname not in ($SYS) and n.nspname not like 'pg_%' and p.prokind in ('f','p') and a.privilege_type='EXECUTE' and r.rolname in ('anon','authenticated','service_role') order by 1" >> "$OUT/grants.sql" || true psqlc -c "select 'grant usage, select on all sequences in schema '||quote_ident(nspname)||' to anon, authenticated, service_role;' from pg_namespace where nspname not in ($SYS) and nspname not like 'pg_%'" >> "$OUT/grants.sql" +log "NOT VALID constraints (COPY would enforce them; the load drops them before data and re-adds them NOT VALID after)" +psqlc -c "select format('alter table %s drop constraint %I;', conrelid::regclass, conname) from pg_constraint where not convalidated and contype in ('c','f') and connamespace::regnamespace::text not in ($SYS) order by 1" > "$OUT/notvalid-drop.sql" || : > "$OUT/notvalid-drop.sql" +psqlc -c "select format('alter table %s add constraint %I %s not valid;', conrelid::regclass, conname, pg_get_constraintdef(oid)) from pg_constraint where not convalidated and contype in ('c','f') and connamespace::regnamespace::text not in ($SYS) order by 1" > "$OUT/notvalid-add.sql" || : > "$OUT/notvalid-add.sql" + +log "Vault secrets (pg_cron http jobs read them)" +psqlc -F$'\t' -c "select name, decrypted_secret, coalesce(description,'') from vault.decrypted_secrets order by 1" > "$OUT/vault.tsv" 2>/dev/null || : > "$OUT/vault.tsv" +chmod 600 "$OUT/vault.tsv" + log "pg_cron jobs" psqlc -c "select 'select cron.schedule(' || quote_literal(jobname) || ', ' || quote_literal(schedule) || ', ' || quote_literal(command) || ');' from cron.job where active order by jobid" > "$OUT/cron-jobs.sql" 2>/dev/null || : > "$OUT/cron-jobs.sql" @@ -56,5 +64,6 @@ psqlc -F$'\t' -c "select n.nspname||'.'||c.relname, c.reltuples::bigint from pg_ echo "dumped_at=$(date -u +%FT%TZ)"; echo "schema_bytes=$(stat -c%s "$OUT/schema.sql")"; echo "data_bytes=$(stat -c%s "$OUT/data.sql")" echo "storage_objects=$(wc -l < "$OUT/storage-inventory.tsv")"; echo "storage_bytes=$(awk -F'\t' '{s+=$4} END{print s+0}' "$OUT/storage-inventory.tsv")" echo "cron_jobs=$(grep -c '^select cron.schedule' "$OUT/cron-jobs.sql" || true)"; echo "auth_users=$(grep -c '' <(psqlc -c 'select id from auth.users'))" + echo "notvalid_constraints=$(grep -c '^alter' "$OUT/notvalid-drop.sql" || true)"; echo "vault_secrets=$(grep -c '' "$OUT/vault.tsv" || true)" } > "$OUT/MANIFEST" log "Done: $OUT"; cat "$OUT/MANIFEST" diff --git a/dev2/templates/supabase-stack.sh b/dev2/templates/supabase-stack.sh index 2ca5892..a1ae684 100755 --- a/dev2/templates/supabase-stack.sh +++ b/dev2/templates/supabase-stack.sh @@ -39,7 +39,7 @@ DBC="$SLUG-supabase-db" sql_admin() { docker exec -i "$DBC" psql -U supabase_admin -h localhost -d postgres -v ON_ERROR_STOP=1 -X -q -At "$@"; } # ------------------------------------------------------------ 1. the files -install -d -m 2750 -o root -g root "$ROOT" 2>/dev/null || true +[ -d "$ROOT" ] || install -d -m 2750 -o root -g root "$ROOT" # never re-chown an existing site root (CI deploys as the deploy user) if [ -f "$DIR/.env" ] && [ -f "$DIR/docker-compose.yml" ]; then log "Supabase project already at $DIR; keeping its secrets" else @@ -138,6 +138,8 @@ log "Overlay docker-compose.$SLUG.yml (own project name, container names, ports, realtime) echo " networks:"; echo " default:"; echo " aliases: [realtime-dev.supabase-realtime, realtime]" ;; api-gw) echo " networks:"; echo " default:"; echo " aliases: [envoy, kong]"; echo " ports: !override"; echo " - \"127.0.0.1:${API_PORT}:8000/tcp\"" ;; supavisor) echo " ports: !override"; echo " - \"127.0.0.1:${POOLER_PORT}:6543\"" ;; + storage) echo " environment:"; echo " FILE_SIZE_LIMIT: 5368709120" ;; # the base file pins 50 MiB; buckets declare up to GiBs + functions) echo " env_file:"; echo " - .env"; echo " - ./volumes/functions/secrets.env" ;; # cloud function secrets (supabase-functions) db) echo " shm_size: 512m"; echo " ports: !override"; echo " - \"${DB_PORT}:5432\""; echo " volumes:" echo " - ./volumes/$SLUG/$SLUG.conf:/etc/postgresql-custom/conf.d/zz-$SLUG.conf:ro,z" echo " - ./volumes/$SLUG/pg_hba.conf:/etc/$SLUG/pg_hba.conf:ro,z" @@ -152,6 +154,8 @@ log "Overlay docker-compose.$SLUG.yml (own project name, container names, ports, echo " gateway: $GATEWAY" } > "$DIR/docker-compose.$SLUG.yml" +[ -f "$DIR/volumes/functions/secrets.env" ] || { install -d "$DIR/volumes/functions"; (umask 077; : > "$DIR/volumes/functions/secrets.env"); } + # ---------------------------------------------------------------- 3. start log "Starting $SLUG-supabase" compose up -d --wait 2>/dev/null || compose up -d From 76c4e92d568aeeb7a1d1a524c11ff3bac1f1b057 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:51:33 +0000 Subject: [PATCH 085/104] supabase-functions: re-point cloud URL/keys inside the copied function secrets Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dev2/dev2-site b/dev2/dev2-site index 1eae436..96785e7 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -1380,6 +1380,13 @@ def cmd_supabase_functions(args): if st.get("cloud_ref"): try: secrets = {x["name"]: x["value"] for x in sb_api(f"projects/{st['cloud_ref']}/secrets") if not x["name"].startswith("SUPABASE_")} except Exception as e: warn(f"could not read cloud function secrets: {e}") + # Secrets that name the cloud project (URL, keys) must point at this stack instead. + for k, v in list(secrets.items()): + nv = v + if st.get("cloud_ref"): nv = re.sub(rf"https?://{st['cloud_ref']}\.supabase\.co", f"https://{st['sb_host']}", nv).replace(f"{st['cloud_ref']}.supabase.co", st["sb_host"]) + if st.get("cloud_anon_key") and v == st["cloud_anon_key"] or v in (st.get("cloud_publishable_keys") or []): nv = st["sb_anon_key"] + if st.get("cloud_service_key") and v == st["cloud_service_key"] or v in (st.get("cloud_secret_keys") or []): nv = st["sb_service_key"] + if nv != v: secrets[k] = nv; note(f"{k}: re-pointed at the stack") ssh_put("".join(f"{k}={v}\n" for k, v in sorted(secrets.items())), f"{fdir}/secrets.env", "0600") note(f"{len(secrets)} function secrets written to secrets.env: {', '.join(sorted(secrets)) or '-'}") # Older overlays predate the env_file line; add it, then restart the runtime. From 6588e86e1a00b1732915cc5220bd1b0f627a462e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:53:01 +0000 Subject: [PATCH 086/104] supabase-functions: the API only lists secret names; take values from the site variables Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index 96785e7..bdcdcbd 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -1375,11 +1375,18 @@ def cmd_supabase_functions(args): fdir = f"{WWW}/{s['site']}/supabase/volumes/functions" log(f"Edge Functions -> {fdir}: {', '.join(n for n in names if not n.startswith('_'))}") ssh("root", f"mkdir -p {fdir} && base64 -d | tar -xzf - -C {fdir} && chmod -R a+rX {fdir}", input=base64.b64encode(buf.getvalue()).decode()) - # Function secrets: everything the cloud had except the SUPABASE_* the stack injects itself. - secrets = {} + # Function secrets: the management API lists their NAMES (the "value" it returns is a digest), + # so take each value from the app's own variables, which carry the same keys. + secrets, missing = {}, [] if st.get("cloud_ref"): - try: secrets = {x["name"]: x["value"] for x in sb_api(f"projects/{st['cloud_ref']}/secrets") if not x["name"].startswith("SUPABASE_")} - except Exception as e: warn(f"could not read cloud function secrets: {e}") + try: names = [x["name"] for x in sb_api(f"projects/{st['cloud_ref']}/secrets") if not x["name"].startswith("SUPABASE_")] + except Exception as e: names = []; warn(f"could not list cloud function secrets: {e}") + vars_ = site_variables(s) + for n in names: + v = vars_.get(n) + if isinstance(v, str) and v: secrets[n] = v + else: missing.append(n) + if missing: warn(f"function secrets with no value in the site's variables (add them to {fdir}/secrets.env by hand): {', '.join(missing)}") # Secrets that name the cloud project (URL, keys) must point at this stack instead. for k, v in list(secrets.items()): nv = v From a101dc3d8431554d3a25f7164109a9915ff2c49f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:53:52 +0000 Subject: [PATCH 087/104] supabase-functions: fix name collision in the probe; sites.d function_secrets overlay Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index bdcdcbd..92788cd 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -1379,11 +1379,13 @@ def cmd_supabase_functions(args): # so take each value from the app's own variables, which carry the same keys. secrets, missing = {}, [] if st.get("cloud_ref"): - try: names = [x["name"] for x in sb_api(f"projects/{st['cloud_ref']}/secrets") if not x["name"].startswith("SUPABASE_")] - except Exception as e: names = []; warn(f"could not list cloud function secrets: {e}") + try: snames = [x["name"] for x in sb_api(f"projects/{st['cloud_ref']}/secrets") if not x["name"].startswith("SUPABASE_")] + except Exception as e: snames = []; warn(f"could not list cloud function secrets: {e}") vars_ = site_variables(s) - for n in names: - v = vars_.get(n) + # sites.d "function_secrets": {"NAME": "secret:NAME"} (values in credentials.json as .fn.NAME) wins over app variables. + fs = {k: (cred(f"{s['site']}.fn.{v[7:]}", v[7:]) if isinstance(v, str) and v.startswith("secret:") else v) for k, v in (s.get("function_secrets") or {}).items()} + for n in snames: + v = fs.get(n) or vars_.get(n) if isinstance(v, str) and v: secrets[n] = v else: missing.append(n) if missing: warn(f"function secrets with no value in the site's variables (add them to {fdir}/secrets.env by hand): {', '.join(missing)}") From 48e57bedcdc673713dc2f6e0b12d6e6c32e6820e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 15:54:57 +0000 Subject: [PATCH 088/104] dev2-site: sites.d crons -> /etc/cron.d (coinpayportal's minute payment monitor was a Vercel cron) Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 15 +++++++++++++++ dev2/sites.d/coinpayportal.com.json | 11 ++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index 92788cd..e3e5f97 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -666,6 +666,20 @@ def deploy_key(repo): sh(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", f"github-actions-deploy@{repo}", "-f", path]) return path +def render_crons(s): + """sites.d "crons": [{"schedule": "* * * * *", "path": "/api/cron/x", "auth_env": "CRON_SECRET", "timeout": 55}] + -> /etc/cron.d/dev2-site-. Replaces the Vercel/Railway cron the app used to rely on: root curls the + site's own URL with the bearer taken from app.env at run time (nothing secret lands in the cron file).""" + path = f"/etc/cron.d/dev2-site-{sb_slug(s)}" + crons = s.get("crons") or [] + if not crons: ssh("root", f"rm -f {path}", check=False); return + lines = [f"# {s['site']}: managed by cli-tools dev2-site (sites.d crons); do not edit", "SHELL=/bin/sh", "PATH=/usr/local/bin:/usr/bin:/bin", ""] + for c in crons: + hdr = f' -H "Authorization: Bearer $(grep -m1 ^{c["auth_env"]}= {WWW}/{s["site"]}/app.env | cut -d= -f2-)"' if c.get("auth_env") else "" + lines.append(f'{c["schedule"]} root curl -fsS -m {int(c.get("timeout", 55))} -o /dev/null{hdr} https://{s["site"]}{c["path"]} >/dev/null 2>&1') + ssh_put("\n".join(lines) + "\n", path, "0644") + note(f"{len(crons)} cron job(s) in {path}: " + ", ".join(f"{c['schedule']} {c['path']}" for c in crons)) + def cmd_provision(args): s = site(args.site); st = load_state(s["site"]) root = f"{WWW}/{s['site']}" @@ -735,6 +749,7 @@ def cmd_provision(args): for name, val in (("DEV2_SSH_KEY", open(key).read()), ("DEV2_KNOWN_HOSTS", known), ("DEV2_HOST", DEV2_HOST), ("DEV2_USER", DEPLOY_USER)): sh(["gh", "secret", "set", name, "--repo", s["repo"]], input=val) note(f"deploy key {os.path.basename(key)} authorized on dev2; DEV2_* secrets set on {s['repo']}") + render_crons(s) mark(s["site"], "provision", root=root, branch=branch, health_path=hp, dockerfile=st["dockerfile"], checkout=st.get("checkout") or checkout_dir(s)) # ------------------------------------------------------------------- db diff --git a/dev2/sites.d/coinpayportal.com.json b/dev2/sites.d/coinpayportal.com.json index 82af887..58fa245 100644 --- a/dev2/sites.d/coinpayportal.com.json +++ b/dev2/sites.d/coinpayportal.com.json @@ -6,5 +6,14 @@ "port": 8273, "max_body": "512m" } - ] + ], + "crons": [ + { + "schedule": "* * * * *", + "path": "/api/cron/monitor-payments", + "auth_env": "CRON_SECRET", + "timeout": 55 + } + ], + "notes": "Payment/escrow/lightning monitor ran as a Vercel cron every minute (vercel.json); on dev2 it is the cron.d entry rendered from `crons`." } From 79e99aca9c5cbfab6c2b8fe9ff227c5d0fd79912 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:00:12 +0000 Subject: [PATCH 089/104] sites.d: coinpayportal cut over to its dev2 Supabase; saasrow function secrets as vault refs Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/coinpayportal.com.json | 13 ++++++++++++- dev2/sites.d/saasrow.com.json | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/dev2/sites.d/coinpayportal.com.json b/dev2/sites.d/coinpayportal.com.json index 58fa245..8e45e68 100644 --- a/dev2/sites.d/coinpayportal.com.json +++ b/dev2/sites.d/coinpayportal.com.json @@ -15,5 +15,16 @@ "timeout": 55 } ], - "notes": "Payment/escrow/lightning monitor ran as a Vercel cron every minute (vercel.json); on dev2 it is the cron.d entry rendered from `crons`." + "notes": "Payment/escrow/lightning monitor ran as a Vercel cron every minute (vercel.json); on dev2 it is the cron.d entry rendered from `crons`.", + "env_overrides": { + "JWT_SECRET": "secret:JWT_SECRET", + "NEXT_PUBLIC_SUPABASE_ANON_KEY": "secret:NEXT_PUBLIC_SUPABASE_ANON_KEY", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.coinpayportal.com", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", + "SUPABASE_JWT_SECRET": "secret:SUPABASE_JWT_SECRET", + "SUPABASE_PUBLISHABLE_KEY": "secret:SUPABASE_PUBLISHABLE_KEY", + "SUPABASE_SECRETMAT_KEY": "secret:SUPABASE_SECRETMAT_KEY", + "SUPABASE_SECRET_KEY": "secret:SUPABASE_SECRET_KEY", + "SUPABASE_SERVICE_ROLE_KEY": "secret:SUPABASE_SERVICE_ROLE_KEY" + } } diff --git a/dev2/sites.d/saasrow.com.json b/dev2/sites.d/saasrow.com.json index 17cecb9..8a1640e 100644 --- a/dev2/sites.d/saasrow.com.json +++ b/dev2/sites.d/saasrow.com.json @@ -5,5 +5,18 @@ "port": 8276, "max_body": "512m" } - ] + ], + "function_secrets": { + "VITE_SUPABASE_URL": "https://supabase.saasrow.com", + "VITE_SUPABASE_ANON_KEY": "secret:VITE_SUPABASE_ANON_KEY", + "OPENAI_API_KEY": "secret:OPENAI_API_KEY", + "STRIPE_SECRET_KEY": "secret:STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET": "secret:STRIPE_WEBHOOK_SECRET", + "SITE_URL": "secret:SITE_URL", + "ADMIN_EMAIL": "secret:ADMIN_EMAIL", + "GETSCREENSHOT_API_KEY": "secret:GETSCREENSHOT_API_KEY", + "RESEND_API_KEY": "secret:RESEND_API_KEY", + "RESEND_DOMAIN": "secret:RESEND_DOMAIN", + "GEMINI_API_KEY": "secret:GEMINI_API_KEY" + } } From 3293f47aeb3c6528808643dad5e1d8b74ded2581 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:27:04 +0000 Subject: [PATCH 090/104] sites.d: tsbb.dev off Turso (TSBB_DATABASE_AUTH_TOKEN retired) --- dev2/sites.d/tsbb.dev.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/tsbb.dev.json diff --git a/dev2/sites.d/tsbb.dev.json b/dev2/sites.d/tsbb.dev.json new file mode 100644 index 0000000..79a7315 --- /dev/null +++ b/dev2/sites.d/tsbb.dev.json @@ -0,0 +1 @@ +{ "env_remove": ["TSBB_DATABASE_AUTH_TOKEN"], "notes": "Turso -> shared Postgres 2026-09-25 (profullstack/tsbb#28); TSBB_DATABASE_URL is rewritten to the cluster URL by provision" } From 32e74abbf4ce7a9273047ef033b7970ecb1f444c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:32:50 +0000 Subject: [PATCH 091/104] sites.d: app.logicsrc.com runs apps/pwa (start_command, /healthz) and leaves Turso --- dev2/sites.d/app.logicsrc.com.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 dev2/sites.d/app.logicsrc.com.json diff --git a/dev2/sites.d/app.logicsrc.com.json b/dev2/sites.d/app.logicsrc.com.json new file mode 100644 index 0000000..e756827 --- /dev/null +++ b/dev2/sites.d/app.logicsrc.com.json @@ -0,0 +1,6 @@ +{ + "start_command": "npm --workspace @logicsrc/pwa run start", + "health_path": "/healthz", + "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"], + "notes": "The service is apps/pwa (Railway rootDirectory); the root .nixpacks CMD starts the Next marketing site instead, so the compose command overrides it. Turso -> shared Postgres 2026-09-25 (profullstack/logicsrc#218)." +} From 43a42e3a0ebde4cc0b2070b169565f6e0d7573e0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:36:12 +0000 Subject: [PATCH 092/104] sites.d: p0dcasters.com off Turso --- dev2/sites.d/p0dcasters.com.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/p0dcasters.com.json diff --git a/dev2/sites.d/p0dcasters.com.json b/dev2/sites.d/p0dcasters.com.json new file mode 100644 index 0000000..f4998b5 --- /dev/null +++ b/dev2/sites.d/p0dcasters.com.json @@ -0,0 +1 @@ +{ "branch": "master", "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"], "notes": "Turso -> shared Postgres 2026-09-25 (profullstack/p0dcasters#29); next build runs at container start against DATABASE_URL" } From 5f8b9646cfca711c6ef1665320d6fe8f3c34c74b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:41:34 +0000 Subject: [PATCH 093/104] sites.d: app.moshcode.sh off Turso (DATABASE_AUTH_TOKEN retired) --- dev2/sites.d/app.moshcode.sh.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dev2/sites.d/app.moshcode.sh.json b/dev2/sites.d/app.moshcode.sh.json index 85ec5f4..4b95cda 100644 --- a/dev2/sites.d/app.moshcode.sh.json +++ b/dev2/sites.d/app.moshcode.sh.json @@ -1,4 +1,5 @@ { "health_path": "/healthz", - "notes": "web app is apps/pwa (own deps); plain Dockerfile on the deploy branch, health /healthz" -} \ No newline at end of file + "env_remove": ["DATABASE_AUTH_TOKEN"], + "notes": "web app is apps/pwa (own deps); plain Dockerfile on main, health /healthz. Turso -> shared Postgres 2026-09-25 (moshcoder/moshcode#551): DATABASE_URL is rewritten to the cluster URL by provision, the Turso token retired" +} From ed71870465a80b66cce82abc0bfda0869983ef3b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:43:58 +0000 Subject: [PATCH 094/104] sites.d: outreachgraph.com off Turso (TURSO_AUTH_TOKEN retired) --- dev2/sites.d/outreachgraph.com.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/outreachgraph.com.json diff --git a/dev2/sites.d/outreachgraph.com.json b/dev2/sites.d/outreachgraph.com.json new file mode 100644 index 0000000..e0e983d --- /dev/null +++ b/dev2/sites.d/outreachgraph.com.json @@ -0,0 +1 @@ +{ "env_remove": ["TURSO_AUTH_TOKEN"], "notes": "Turso -> shared Postgres 2026-09-25 (profullstack/outreachgraph#99); TURSO_DATABASE_URL is rewritten to the cluster URL by provision (the app reads DATABASE_URL first, that name as a fallback)" } From cbec88730e5c718f8d86523ac1038ce73a09df8f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:44:26 +0000 Subject: [PATCH 095/104] sites.d: advis0r.com off Turso (DATABASE_URL was the libsql:// URL; drop DATABASE_AUTH_TOKEN) --- dev2/sites.d/advis0r.com.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/advis0r.com.json diff --git a/dev2/sites.d/advis0r.com.json b/dev2/sites.d/advis0r.com.json new file mode 100644 index 0000000..ef777cd --- /dev/null +++ b/dev2/sites.d/advis0r.com.json @@ -0,0 +1 @@ +{ "branch": "main", "env_remove": ["DATABASE_AUTH_TOKEN"] } From a02dc464dabc3cf60c7c831f2a43ba490361b5a3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:52:51 +0000 Subject: [PATCH 096/104] dev2-site: a sites.d health_path overrides the one recorded in the state file Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index e3e5f97..731ea61 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -700,7 +700,7 @@ def cmd_provision(args): if not st.get("dockerfile"): st["dockerfile"] = find_dockerfile(st.get("checkout") or checkout_dir(s), cfg) or ".nixpacks/Dockerfile" branch = st.get("branch") or s.get("branch") or default_branch(s["repo"]) - hp = st.get("health_path") or health_url(s, cfg)[1] + hp = s.get("health_path") or st.get("health_path") or health_url(s, cfg)[1] # sites.d wins over the state file (app.logicsrc.com) compose = render_compose(s, st, inside, build_args=public_keys(merged)) build_services = " ".join(["app"] + [c["name"] for c in s["companions"]]) # Public repos clone over https. Private ones clone over ssh with a read-only GitHub @@ -1054,7 +1054,7 @@ def cmd_vhost(args): def cmd_verify(args): s = site(args.site); st = load_state(s["site"]) - hp = st.get("health_path") or "/" + hp = s.get("health_path") or st.get("health_path") or "/" # Without the vhost nginx's default server answers the pinned SNI and a 200 means nothing. if ssh("root", f"test -e /etc/nginx/sites-enabled/{shlex.quote(s['site'])} && echo yes", check=False).strip() != "yes": die(f"no nginx vhost for {s['site']} yet: run `dev2-site vhost {s['site']}` first") From 6798bd317f300c9c04a970aface61b9f4afd9782 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 17:00:11 +0000 Subject: [PATCH 097/104] sites.d: c0upons.com and bufferoverride.com off Turso (retired settings dropped; c0upons also loses the dead SQLITECLOUD_URL) --- dev2/sites.d/bufferoverride.com.json | 1 + dev2/sites.d/c0upons.com.json | 1 + 2 files changed, 2 insertions(+) create mode 100644 dev2/sites.d/bufferoverride.com.json create mode 100644 dev2/sites.d/c0upons.com.json diff --git a/dev2/sites.d/bufferoverride.com.json b/dev2/sites.d/bufferoverride.com.json new file mode 100644 index 0000000..26eeea2 --- /dev/null +++ b/dev2/sites.d/bufferoverride.com.json @@ -0,0 +1 @@ +{ "branch": "main", "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"] } diff --git a/dev2/sites.d/c0upons.com.json b/dev2/sites.d/c0upons.com.json new file mode 100644 index 0000000..5e00908 --- /dev/null +++ b/dev2/sites.d/c0upons.com.json @@ -0,0 +1 @@ +{ "branch": "master", "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN", "SQLITECLOUD_URL"] } From d4020d4f221ed8b616c2d830a73c04e877d82bfa Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 17:04:07 +0000 Subject: [PATCH 098/104] sites.d: hqtui.com and moshcoding.com off Turso --- dev2/sites.d/hqtui.com.json | 1 + dev2/sites.d/moshcoding.com.json | 1 + 2 files changed, 2 insertions(+) create mode 100644 dev2/sites.d/hqtui.com.json create mode 100644 dev2/sites.d/moshcoding.com.json diff --git a/dev2/sites.d/hqtui.com.json b/dev2/sites.d/hqtui.com.json new file mode 100644 index 0000000..26eeea2 --- /dev/null +++ b/dev2/sites.d/hqtui.com.json @@ -0,0 +1 @@ +{ "branch": "main", "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"] } diff --git a/dev2/sites.d/moshcoding.com.json b/dev2/sites.d/moshcoding.com.json new file mode 100644 index 0000000..6d2a6e3 --- /dev/null +++ b/dev2/sites.d/moshcoding.com.json @@ -0,0 +1 @@ +{ "branch": "master", "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"] } From a357409cebaf5c9818b93efdb2a6eff900fff9c1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 17:08:31 +0000 Subject: [PATCH 099/104] sites.d: aiornot.vote off Turso --- dev2/sites.d/aiornot.vote.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 dev2/sites.d/aiornot.vote.json diff --git a/dev2/sites.d/aiornot.vote.json b/dev2/sites.d/aiornot.vote.json new file mode 100644 index 0000000..6d2a6e3 --- /dev/null +++ b/dev2/sites.d/aiornot.vote.json @@ -0,0 +1 @@ +{ "branch": "master", "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"] } From 1f390c9cf9974c68ed17682018cb406b6cbbe38b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 18:04:30 +0000 Subject: [PATCH 100/104] dev2-site backup-install: version dev2-pg-backup.sh; dump every per-site Supabase stack and mirror storage files The backup job only knew the shared cluster. With the cloud projects deleted the 20 -supabase-db containers were the sole copy of their data. Now: each stack's postgres database is dumped (4-hourly, or daily when large) and verified like the cluster's, and volumes/storage is rsync-mirrored daily. The script and its cron file live here as templates and are installed by . Co-Authored-By: Claude Fable 5.1 --- dev2/dev2-site | 15 +++ dev2/templates/dev2-pg-backup.cron | 15 +++ dev2/templates/dev2-pg-backup.sh | 178 +++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 dev2/templates/dev2-pg-backup.cron create mode 100644 dev2/templates/dev2-pg-backup.sh diff --git a/dev2/dev2-site b/dev2/dev2-site index 731ea61..c1a3379 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -1178,6 +1178,20 @@ def cmd_firewall(args): ssh_put(tmpl("firewall-data-ports.sh"), "/root/dev2-kit/firewall-data-ports.sh", "0700") print(ssh("root", f"env PORTS={shlex.quote(' '.join(map(str, sorted(ports))))} bash /root/dev2-kit/firewall-data-ports.sh 2>&1")) +def cmd_backup_install(args): + """Install /usr/local/bin/dev2-pg-backup.sh + /etc/cron.d/dev2-pg-backup from the templates + (shared cluster every 4h, per-site Supabase stacks, storage files daily). --run does a + frequent pass right away and shows the log.""" + log("Installing dev2-pg-backup.sh + cron") + ssh_put(tmpl("dev2-pg-backup.sh"), "/usr/local/bin/dev2-pg-backup.sh", "0755") + ssh_put(tmpl("dev2-pg-backup.cron"), "/etc/cron.d/dev2-pg-backup", "0644") + ssh("root", "bash -n /usr/local/bin/dev2-pg-backup.sh && grep -c supabase-db /usr/local/bin/dev2-pg-backup.sh >/dev/null") + note("installed; runs frequent at 0 */4 and full at 05:20 UTC") + if getattr(args, "run", False): + log("Running a frequent pass now (all databases; a few minutes)") + ssh("root", "/usr/local/bin/dev2-pg-backup.sh frequent", check=False, timeout=3600) + print(ssh("root", "tail -n 40 /var/log/dev2-pg-backup.log")) + def cmd_box_tune(args): ssh("root", "install -d -m 700 /root/dev2-kit") ssh_put(tmpl("box-tune.sh"), "/root/dev2-kit/box-tune.sh", "0700") @@ -1431,6 +1445,7 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("registry").set_defaults(fn=cmd_registry) sub.add_parser("firewall").set_defaults(fn=cmd_firewall) + p = sub.add_parser("backup-install"); p.add_argument("--run", action="store_true"); p.set_defaults(fn=cmd_backup_install) sub.add_parser("box-tune").set_defaults(fn=cmd_box_tune) p = sub.add_parser("list"); p.add_argument("--kind"); p.add_argument("--status"); p.set_defaults(fn=cmd_list) p = sub.add_parser("scaffold"); p.add_argument("site"); p.add_argument("--regen", action="store_true", help="regenerate .nixpacks even if the branch already has one"); p.set_defaults(fn=cmd_scaffold) diff --git a/dev2/templates/dev2-pg-backup.cron b/dev2/templates/dev2-pg-backup.cron new file mode 100644 index 0000000..a2d778d --- /dev/null +++ b/dev2/templates/dev2-pg-backup.cron @@ -0,0 +1,15 @@ +# Backups for the self-hosted cluster, the per-site Supabase stacks and their storage +# files. Managed by cli-tools `dev2-site backup-install`; do not edit on the box. +# +# frequent (every 4h): everything that cannot be re-derived. Full dumps of the +# small databases; the bulk catalogue tables are excluded from nichedb and +# rssamplifier because they are re-importable from public sources. Measured +# at ~700 MB and ~50 seconds per run, so four-hourly is cheap. +# +# full (daily): the complete copies including those bulk tables, kept 2 deep. +# Six full dumps a day of a 165 GB database would outgrow the disk in days, +# which is why the frequent job excludes them. +# +# 05:20 is after the 04:17 docker build cache prune, so they do not overlap. +0 */4 * * * root /usr/local/bin/dev2-pg-backup.sh frequent +20 5 * * * root /usr/local/bin/dev2-pg-backup.sh full diff --git a/dev2/templates/dev2-pg-backup.sh b/dev2/templates/dev2-pg-backup.sh new file mode 100644 index 0000000..2bdffe4 --- /dev/null +++ b/dev2/templates/dev2-pg-backup.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# +# Backups for the self-hosted cluster on dev2, the per-site Supabase stacks and +# their storage files. Source of truth: cli-tools dev2/templates/dev2-pg-backup.sh, +# installed by `dev2-site backup-install`; do not edit on the box. +# +# dev2-pg-backup.sh frequent every 4 hours (cron) +# dev2-pg-backup.sh full once a day (cron) +# +# WHY TWO MODES. "Back up every database every four hours" is the right +# instinct but not literally affordable: nichedb is ~165 GB and rssamplifier +# will be ~40-50 GB, and six full dumps a day of those would outgrow the disk +# in days. The split keeps the 4-hour recovery point for everything that +# cannot be re-derived, and takes the bulk catalogues once a day. +# +# The excluded tables are all re-importable from public sources. Critically, +# `sources` in nichedb is NOT excluded: it holds the adapter config and the +# import cursors, which is what makes re-running an importer cheap rather than +# a restart from zero. Scoping agreed with the session that owns those +# databases; do not widen the exclusions without asking it. +# +# RETENTION is 7 days for both classes. That is cheap for the frequent dumps +# (~700 MB a run) and expensive for the daily fulls, because seven copies of +# the bulk databases is hundreds of GB. The script therefore warns when the +# backup directory passes BACKUP_WARN_GB, rather than letting backups cause +# the disk outage they exist to protect against. +# +# Every dump is checked with `pg_restore --list` before it counts as good. A +# pg_dump can exit 0 and still be truncated if the disk fills, and a backup +# that cannot be read is worse than none because it buys false confidence. +set -uo pipefail + +MODE=${1:-frequent} +OUT=${OUT:-/var/backups/postgres} +LOG=${LOG:-/var/log/dev2-pg-backup.log} +# Both classes roll on age, not count: "7 days of history" should mean the +# same thing whichever dump you reach for. +KEEP_FREQUENT_DAYS=${KEEP_FREQUENT_DAYS:-7} +KEEP_FULL_DAYS=${KEEP_FULL_DAYS:-7} +# Backups that quietly eat the disk would cause the outage they exist to +# prevent. Flag it well before the disk alarm would. +BACKUP_WARN_GB=${BACKUP_WARN_GB:-450} +# A database with no policy that is bigger than this gets flagged rather than +# silently dumped in full every four hours. +UNPOLICIED_WARN_GB=${UNPOLICIED_WARN_GB:-5} + +mkdir -p "$OUT" +stamp=$(date -u +%Y%m%d-%H%M%S) +say() { echo "$(date -u '+%F %T') [$MODE] $*" >> "$LOG"; } +alert() { logger -t dev2-pg-backup -p daemon.err "$*"; say "ALERT $*"; } + +psql_q() { docker exec -i supabase-db psql -U postgres -h localhost -d postgres -X -At -c "$1" 2>/dev/null; } + +# Tables excluded from the FREQUENT dump of each database. Everything here is +# re-derivable from public dumps or re-crawlable; everything not here is not. +exclusions_for() { + case "$1" in + nichedb) echo "public.items" ;; + rssamplifier) echo "public.feed_items public.item_extracts public.feed_keywords" ;; + *) echo "" ;; + esac +} + +# Databases big enough that a full copy belongs in the daily job, not the +# 4-hourly one. +is_bulk() { [ -n "$(exclusions_for "$1")" ]; } + +dbs=$(psql_q "select datname from pg_database where not datistemplate and datallowconn order by datname") +[ -n "$dbs" ] || { alert "cannot list databases — postgres unreachable"; exit 1; } + +rc=0 +for db in $dbs; do + size_gb=$(psql_q "select (pg_database_size('$db')/1024/1024/1024)::int") + excl=$(exclusions_for "$db") + + if [ "$MODE" = full ]; then + # The daily job only exists to capture the bulk tables the frequent job + # skips. Databases with no exclusions are already complete every 4 hours. + is_bulk "$db" || continue + f="$OUT/${db}-full-${stamp}.dump" + args=() + else + f="$OUT/${db}-${stamp}.dump" + args=() + for t in $excl; do args+=(--exclude-table="$t"); done + # An unknown database that is large would quietly cost a full dump six + # times a day. Back it up, but say so. + if [ -z "$excl" ] && [ "${size_gb:-0}" -ge "$UNPOLICIED_WARN_GB" ]; then + alert "$db is ${size_gb}GB with no exclusion policy — it is being dumped in full every 4h; add a policy to exclusions_for()" + fi + fi + + if docker exec -i supabase-db pg_dump -U postgres -h localhost -Fc "${args[@]}" -d "$db" > "$f" 2>>"$LOG"; then + sz=$(du -h "$f" | cut -f1) + if docker exec -i supabase-db pg_restore --list < "$f" >/dev/null 2>&1; then + say "ok $db $sz${excl:+ (excluding:$excl)}" + else + alert "$db dump is unreadable by pg_restore" + rc=1 + fi + else + alert "pg_dump failed for $db" + rm -f "$f" + rc=1 + fi +done + +# Per-site self-hosted Supabase stacks (-supabase-db). Their cloud projects are +# deleted, so these dumps are the only copy of auth, storage metadata and app data. +# Whole `postgres` database as supabase_admin (the superuser in the docker image); +# small stacks every 4h, anything over UNPOLICIED_WARN_GB daily only. +stack_names="" +for c in $(docker ps --format '{{.Names}}' | grep -E -- '-supabase-db$' | sort); do + slug=${c%-supabase-db}; name="${slug}-supabase"; stack_names="$stack_names $name" + size_gb=$(docker exec -i "$c" psql -U supabase_admin -h localhost -d postgres -X -At -c "select (pg_database_size('postgres')/1024/1024/1024)::int" 2>/dev/null) + if [ "$MODE" = full ]; then + [ "${size_gb:-0}" -ge "$UNPOLICIED_WARN_GB" ] || continue + f="$OUT/${name}-full-${stamp}.dump" + else + if [ "${size_gb:-0}" -ge "$UNPOLICIED_WARN_GB" ]; then say "skip $name (${size_gb}GB, daily full only)"; continue; fi + f="$OUT/${name}-${stamp}.dump" + fi + if docker exec -i "$c" pg_dump -U supabase_admin -h localhost -Fc -d postgres > "$f" 2>>"$LOG"; then + if docker exec -i "$c" pg_restore --list < "$f" >/dev/null 2>&1; then + say "ok $name $(du -h "$f" | cut -f1)" + else + alert "$name dump is unreadable by pg_restore"; rc=1 + fi + else + alert "pg_dump failed for $name"; rm -f "$f"; rc=1 + fi +done + +# Storage object files (Supabase Storage keeps them on disk under each stack's +# volumes/storage, the shared cluster's included). Mirrored once a day, never +# deleting on the mirror side, so a deleted object survives until the mirror is +# rebuilt by hand. +if [ "$MODE" = full ]; then + for d in /home/anthony/www/*/supabase/volumes/storage; do + [ -d "$d" ] || continue + site=$(basename "$(dirname "$(dirname "$(dirname "$d")")")") + mkdir -p "$OUT/storage/$site" + if rsync -a "$d/" "$OUT/storage/$site/" 2>>"$LOG"; then + say "ok storage $site $(du -sh "$OUT/storage/$site" | cut -f1)" + else + alert "rsync failed for $site storage"; rc=1 + fi + done +fi + +# Retention: both classes roll on age, 7 days each by default. +find "$OUT" -name '*-[0-9]*.dump' ! -name '*-full-*' -mtime +"$KEEP_FREQUENT_DAYS" -delete 2>/dev/null + +# Fulls also roll on age, but never delete the newest one for a database. If +# the daily job were broken for a fortnight, pure age-based expiry would +# cheerfully leave that database with no complete backup at all — exactly when +# you most need one. +for db in $dbs $stack_names; do + newest=$(ls -1t "$OUT/${db}-full-"*.dump 2>/dev/null | head -1) + while IFS= read -r old; do + [ -n "$old" ] || continue + [ "$old" = "$newest" ] && continue + rm -f "$old" + done < <(find "$OUT" -name "${db}-full-*.dump" -mtime +"$KEEP_FULL_DAYS" 2>/dev/null) +done + +total=$(du -sh "$OUT" 2>/dev/null | cut -f1) +total_gb=$(du -s --block-size=1G "$OUT" 2>/dev/null | cut -f1) +avail=$(df -h --output=avail / | tail -1 | tr -d ' ') +say "retained $(ls -1 "$OUT"/*.dump 2>/dev/null | wc -l) dumps, $total total, $avail free on /" +if [ "${total_gb:-0}" -ge "$BACKUP_WARN_GB" ]; then + alert "backups are using ${total_gb}GB (warn at ${BACKUP_WARN_GB}GB). Seven days of full dumps of the bulk databases is the likely cause — shorten KEEP_FULL_DAYS or move them off-box." +fi + +# Backups are useless if they are all on the machine that fails. Say so every +# run so it is never quietly forgotten. +say "NOTE same-box only — does not survive losing dev2; off-box copy still missing" +exit $rc From 6064da580e174bcbd24b9cab7600fb5c7df6ec5c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 18:45:00 +0000 Subject: [PATCH 101/104] sites.d: communitygardensasappleseeds.info off skip, moving to dev2 Anthony reversed the stay-on-Railway decision ("move it all off railway"). Zone is at Cloudflare with no token in the vault, so the cert is HTTP-01 after the apex and www A records are pointed at dev2 by hand. Co-Authored-By: Claude Opus 5.5 --- dev2/sites.d/communitygardensasappleseeds.info.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev2/sites.d/communitygardensasappleseeds.info.json b/dev2/sites.d/communitygardensasappleseeds.info.json index 618b5fc..31f8b58 100644 --- a/dev2/sites.d/communitygardensasappleseeds.info.json +++ b/dev2/sites.d/communitygardensasappleseeds.info.json @@ -1,4 +1,4 @@ { - "skip": true, - "notes": "Anthony 2026-09-25: stays on Railway (DNS at Cloudflare). Never retire, never migrate." + "health_path": "/", + "notes": "2026-09-25: was `skip` (stay on Railway, DNS at Cloudflare); Anthony reversed that the same day (\"move it all off railway\"). Zone is at Cloudflare (kobe/maxine), no Cloudflare token in the vault: the `dns` step cannot flip it and Porkbun DNS-01 cannot issue, so the cert is HTTP-01 (`cert` falls back to cmd_cert_http) AFTER the apex and www A records point at dev2 unproxied. Stateless Next site: no database; the Railway volume at /data/db is copied but nothing in the repo reads it." } From d93ae5efc526fce2f2cdc8faa8eac937efb19f98 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 18:51:31 +0000 Subject: [PATCH 102/104] dev2-site: a failing nginx -t no longer reloads; HTTP-01 challenge files world-readable (umask 022) Co-Authored-By: Claude Opus 5.5 --- dev2/dev2-site | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dev2/dev2-site b/dev2/dev2-site index c1a3379..9107d53 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -986,10 +986,10 @@ def cmd_cert_http(s, domains): log(f"Certificate for {', '.join(domains)} (acme.sh, HTTP-01 through nginx, zone not at Porkbun)") conf = "server {\n listen 80;\n listen [::]:80;\n server_name " + " ".join(domains) + ";\n location ^~ /.well-known/acme-challenge/ { root /var/www/acme; default_type \"text/plain\"; }\n location / { return 301 https://$host$request_uri; }\n}\n" ssh_put(conf, f"/etc/nginx/sites-available/{s['site']}", "0644") - ssh("root", f"install -d -m 0755 /var/www/acme && ln -sf /etc/nginx/sites-available/{s['site']} /etc/nginx/sites-enabled/{s['site']} && nginx -t 2>&1 | tail -n1 && systemctl reload nginx") + ssh("root", f"install -d -m 0755 /var/www/acme && ln -sf /etc/nginx/sites-available/{s['site']} /etc/nginx/sites-enabled/{s['site']} && {{ nginx -t 2>/tmp/nginx-t.log || {{ tail -n3 /tmp/nginx-t.log; exit 1; }}; }} && systemctl reload nginx") ds = " ".join(f"-d {shlex.quote(d)}" for d in domains) ssh("root", "test -x /root/.acme.sh/acme.sh || curl -fsSL https://get.acme.sh | env HOME=/root sh -s email=" + ACME_EMAIL + " >/dev/null") - out = ssh("root", f"install -d -m 0750 {cert_dir} && env HOME=/root /root/.acme.sh/acme.sh --issue --server letsencrypt --webroot /var/www/acme {ds} --force >/tmp/acme-{s['site']}.log 2>&1 || {{ tail -n 25 /tmp/acme-{s['site']}.log; exit 1; }}", check=False, timeout=1800) + out = ssh("root", f"install -d -m 0750 {cert_dir} && umask 022 && env HOME=/root /root/.acme.sh/acme.sh --issue --server letsencrypt --webroot /var/www/acme {ds} --force >/tmp/acme-{s['site']}.log 2>&1 || {{ tail -n 25 /tmp/acme-{s['site']}.log; exit 1; }}", check=False, timeout=1800) if out.strip() and "Cert success" not in out: die(out) ssh("root", f"env HOME=/root /root/.acme.sh/acme.sh --install-cert -d {shlex.quote(domains[0])} --fullchain-file {cert_dir}/fullchain.pem --key-file {cert_dir}/privkey.pem --reloadcmd 'systemctl reload nginx' >/dev/null") note(ssh("root", f"openssl x509 -in {cert_dir}/fullchain.pem -noout -subject -enddate | tr '\\n' ' '").strip()) @@ -1049,7 +1049,7 @@ def cmd_vhost(args): conf += "\nserver {\n listen 80;\n listen [::]:80;\n server_name " + p["host"] + ";\n return 301 https://$host$request_uri;\n}\n" log(f"nginx vhost {s['site']} -> 127.0.0.1:{s['port']} ({', '.join(domains)})") ssh_put(conf, f"/tmp/dev2-site-{s['site']}.conf", "0644") - ssh("root", f"f=/etc/nginx/sites-available/{s['site']}; if [ -e $f ]; then n=1; while [ -e $f.bak-$(printf %03d $n) ]; do n=$((n+1)); done; cp -a $f $f.bak-$(printf %03d $n); fi; install -m 0644 /tmp/dev2-site-{s['site']}.conf $f && ln -sf $f /etc/nginx/sites-enabled/{s['site']} && nginx -t 2>&1 | tail -n1 && systemctl reload nginx") + ssh("root", f"f=/etc/nginx/sites-available/{s['site']}; if [ -e $f ]; then n=1; while [ -e $f.bak-$(printf %03d $n) ]; do n=$((n+1)); done; cp -a $f $f.bak-$(printf %03d $n); fi; install -m 0644 /tmp/dev2-site-{s['site']}.conf $f && ln -sf $f /etc/nginx/sites-enabled/{s['site']} && {{ nginx -t 2>/tmp/nginx-t.log || {{ tail -n3 /tmp/nginx-t.log; exit 1; }}; }} && systemctl reload nginx") mark(s["site"], "vhost") def cmd_verify(args): From 4393b0396d8229fe8a251b48c19b92ab59bd6b38 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 18:59:31 +0000 Subject: [PATCH 103/104] sites.d: bittorrented.com (not a Railway site; Supabase stack only, app stays on the DO droplet) Co-Authored-By: Claude Fable 5.1 --- dev2/sites.d/bittorrented.com.json | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 dev2/sites.d/bittorrented.com.json diff --git a/dev2/sites.d/bittorrented.com.json b/dev2/sites.d/bittorrented.com.json new file mode 100644 index 0000000..8f2cf12 --- /dev/null +++ b/dev2/sites.d/bittorrented.com.json @@ -0,0 +1,33 @@ +{ + "site": "bittorrented.com", + "service": "bittorrented.com", + "repo": "profullstack/media-streamer", + "image": null, + "domains": [ + "bittorrented.com", + "www.bittorrented.com" + ], + "db": "supabase", + "db_kinds": [ + "supabase" + ], + "redis": false, + "volumes": [], + "companions": [], + "infra": [], + "project": null, + "project_id": null, + "environment_id": null, + "service_id": null, + "port": 3290, + "cloud_ref": "ussbjnpovrynxyztjeeb", + "app_host": "do (DigitalOcean droplet 137.184.114.110, user ubuntu, systemd bittorrented.service); NOT on dev2", + "notes": "Not a Railway site: only the Supabase stack lives on dev2 (supabase.bittorrented.com). DNS at DigitalOcean, so the cert is HTTP-01. The app, its workers and bitmagnet run on the droplet and were re-pointed by hand; never provision/deploy/dns/cutover this site with the kit.", + "proxies": [ + { + "host": "supabase.bittorrented.com", + "port": 8290, + "max_body": "512m" + } + ] +} From 9995d4b0d9c34b867a0802338f18384192a4b625 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 22:22:38 +0000 Subject: [PATCH 104/104] supabase-load: extensions into the extensions schema (cloud DDL says extensions.gin_trgm_ops); skip generated columns in the URL rewrite Co-Authored-By: Claude Fable 5.1 --- dev2/templates/supabase-load.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev2/templates/supabase-load.sh b/dev2/templates/supabase-load.sh index b1d12e9..e386d04 100755 --- a/dev2/templates/supabase-load.sh +++ b/dev2/templates/supabase-load.sh @@ -36,7 +36,7 @@ fi if [ "$POST_ONLY" = 0 ]; then log "Extensions the cloud had" while read -r e <&3; do [ -n "$e" ] || continue; case $e in plpgsql|pg_graphql|pgsodium|supabase_vault|pg_stat_statements|pgjwt) continue;; esac - psql_admin -v ON_ERROR_STOP=1 -c "create extension if not exists \"$e\" cascade" >/dev/null 2>&1 && echo " $e" || echo " $e: NOT AVAILABLE (dump may fail on objects using it)"; done 3< "$DUMP/extensions.txt" + psql_admin -v ON_ERROR_STOP=1 -c "create extension if not exists \"$e\" with schema extensions cascade" >/dev/null 2>&1 && echo " $e" || { psql_admin -v ON_ERROR_STOP=1 -c "create extension if not exists \"$e\" cascade" >/dev/null 2>&1 && echo " $e (own schema)" || echo " $e: NOT AVAILABLE (dump may fail on objects using it)"; }; done 3< "$DUMP/extensions.txt" log "Schema" grep -qE 'CREATE SCHEMA "?(auth|storage)"?' "$DUMP/schema.sql" && die "schema.sql contains auth/storage DDL" psql_admin -f /dev/stdin < "$DUMP/schema.sql" > "$DUMP/schema.load.log" 2>&1 || true @@ -84,7 +84,7 @@ strict -At <