mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-11-01 16:31:09 +00:00
* 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.
335 lines
13 KiB
Python
Executable File
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()
|