diff --git a/dev2/dev2-site b/dev2/dev2-site index f2c81e3..9107d53 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]) @@ -585,7 +614,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 +624,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 +642,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\" }") @@ -636,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']}" @@ -643,7 +687,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) @@ -656,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 @@ -675,7 +719,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) @@ -705,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 @@ -716,13 +761,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): @@ -935,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()) @@ -962,13 +1013,21 @@ 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 # 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) @@ -990,12 +1049,12 @@ 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): 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") @@ -1089,6 +1148,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), @@ -1118,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") @@ -1125,6 +1199,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"] @@ -1154,7 +1229,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") @@ -1171,10 +1246,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_ @@ -1187,7 +1266,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") @@ -1226,7 +1305,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'} 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") @@ -1248,23 +1328,38 @@ 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) + # 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 {}), [] @@ -1282,7 +1377,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}") @@ -1290,6 +1386,58 @@ 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: 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: 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) + # 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)}") + # 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. + 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(): @@ -1297,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) @@ -1310,10 +1459,10 @@ 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.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/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"] } 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"] } 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)." +} diff --git a/dev2/sites.d/app.moshcode.sh.json b/dev2/sites.d/app.moshcode.sh.json index b631a9f..4b95cda 100644 --- a/dev2/sites.d/app.moshcode.sh.json +++ b/dev2/sites.d/app.moshcode.sh.json @@ -1 +1,5 @@ -{"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", + "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" +} 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" + } +} 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" + } + ] +} 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" + } +} 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" + } } 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"] } diff --git a/dev2/sites.d/coinpayportal.com.json b/dev2/sites.d/coinpayportal.com.json index 8114183..8e45e68 100644 --- a/dev2/sites.d/coinpayportal.com.json +++ b/dev2/sites.d/coinpayportal.com.json @@ -1 +1,30 @@ -{ "health_path": "/api/health" } +{ + "health_path": "/api/health", + "proxies": [ + { + "host": "supabase.coinpayportal.com", + "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`.", + "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/communitygardensasappleseeds.info.json b/dev2/sites.d/communitygardensasappleseeds.info.json new file mode 100644 index 0000000..31f8b58 --- /dev/null +++ b/dev2/sites.d/communitygardensasappleseeds.info.json @@ -0,0 +1,4 @@ +{ + "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." +} 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" + } +} 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" +} 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/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/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" + ] +} 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" + } +} 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" + } +} 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" + } +} 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" + } +} 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" + ] +} 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"] } 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" +} 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)" } 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" } 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" + } +} diff --git a/dev2/sites.d/phonenumbers.bot.json b/dev2/sites.d/phonenumbers.bot.json new file mode 100644 index 0000000..5768e42 --- /dev/null +++ b/dev2/sites.d/phonenumbers.bot.json @@ -0,0 +1,15 @@ +{ + "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", + "SUPABAsE_URL": "https://supabase.phonenumbers.bot", + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD" + } +} 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" + } +} 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" + } +} 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" + } +} 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"] } diff --git a/dev2/sites.d/saasrow.com.json b/dev2/sites.d/saasrow.com.json new file mode 100644 index 0000000..8a1640e --- /dev/null +++ b/dev2/sites.d/saasrow.com.json @@ -0,0 +1,22 @@ +{ + "proxies": [ + { + "host": "supabase.saasrow.com", + "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" + } +} diff --git a/dev2/sites.d/sh1pt.com.json b/dev2/sites.d/sh1pt.com.json new file mode 100644 index 0000000..6534fa5 --- /dev/null +++ b/dev2/sites.d/sh1pt.com.json @@ -0,0 +1,14 @@ +{ + "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", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.sh1pt.com" + } +} diff --git a/dev2/sites.d/smshub.dev.json b/dev2/sites.d/smshub.dev.json new file mode 100644 index 0000000..c35cad3 --- /dev/null +++ b/dev2/sites.d/smshub.dev.json @@ -0,0 +1,20 @@ +{ + "proxies": [ + { + "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." +} 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"] } 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" } diff --git a/dev2/sites.d/ugig.net.json b/dev2/sites.d/ugig.net.json new file mode 100644 index 0000000..964c788 --- /dev/null +++ b/dev2/sites.d/ugig.net.json @@ -0,0 +1,15 @@ +{ + "proxies": [ + { + "host": "supabase.ugig.net", + "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" + } +} 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" + } +} 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 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 diff --git a/dev2/templates/supabase-load.sh b/dev2/templates/supabase-load.sh index 826a200..e386d04 100755 --- a/dev2/templates/supabase-load.sh +++ b/dev2/templates/supabase-load.sh @@ -6,33 +6,62 @@ 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" 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" 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 <&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 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\" 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 - 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" + 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 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" + 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 @@ -47,15 +76,15 @@ 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, 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 0fb0886..8bce031 100755 --- a/dev2/templates/supabase-pull.sh +++ b/dev2/templates/supabase-pull.sh @@ -28,14 +28,25 @@ 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)" 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 +# 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 "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" @@ -53,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