mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-08-02 14:25:48 +00:00
9c6af146e6
* ops: let a function be retired, not just deployed The guarded deploy could create and update, never remove. rsync runs without --delete on purpose, so one project cannot wipe another's functions — but the consequence was that nothing ever left. A function deleted from its repository kept serving traffic, and nobody would know. Found the concrete case on 2026-08-01: email-verification had been removed from pwap on 2026-07-30 and was still live and reachable with only the anon key, running under the service-role key with Access-Control-Allow-Origin *. Its verify path sets profiles.email_verified for whatever user_id a token maps to. It was not exploitable — the send path calls getUserByEmail, which the SDK does not have, so no tokens could be minted and the table was empty. Broken is not the same as safe; it was protected by a bug, not by design. - --audit compares the volume against the registry both directions and exits 1 on drift. Runs after every deploy too, so a leftover is stated rather than invisible. - --retire moves functions into /opt/supabase-self-hosted/retired-functions/ with a timestamp. Quarantined, not deleted: retiring the wrong thing has to be one command to undo, and that copy is the only remaining record of what was serving traffic. - _cloud_hosted names now count as declared. They live on the cloud project and are owned by nobody here, so without this the audit would have offered to retire ask and telegram-bot, which look live. Applied: email-verification retired and dropped from the registry; the audit is clean; two-factor-auth and the miniapp's check-deposits both still answer. * ops: catch the function a project still serves but no longer ships The retire mechanism closed the second half of the problem. This closes the first: nothing could detect that email-verification needed retiring. It was never an orphan. The registry listed it as owned and current, the volume agreed, and both were right about each other — the repository was the one that had dropped it. Volume-vs-registry compares two of the three and cannot see a disagreement in the third. It only became visible as drift because I removed it from the registry by hand, which means the mechanism did not find it; I did. A deploy is the one moment we hold the project's actual source next to the registry and the volume, so it is the only place all three can be compared. Anything the project owns, that is on disk, and that is absent from what it just shipped, is now named with the command to retire it. Warns rather than refuses: deleting a function and shipping an unrelated change in the same commit is ordinary, and blocking that deploy until someone retires would punish the correct move. It is printed in the deploy output and recorded as an ABANDONED line in the deploy log. Verified against a source tree missing eight of pwap-web's functions: all eight named, deploy still allowed.
325 lines
13 KiB
Python
Executable File
325 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"
|
|
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 that were moved to the cloud project. They are declared, just not
|
|
# owned by anyone here — without this they read as unaccounted-for and the
|
|
# audit would offer to retire two live-looking directories.
|
|
cloud = data.get("_cloud_hosted") or {}
|
|
for name in cloud.get("names", []):
|
|
owner_of.setdefault(name, "_cloud_hosted")
|
|
|
|
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.
|
|
|
|
`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", "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)
|
|
|
|
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)
|
|
if owner is not None and owner != project:
|
|
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()
|