Files
pezkuwi-telegram-miniapp/ops/supabase-deploy-functions
SatoshiQaziMuhammed 07cd642051 ops: run the Telegram bots and the assistant on vps3 (#46)
* ops: run the Telegram bots and the assistant on vps3

telegram-bot and ask ran on the free cloud project, which paused itself
after a quiet week; every bot went silent and the deploy job failed against
the paused project. They now run in their own edge-runtime container on vps3
(bots/), from /opt/pezkuwi-bots.

Separate from the shared functions runtime because that one verifies a JWT
on every request, which Telegram cannot send, and holds the platform's wallet
keys, which a public bot feeding user text to a language model should not
sit next to. The new runtime holds only the keys the two functions read.

- telegram-bot refuses updates without Telegram's secret-token header
  (TELEGRAM_WEBHOOK_SECRET); the main slot reads TELEGRAM_BOT_TOKEN_MAIN,
  since TELEGRAM_BOT_TOKEN on vps3 is the miniapp's sign-in check.
- nginx keeps the public URL shape:
  supabase.pezkuwichain.io/functions/v1/{telegram-bot,ask}.
- deploy.yml ships them with a new bots-deploy verb on the existing deploy
  key; the host script accepts exactly main, telegram-bot and ask, restarts
  the container and fails unless the bot comes back refusing unsigned updates.
- Registry: _cloud_hosted becomes _hosted_elsewhere; the gate refuses those
  names in the shared volume and reports any copy there as stale.

* ask: send the legacy service key as a bearer token on vps3

The vps3 gateway has no opaque sb_secret keys and honours the service_role JWT
only as a bearer token; sent on apikey alone it reads as anon, the limiter's
count fails, and every question is refused as rate-limited.
2026-10-22 13:51:35 +04:00

335 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""Guarded deploy for the shared Supabase edge-function volume.
Several independent projects deploy into one edge-runtime volume. Before this
gate existed they rsynced whole trees into it, so whoever deployed last silently
overwrote any function name they happened to share. That is how the miniapp's
telegram-auth disappeared on 2026-06-28 and stayed broken for a month.
This is the only supported way to write into the volume. It refuses to touch a
directory the calling project does not own, and it refuses names that are not in
the registry at all, so a new collision cannot be introduced by accident.
Deploying does not delete, so that one project cannot wipe another's functions.
The cost is that nothing ever leaves on its own: a function deleted from its
repository keeps serving traffic until someone retires it here. `--audit` makes
that visible and `--retire` acts on it.
supabase-deploy-functions --project <name> --src <dir> [--restart] [--dry-run]
supabase-deploy-functions --project <name> --audit
supabase-deploy-functions --project <name> --retire <fn>... [--restart]
Exit codes: 0 ok, 1 refused / audit found drift, 2 usage/internal error.
"""
import argparse
import fcntl
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timezone
REGISTRY = "/opt/supabase-self-hosted/functions-registry.json"
ELSEWHERE = "_hosted_elsewhere"
VOLUME = "/opt/supabase-self-hosted/docker/volumes/functions"
LOG = "/var/log/supabase-function-deploys.log"
LOCK = "/var/lock/supabase-function-deploy.lock"
RETIRED = "/opt/supabase-self-hosted/retired-functions"
CONTAINER = "supabase-edge-functions"
COMPOSE_DIR = "/opt/supabase-self-hosted/docker"
def fail(msg, code=1):
print("ERROR: " + msg, file=sys.stderr)
sys.exit(code)
def load_registry():
try:
with open(REGISTRY) as fh:
data = json.load(fh)
except FileNotFoundError:
fail("registry not found at " + REGISTRY, 2)
except json.JSONDecodeError as exc:
fail("registry is not valid JSON: %s" % exc, 2)
projects = data.get("projects")
if not isinstance(projects, dict):
fail("registry has no 'projects' object", 2)
owner_of = {}
for project, names in projects.items():
for name in names:
if name in owner_of:
fail("registry is inconsistent: '%s' is claimed by both '%s' and '%s'"
% (name, owner_of[name], project), 2)
owner_of[name] = project
# Functions served by another runtime, not from this volume. They are declared
# so no project can claim the name here, but nobody deploys them into it.
elsewhere = data.get("_hosted_elsewhere") or {}
for name in elsewhere.get("names", []):
owner_of.setdefault(name, ELSEWHERE)
return projects, owner_of
def container_running():
out = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER],
capture_output=True, text=True,
)
return out.returncode == 0 and out.stdout.strip() == "true"
def restart_runtime():
"""Recreate the edge runtime, tolerating a racing restart.
Only the runtime: --no-deps. Without it compose also starts what functions depends on
(kong, and through kong studio); with studio down that failed with "dependency failed
to start" after the old runtime had already been removed, which left the new one in
Created and every edge function answering 503.
`docker compose up --force-recreate` fails with "No such container" when
something else recreated the container between compose reading state and
acting on it — a hand-run restart, or another project's deploy. The deploy
itself already succeeded at that point, so failing the whole run over it
reports a false failure. Retry, then fall back to checking whether the
runtime is actually up.
"""
for attempt in (1, 2, 3):
r = subprocess.run(
["docker", "compose", "up", "-d", "--force-recreate", "--no-deps", "functions"],
cwd=COMPOSE_DIR, capture_output=True, text=True,
)
if r.returncode == 0:
print("Edge runtime recreated.")
return
print("restart attempt %d failed: %s" % (attempt, r.stderr.strip()[:200]), file=sys.stderr)
time.sleep(3)
# Compose kept failing. If the runtime is up it is serving the files we just
# wrote, so the deploy stands; say so rather than reporting a false failure.
if container_running():
print("WARNING: could not recreate the runtime, but %s is running and "
"serving the deployed files." % CONTAINER, file=sys.stderr)
log_line("%s RESTART-DEGRADED container=%s" % (
datetime.now(timezone.utc).isoformat(timespec="seconds"), CONTAINER))
return
fail("restart failed and %s is not running" % CONTAINER, 2)
def log_line(text):
try:
with open(LOG, "a") as fh:
fh.write(text + "\n")
except OSError:
pass # logging must never block a deploy
def on_disk():
if not os.path.isdir(VOLUME):
return set()
return {
d for d in os.listdir(VOLUME)
if os.path.isdir(os.path.join(VOLUME, d)) and not d.startswith(".")
}
def audit(owner_of):
"""Compare the volume against the registry, both directions.
Deploying rsyncs without --delete, deliberately, so one project cannot wipe
another's functions. The cost is that nothing ever leaves: a function deleted
from its repository keeps serving traffic. That is not hypothetical — on
2026-08-01 `email-verification` was still live and reachable weeks after it
was removed from pwap, running under the service-role key with
Access-Control-Allow-Origin *.
Returns the unaccounted-for names.
"""
disk, declared = on_disk(), set(owner_of)
extra, missing = sorted(disk - declared), sorted(declared - disk)
# A copy of a function served elsewhere is never the live one; it is stale.
stale = sorted(n for n in disk if owner_of.get(n) == ELSEWHERE)
missing = [n for n in missing if owner_of.get(n) != ELSEWHERE]
extra = sorted(set(extra) | set(stale))
if extra:
print("\nVolume holds %d function(s) the registry does not account for:" % len(extra))
for name in extra:
print(" %s <- retire it, or add it to the registry under an owner" % name)
if missing:
print("\nRegistry declares %d function(s) that are not on disk:" % len(missing))
for name in missing:
print(" %s <- will appear on that project's next deploy" % name)
if not extra and not missing:
print("Volume matches the registry.")
return extra
def retire(names, project, owner_of):
"""Move functions out of the volume, into a dated quarantine directory.
Quarantined rather than deleted: retiring the wrong thing has to be a
one-command mistake to undo, and the copy is the only remaining record of
what was serving traffic.
"""
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
for name in names:
owner = owner_of.get(name)
# A stale copy of a function served elsewhere belongs to no one here.
if owner is not None and owner not in (project, ELSEWHERE):
fail("'%s' belongs to '%s' — refusing. Retire it from that project."
% (name, owner))
if not os.path.isdir(os.path.join(VOLUME, name)):
fail("'%s' is not in the volume" % name)
os.makedirs(RETIRED, exist_ok=True)
for name in names:
dst = os.path.join(RETIRED, "%s-%s" % (name, stamp))
shutil.move(os.path.join(VOLUME, name), dst)
print("Retired %s -> %s" % (name, dst))
log_line("%s RETIRED project=%s function=%s to=%s" % (
datetime.now(timezone.utc).isoformat(timespec="seconds"), project, name, dst))
still_declared = [n for n in names if n in owner_of]
if still_declared:
print("\nStill listed in the registry — remove them there too, or the next "
"audit reports them as missing: %s" % ", ".join(still_declared))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--project", required=True)
ap.add_argument("--src", help="staging dir holding one subdir per function")
ap.add_argument("--restart", action="store_true", help="recreate the edge runtime afterwards")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--audit", action="store_true",
help="compare the volume against the registry and exit")
ap.add_argument("--retire", nargs="+", metavar="NAME",
help="move these functions out of the volume into quarantine")
args = ap.parse_args()
projects, owner_of = load_registry()
if args.audit:
sys.exit(1 if audit(owner_of) else 0)
if args.retire:
if args.project not in projects:
fail("unknown project '%s'" % args.project, 2)
retire(args.retire, args.project, owner_of)
if args.restart:
restart_runtime()
return
if not args.src:
fail("--src is required unless --audit or --retire is given", 2)
if args.project not in projects:
fail("unknown project '%s'. Known: %s" % (args.project, ", ".join(sorted(projects))), 2)
owned = set(projects[args.project])
if not os.path.isdir(args.src):
fail("source dir not found: " + args.src, 2)
incoming = sorted(
d for d in os.listdir(args.src)
if os.path.isdir(os.path.join(args.src, d)) and not d.startswith("__")
)
if not incoming:
fail("no function directories found under " + args.src, 2)
# Validate everything before writing anything: a partial deploy is worse
# than a refused one.
foreign, unknown = [], []
for name in incoming:
if name in owned:
continue
if name in owner_of:
foreign.append((name, owner_of[name]))
else:
unknown.append(name)
if foreign or unknown:
print("Refusing to deploy — nothing was written.\n", file=sys.stderr)
for name, owner in foreign:
print(" '%s' belongs to '%s', not '%s'" % (name, owner, args.project), file=sys.stderr)
for name in unknown:
print(" '%s' is not in the registry. Add it to %s under an owner first."
% (name, REGISTRY), file=sys.stderr)
print("\nRegistry: " + REGISTRY, file=sys.stderr)
log_line("%s REFUSED project=%s foreign=%s unknown=%s" % (
datetime.now(timezone.utc).isoformat(timespec="seconds"),
args.project, [n for n, _ in foreign], unknown))
sys.exit(1)
# Owned, on disk, and no longer in the project's source. Neither the deploy
# nor the volume-vs-registry audit could see this: the registry said the name
# was current and the volume agreed, while the repository had dropped it. That
# is exactly how email-verification kept serving traffic for two days after it
# was deleted from pwap. A deploy is the one moment we hold the project's real
# source, so it is the only place the three can be compared.
abandoned = sorted(n for n in owned - set(incoming) if os.path.isdir(os.path.join(VOLUME, n)))
if abandoned:
print("\n%s still serves %d function(s) it no longer ships:" % (args.project, len(abandoned)))
for name in abandoned:
print(" %s <- delete it here too: --project %s --retire %s"
% (name, args.project, name))
print(" (or restore it to the source tree if it was dropped by mistake)")
log_line("%s ABANDONED project=%s functions=%s" % (
datetime.now(timezone.utc).isoformat(timespec="seconds"),
args.project, ",".join(abandoned)))
if args.dry_run:
print("dry-run OK — %s may deploy: %s" % (args.project, ", ".join(incoming)))
return
# Two projects deploy into this volume and both restart the same runtime, so
# serialise from here on. Without it a second deploy can recreate the
# container while the first is mid-restart, which is exactly what turned a
# successful deploy into a failed job on 2026-07-30.
lock_fd = os.open(LOCK, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("another deploy is in progress, waiting for it to finish...")
fcntl.flock(lock_fd, fcntl.LOCK_EX)
os.makedirs(VOLUME, exist_ok=True)
for name in incoming:
src = os.path.join(args.src, name)
dst = os.path.join(VOLUME, name)
# Stage beside the target, then swap, so a reader never sees a half-written function.
staged = tempfile.mkdtemp(prefix=".deploy-%s-" % name, dir=VOLUME)
try:
tree = os.path.join(staged, name)
shutil.copytree(src, tree)
old = dst + ".old"
if os.path.exists(old):
shutil.rmtree(old)
if os.path.exists(dst):
os.rename(dst, old)
os.rename(tree, dst)
if os.path.exists(old):
shutil.rmtree(old)
finally:
shutil.rmtree(staged, ignore_errors=True)
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
log_line("%s DEPLOYED project=%s functions=%s" % (stamp, args.project, ",".join(incoming)))
print("Deployed %d function(s) for %s: %s" % (len(incoming), args.project, ", ".join(incoming)))
audit(owner_of)
if args.restart:
restart_runtime()
if __name__ == "__main__":
main()