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 new file mode 100644 index 0000000..cd44731 --- /dev/null +++ b/dev2/README.md @@ -0,0 +1,143 @@ +# 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` | +| `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 | + +| `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) + +``` +/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 +``` + +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. + +## 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", + "env_remove": ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"] } +``` + +### 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. +- `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 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 + +`~/.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. diff --git a/dev2/dev2-site b/dev2/dev2-site new file mode 100755 index 0000000..f2c81e3 --- /dev/null +++ b/dev2/dev2-site @@ -0,0 +1,1324 @@ +#!/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 glob +import json +import os +import re +import secrets +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.parse +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" +# 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 +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): + 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 + time.sleep(3 * (attempt + 1)) + if check and p.returncode != 0: + raise RuntimeError(f"command failed ({p.returncode}): {' '.join(map(shlex.quote, argv))}\n{(p.stdout or '')[-1500:]}\n{(p.stderr or '')[-2000:]}") + return p.stdout + +def ssh_put(content, path, mode="0644", owner=None, user="root"): + """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): + 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") + +# ── 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 + (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 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" + +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) + # 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) + 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"] + # 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") or cfg.get("NIXPACKS_NODE_VERSION") or "22" + nix_env = [f"NIXPACKS_NODE_VERSION={node_v}"] + if s.get("start_command"): cfg["NIXPACKS_START_CMD"] = s["start_command"] + 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 ""): + 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. + pub = public_keys(rvars) + dfp = os.path.join(wt, ".nixpacks", "Dockerfile") + # nixpacks 1.41 installs corepack 0.24, which cannot launch pnpm >= 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 + lines[at:at] = [f"ARG {k}\nENV {k}=${k}" for k in pub] + open(dfp, "w").write("\n".join(lines)) + note(f"build args declared in .nixpacks/Dockerfile: {', '.join(pub)}") + 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('"', '\\"') + '"' + +PUBLIC_PREFIXES = ("NEXT_PUBLIC_", "VITE_", "PUBLIC_", "NUXT_PUBLIC_", "EXPO_PUBLIC_", "REACT_APP_", "SVELTEKIT_PUBLIC_") + +def public_keys(vars_): + return sorted(k for k in vars_ if k.startswith(PUBLIC_PREFIXES) and isinstance(vars_[k], str)) + +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", "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": (s.get("db_sslparams") or sslparams_for(s, st)).split("=")[-1]}[k] + if s["db"] == "turso" and st.get("database_url"): + # After `db-create` + the app's Postgres port: every libsql:// URL becomes the new + # DATABASE_URL, the Turso token goes, DATABASE_URL is set if the app never had one. + for k, v in list(out.items()): + if isinstance(v, str) and (v.startswith("libsql://") or ".turso.io" in v): out[k] = st["database_url"] + if k in ("TURSO_AUTH_TOKEN",): del out[k] + out.setdefault("DATABASE_URL", st["database_url"]) + 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 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 + +def render_compose(s, st, app_port_inside, build_args=()): + 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:"] + image_only = bool(s.get("image")) and not s.get("repo") + def service(name, envfile, command=None, ports=True, vols=()): + 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}") + else: + y.append(f" build:\n context: ./app\n dockerfile: {df}") + 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") + 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:") + 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"]: + # 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", [])) + 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\" }") + 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 + +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"]) + 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 "/" + st["dockerfile"] = None + else: + 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_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 + # deploy key (the alias keeps one key per repo); some orgs (moshcoder) disable deploy + # keys, which only matters when the repo is private. + if image_only: + repo_url, key_alias = "", "" + else: + private = sh(["gh", "api", f"repos/{s['repo']}", "--jq", ".private"], check=False).strip() == "true" + key_alias = f"github.com-{s['repo'].replace('/', '__')}" + repo_url = f"git@{key_alias}:{s['repo']}.git" if private else f"https://github.com/{s['repo']}.git" + deploy_env = f"SITE={s['site']}\nREPO={repo_url}\nBRANCH={branch}\nAPP_PORT={s['port']}\nBUILD_SERVICES=\"{build_services}\"\nHEALTH_PATH={hp}\nIMAGE_ONLY={'1' if image_only else '0'}\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") + if image_only: + mark(s["site"], "provision", root=root, branch="", health_path=hp, dockerfile=None); return + # 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") + # 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: + try: + 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") + except RuntimeError as e: + if private: die(f"{s['repo']} is private and GitHub refused a deploy key ({str(e)[-200:].strip()}); enable deploy keys for the org or make the repo public") + note("GitHub refused a deploy key (org setting); the repo is public, so the box clones over https") + 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) + 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 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): + """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"]) + 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, + # 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"]) + 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 = {} + 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 + 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}") + # 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_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") + 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:-$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") + 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_db_create(args): + """An empty database in the shared cluster and its DATABASE_URL in state (Turso ports: + the app's Postgres schema is applied by the app or by `libsql-pg convert-schema`).""" + s = site(args.site); st = load_state(s["site"]) + db = db_name_for(s); pw = cluster_pg_password() + if pg_admin(f"select count(*) from pg_database where datname='{db}'").strip() == "0": + pg_admin(f'create database "{db}" owner postgres'); note(f"created database {db}") + else: note(f"database {db} exists") + pg_admin("create extension if not exists citext; create extension if not exists pg_trgm; create extension if not exists pgcrypto", db) + st.update({"db_name": db, "db_user": "postgres", "db_password": pw, + "database_url": f"postgres://postgres:{pw}@{DEV2_HOST}:5432/{db}?{s.get('db_sslparams') or sslparams_for(s, st)}"}) + save_state(s["site"], st); mark(s["site"], "db-create") + print(st["database_url"].replace(pw, "***")) + note("the URL with the password is in the site's state file; `provision` renders it into app.env") + +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) + 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 + +def cmd_deploy(args): + s = site(args.site); st = load_state(s["site"]) + # 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:]) + 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(): + 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): + """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 [] + 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 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)") + # 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) + # 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) + +def cmd_vhost(args): + s = site(args.site) + 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;").replace("client_max_body_size " + p.get("max_body", "512m") + ";", "client_max_body_size " + p.get("max_body", "512m") + ";\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") + mark(s["site"], "vhost") + +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"]: + # 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}"] + 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) + 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|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|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") + +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) + 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 ("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") + 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, regen=False, ignore_logs=False) + 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_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_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)") + 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") + +# --------------------------------------------------------------- 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']}") + 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") + 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 = {} + # 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"]; 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 {} + 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"]))) + 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() + 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).") + +# ----------------------------------------------------------------- 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) + 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) + 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) + except RuntimeError as e: die(str(e)) + +if __name__ == "__main__": + main() diff --git a/dev2/sites.d/agenticjobs.work.json b/dev2/sites.d/agenticjobs.work.json new file mode 100644 index 0000000..be5e75e --- /dev/null +++ b/dev2/sites.d/agenticjobs.work.json @@ -0,0 +1,6 @@ +{ + "env_overrides": { + "OBSCURA_MCP_URL": "", + "PGSSLMODE": "require" + } +} 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"} 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 new file mode 100644 index 0000000..a5f50d8 --- /dev/null +++ b/dev2/sites.d/cigarunderground.org.json @@ -0,0 +1,4 @@ +{ + "health_path": "/healthz", + "db_sslparams": "sslmode=require" +} 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/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/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"} 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"} diff --git a/dev2/sites.d/mynaposter.com.json b/dev2/sites.d/mynaposter.com.json new file mode 100644 index 0000000..546e1e3 --- /dev/null +++ b/dev2/sites.d/mynaposter.com.json @@ -0,0 +1,32 @@ +{ + "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", + "start_command": "bun apps/web/src/build.ts && bun apps/web/src/server.ts", + "companion_commands": { + "mynaposter-api": "bun apps/api/src/server.ts" + }, + "node_version": "22" +} \ No newline at end of file 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/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/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/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"] } diff --git a/dev2/sites.d/threatcrush.com.json b/dev2/sites.d/threatcrush.com.json new file mode 100644 index 0000000..150c502 --- /dev/null +++ b/dev2/sites.d/threatcrush.com.json @@ -0,0 +1,15 @@ +{ + "proxies": [ + { + "host": "supabase.threatcrush.com", + "port": 8270, + "max_body": "512m" + } + ], + "env_overrides": { + "SUPABASE_DB_PASSWORD": "secret:SUPABASE_DB_PASSWORD", + "NEXT_PUBLIC_SUPABASE_URL": "https://supabase.threatcrush.com", + "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 new file mode 100644 index 0000000..1b556a3 --- /dev/null +++ b/dev2/sites.d/tipoffwatch.com.json @@ -0,0 +1,6 @@ +{ + "env_overrides": { + "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. The value is the retired Railway Postgres credential, so it lives in credentials.json as tipoffwatch.com.PLAYLIST_SECRET, never here." +} 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/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 diff --git a/dev2/templates/deploy-app.sh b/dev2/templates/deploy-app.sh new file mode 100755 index 0000000..65ac926 --- /dev/null +++ b/dev2/templates/deploy-app.sh @@ -0,0 +1,173 @@ +#!/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" +: "${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} + +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)" + +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 "") + +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" +# 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" +# Public build-time variables (NEXT_PUBLIC_*, VITE_*, ...) come from app.env through the +# 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 + +# 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 + +# 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 $(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" + 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" + { + 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/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/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; + } +} diff --git a/dev2/templates/supabase-load.sh b/dev2/templates/supabase-load.sh new file mode 100755 index 0000000..826a200 --- /dev/null +++ b/dev2/templates/supabase-load.sh @@ -0,0 +1,82 @@ +#!/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 +# 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." 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..2ca5892 --- /dev/null +++ b/dev2/templates/supabase-stack.sh @@ -0,0 +1,241 @@ +#!/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 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 + +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); +}