From cfbc4f1612fafe694a4d075c3dde01482e3938e7 Mon Sep 17 00:00:00 2001 From: "Igor K." Date: Fri, 18 Sep 2026 23:02:40 +0300 Subject: [PATCH] feat: supaspin, on-demand throwaway Supabase instances on microk8s Adds a terminal app that creates, lists, shows and deletes numbered Supabase instances on a single-node microk8s server, for testing and staging copies of any app. Nothing in it is specific to one app. - supaspin: single-file Python app (menu and commands: create, list, show, env, tunnel, delete, mail, setup). Instance N gets namespace supaspin-N, hostname slot N, Postgres on :544N and Studio on :300N. Lowest free number is reused; a refused create says why. - Disk: fixed-size ext4 image files, loop-mounted from /etc/fstab. db 2 GB, storage 1 GB, functions/snippets/deno/pgsodium 256 MB each. imgproxy mounts the storage volume (the chart's own imgproxy volume cannot see uploads, so image resizing failed). Empty mount points are made read-only until mounted so a pod started early after a reboot cannot initialise a database there. - Memory: limits 5.625 GiB, requests 1.875 GiB per instance, all 12 services. Half of a production setup, except analytics (768 MiB) and realtime (384 MiB), which were killed for running out of memory at half of it. - Priority class supaspin-low (-100, never preempts) on every instance pod. - Public HTTPS through the existing nginx ingress with Let's Encrypt. The Studio and pg-meta routes are cut out of the Kong gateway config by a post-renderer, which also pins the db and studio pods to fixed host ports. - Certificates: delete keeps the TLS secret (with its cert-manager issuer annotations) in namespace supaspin-certs and create restores it, so create/delete cycles do not hit Let's Encrypt's 5-per-week limit. - Secrets generated per instance: JWT secret, anon and service keys, Postgres and Studio passwords, realtime/meta/logflare keys. The rendered values file is removed after install; secrets are never written through kubectl apply. - Instance .env (mode 600) with the usual Supabase variable names, plus an SSH tunnel command and DATABASE_URL help. - Delete is idempotent and finds the real mounts instead of trusting a list, so half-created and older instances clean up completely. A failed create offers to run it. - Mail settings: saved defaults changeable from the menu; create asks to use them or enter different ones for that instance. - install.sh: installs the app; accepts a Gitea token or a local checkout because git.didgibot.com requires sign-in even for public repositories. uninstall.sh: removes the app and, after confirmation, every instance. - tests: 30 unittest cases (numbering, ports, JWT signing, values, .env, fstab handling, post-renderer, certificate secret handling). - README: install, first-run setup, menu, tunnel, troubleshooting, uninstall. Pinned chart: supabase-community/supabase-kubernetes 0.8.0 (Postgres 17.6.1.136). Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 + README.md | 191 ++++++- install.sh | 69 +++ supaspin | 1005 +++++++++++++++++++++++++++++++++++++ templates/values.yaml.tpl | 88 ++++ tests/test_supaspin.py | 327 ++++++++++++ uninstall.sh | 39 ++ 7 files changed, 1720 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100755 install.sh create mode 100755 supaspin create mode 100644 templates/values.yaml.tpl create mode 100644 tests/test_supaspin.py create mode 100755 uninstall.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 8b550e1..16e5e28 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,192 @@ # supaspin -On-demand throwaway Supabase instances on microk8s \ No newline at end of file +Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a +single-node [microk8s](https://microk8s.io) server, from a terminal menu. + +Use it for testing and staging copies of an app. Each instance is numbered, has its own hostname, +its own database, capped disk and capped memory, and runs beside your other workloads without +endangering them. Nothing in it is specific to any one app. + +## What each instance gets + +| | | +|---|---| +| Web API | `https://` (auth, rest, graphql, realtime, storage, functions), with a Let's Encrypt certificate | +| Postgres | `:544N` (instance 1 → 5441, 2 → 5442, …), bound on the LAN address only | +| Studio (admin UI) | `http://:300N` (3001, 3002, …), not on the public hostname | +| Disk | fixed-size ext4 image files, mounted at boot: **db 2 GB, storage 1 GB, every other volume 256 MB** | +| Memory | limits add up to **5.625 GiB** per instance, requests to 1.875 GiB (table below) | +| Priority | pods run at priority -100 (`supaspin-low`); when the server runs short of memory they are evicted first and never evict anyone else | +| Secrets | generated fresh per instance: JWT secret, anon and service keys, Postgres password, Studio password, and the internal keys | +| Uploads | stored in the instance's own storage image (no MinIO); deleted with the instance | + +Studio and pg-meta (the admin UI and its schema API) are **removed from the public gateway**: `/` +and `/pg/` return 404 on the public hostname. Studio on port `300N` has **no login of its own**, so +keep that port off the internet (do not forward it on your router). + +Instance *N* uses hostname slot *N* from your settings, so the number of slots is the maximum number +of instances at once. Numbers are reused: create picks the lowest free one. + +## Requirements + +- A Debian/Ubuntu server with **microk8s** (or any Kubernetes with `kubectl` and `helm` on the + path), with an **ingress-nginx** ingress class and **cert-manager** installed. +- DNS `A` records for every hostname slot pointing at the server, and ports 80 and 443 forwarded to + it (Let's Encrypt fetches a test file over port 80 to issue each certificate). +- Root access on that server. `python3` and `python3-yaml` (the installer adds the latter with `apt`). + +## Install + +```bash +curl -fsSL https://git.didgibot.com/igor/supaspin/raw/branch/main/install.sh | sudo bash +``` + +**If your Gitea requires sign-in even for public repositories** (the download then fails with +"Could not download"), either pass a read-only token (Gitea → Settings → Applications, scope +`repository: read`): + +```bash +curl -fsSL -H "Authorization: token $TOKEN" https://git.didgibot.com/igor/supaspin/raw/branch/main/install.sh | sudo SUPASPIN_TOKEN=$TOKEN bash +``` + +or clone the repository and install from the checkout: + +```bash +git clone ssh://git@git.didgibot.com:2226/igor/supaspin.git && sudo bash supaspin/install.sh +``` + +Run it again to upgrade. It installs `/usr/local/bin/supaspin`, its values template and the +uninstaller in `/usr/share/supaspin/`. + +Then start the app; the first run asks for your settings and prepares the cluster: + +```bash +sudo supaspin +``` + +| Setting | Meaning | +|---|---| +| Storage root | folder that holds every instance's images and `.env` (`//…`) | +| Node name | Kubernetes node the volumes are pinned to (detected) | +| Server LAN IP | address the Postgres and Studio ports are bound to (detected) | +| Let's Encrypt e-mail | for certificate expiry notices | +| Ingress class | usually `public` on microk8s | +| Hostname slots | comma-separated; the first is instance 1, the second instance 2, … | +| Public SSH host / port / user | only used to print the tunnel command | +| Mail settings | default SMTP settings for the auth service (changeable later) | + +Settings live in `/etc/supaspin/config.yaml` (readable by root only). The first run also creates, +only if missing: the `supaspin-local-sc` StorageClass, the `supaspin-letsencrypt` ClusterIssuer, +the `supaspin-low` PriorityClass and the `supaspin-certs` namespace, and downloads the pinned +Supabase Helm chart (0.8.0, Postgres 17.6.1.136) once into `/var/lib/supaspin/charts/`. + +## Using it + +`sudo supaspin` opens the menu (arrow keys, Enter, `q` to go back). + +| Menu item | What it does | +|---|---| +| Create instance | picks the lowest free number, asks whether to use the saved mail settings or enter different ones, creates the disks, installs Supabase, waits until every pod is ready, writes `//.env` | +| List instances | number, hostname, pods ready, memory in use, disk use per image, `.env` path | +| Show instance | the `.env` (secrets hidden unless you ask), the tunnel command, pod status and recent events | +| Delete instance | asks you to type the number; removes everything (see below) | +| Mail settings | view and change the saved default mail settings | +| Settings | change any first-run setting | + +The same actions are available without the menu: + +```bash +sudo supaspin create [--ask-mail] # --ask-mail: choose saved or different mail settings +sudo supaspin list +sudo supaspin show N # .env with secrets hidden, plus tunnel help +sudo supaspin env N # the full .env (contains secrets) +sudo supaspin tunnel N +sudo supaspin delete N [--yes] +sudo supaspin mail # change the saved default mail settings +sudo supaspin setup # change settings +``` + +### Connecting your app + +Each instance writes `//.env` (mode 600, owned by root) with the usual Supabase variable +names: `SUPABASE_URL`, `PUBLIC_SUPABASE_URL`, `API_EXTERNAL_URL`, `ANON_KEY`, +`PUBLIC_SUPABASE_ANON_KEY`, `JWT_ANON_KEY`, `SERVICE_ROLE_KEY`, `JWT_SERVICE_KEY`, `JWT_SECRET`, +`JWT_EXPIRY`, `POSTGRES_*`, `DATABASE_URL`, `DASHBOARD_USERNAME`, `DASHBOARD_PASSWORD` and +`GOTRUE_SMTP_*`. Read it with `sudo supaspin env N` and copy what your app needs. + +The web API is public, so the URL and keys work from anywhere. **Postgres and Studio are not +public**; from outside the server's network use an SSH tunnel: + +1. On your computer, run the command from `supaspin tunnel N` in its own terminal and leave it open: + `ssh -N -p 2222 -L 5441:192.168.10.30:5441 -L 3001:192.168.10.30:3001 you@your.server` +2. Put `DATABASE_URL=postgresql://postgres:@localhost:5441/postgres` in your app's + `.env` (the password is `POSTGRES_PASSWORD` in the instance's `.env`). +3. Studio is at `http://localhost:3001`. + +On the server's own LAN skip the tunnel and use `:5441` and `http://:3001`. + +### Deleting + +Delete works on a half-created instance too and can be run again safely. It: + +1. saves the instance's TLS certificate into namespace `supaspin-certs`, +2. uninstalls the Helm release, then deletes the namespace and the volumes' Kubernetes objects, +3. unmounts every image, removes its `/etc/fstab` line and deletes `//`. + +Creating that instance again restores the saved certificate, so repeated create/delete cycles do +not hit Let's Encrypt's limit of 5 certificates per hostname per week. If a create fails midway, +run `supaspin delete N` to clean up. + +## Memory per instance + +| Service | Request | Limit | +|---|---|---| +| db | 512 MiB | 2048 MiB | +| analytics | 128 MiB | 768 MiB | +| kong | 128 MiB | 512 MiB | +| realtime | 128 MiB | 384 MiB | +| auth, functions, imgproxy, meta, rest, storage, studio, vector | 128 MiB each | 256 MiB each | +| **Total** | **1.875 GiB** | **5.625 GiB** | + +Most limits are half of what a production setup would use. Analytics and realtime are higher: at +half of production they were killed for running out of memory in a loop (measured on a fresh +instance: analytics peaks at 624 MiB, realtime at 280 MiB). Image resizing (imgproxy) reads the +uploads from the storage volume, so it has no image of its own. + +## Troubleshooting + +- **Pods not ready.** `sudo supaspin` → Show instance → Pods and events, or + `kubectl get pods -n supaspin-N` and `kubectl describe pod …`. `OOMKilled` means a pod hit its + memory limit. +- **Certificate stays pending.** The hostname's DNS record must point at the server and port 80 must + reach the ingress: `kubectl get certificate,order,challenge -n supaspin-N`. +- **"no free hostname slot".** Delete an instance or add a slot in Settings (and its DNS record). +- **Create failed.** `sudo supaspin delete N`, then create again. +- **Port already in use.** Postgres and Studio ports are fixed per number (`5440+N`, `3000+N`); + another program is using it. Free the port or use another instance number. +- **After a reboot.** Images are mounted from `/etc/fstab` (`nofail`, after the storage root is + available) and the pods restart on their own. Check the mounts with `findmnt | grep supabase`. + Kubernetes can start a pod a moment before its image is mounted, so supaspin makes every empty + mount-point folder read-only (`chattr +i`) until the image is mounted over it: an early pod fails + and retries instead of setting up a fresh database on the bare folder. + +## Uninstall + +```bash +sudo bash /usr/share/supaspin/uninstall.sh +``` + +It asks before deleting the instances (their data is gone for good) and before removing the shared +cluster objects, then removes the app, its settings and its cache. + +## Development + +The tests use Python's built-in `unittest` and need no extra packages: + +```bash +python3 -m unittest discover -s tests +``` + +## License + +MIT, see `LICENSE`. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..2529998 --- /dev/null +++ b/install.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# One-line install (run on the Kubernetes server): +# curl -fsSL https://git.didgibot.com/igor/supaspin/raw/branch/main/install.sh | sudo bash +# Installs /usr/local/bin/supaspin and its Helm values template. Safe to run again to upgrade. +# +# If your Gitea requires sign-in even for public repositories, give a read-only token: +# curl -fsSL -H "Authorization: token $TOKEN" .../install.sh | sudo SUPASPIN_TOKEN=$TOKEN bash +# or clone the repository and run `sudo bash install.sh` from inside the checkout. +# +# SUPASPIN_REF= installs that version instead of main +# SUPASPIN_SRC= installs from a local checkout instead of downloading +# SUPASPIN_TOKEN= Gitea token (read access to the repository) for the download +set -euo pipefail + +REPO="https://git.didgibot.com/igor/supaspin" +REF="${SUPASPIN_REF:-main}" + +[ "$(id -u)" -eq 0 ] || { echo "Run as root: curl -fsSL $REPO/raw/branch/main/install.sh | sudo bash" >&2; exit 1; } + +# python3 and its YAML module are the only runtime dependencies. +command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 1; } +if ! python3 -c 'import yaml' 2>/dev/null; then + echo "Installing python3-yaml with apt..." + apt-get install -y python3-yaml +fi + +# kubectl and helm: the microk8s snap commands, or plain ones. +if [ ! -x /snap/bin/microk8s.kubectl ] && ! command -v kubectl >/dev/null; then + echo "kubectl not found (on microk8s: sudo snap install microk8s --classic)" >&2; exit 1 +fi +if [ ! -x /snap/bin/microk8s.helm3 ] && ! command -v helm >/dev/null; then + echo "helm not found (on microk8s: sudo microk8s enable helm3)" >&2; exit 1 +fi + +# Where to install from: an explicit folder, the checkout this script sits in, or a download. +here="$(cd "$(dirname "${BASH_SOURCE[0]:-/nonexistent}")" 2>/dev/null && pwd || true)" +if [ -n "${SUPASPIN_SRC:-}" ]; then + src="$SUPASPIN_SRC" +elif [ -n "$here" ] && [ -f "$here/supaspin" ] && [ -f "$here/templates/values.yaml.tpl" ]; then + src="$here" +else + tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT + echo "Downloading supaspin ($REF)..." + auth=() + [ -n "${SUPASPIN_TOKEN:-}" ] && auth=(-H "Authorization: token $SUPASPIN_TOKEN") + curl -fsSL "${auth[@]}" -o "$tmp/src.tar.gz" "$REPO/archive/$REF.tar.gz" || true + if ! gzip -t "$tmp/src.tar.gz" 2>/dev/null; then + echo "Could not download $REPO/archive/$REF.tar.gz." >&2 + echo "If the server asks for sign-in, rerun with SUPASPIN_TOKEN=," >&2 + echo "or clone the repository and run 'sudo bash install.sh' inside it." >&2 + exit 1 + fi + tar -xzf "$tmp/src.tar.gz" -C "$tmp" + src="$(find "$tmp" -mindepth 1 -maxdepth 1 -type d | head -1)" +fi + +for f in supaspin templates/values.yaml.tpl uninstall.sh; do + [ -f "$src/$f" ] || { echo "$src/$f is missing: not a supaspin checkout" >&2; exit 1; } +done + +install -m 0755 "$src/supaspin" /usr/local/bin/supaspin +install -d -m 0755 /usr/share/supaspin/templates +install -m 0644 "$src/templates/values.yaml.tpl" /usr/share/supaspin/templates/values.yaml.tpl +install -m 0755 "$src/uninstall.sh" /usr/share/supaspin/uninstall.sh +install -d -m 0700 /etc/supaspin /var/lib/supaspin + +echo "Installed supaspin $(/usr/local/bin/supaspin --version)." +echo "Next: run 'sudo supaspin' - the first run asks for your settings." +echo "To remove it later: sudo bash /usr/share/supaspin/uninstall.sh" diff --git a/supaspin b/supaspin new file mode 100755 index 0000000..ed9f999 --- /dev/null +++ b/supaspin @@ -0,0 +1,1005 @@ +#!/usr/bin/env python3 +"""supaspin - create, list, show and delete throwaway Supabase instances on microk8s. + +Each numbered instance gets its own namespace, capped disk images, capped memory, +an HTTPS hostname and fixed Postgres/Studio ports. Standard library plus python3-yaml. +Run as root: supaspin (menu) + supaspin create|list|show N|env N|tunnel N|delete N|setup|mail +""" + +import base64 +import glob +import hashlib +import hmac +import json +import os +import re +import secrets +import shutil +import string +import subprocess +import sys +import termios +import time +import tty + +import yaml + +VERSION = "1.0.0" +CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml") +STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin") +CHART_REPO = "https://supabase-community.github.io/supabase-kubernetes" +CHART_NAME = "supabase" +CHART_VERSION = "0.8.0" + +STORAGE_CLASS = "supaspin-local-sc" +ISSUER = "supaspin-letsencrypt" +PRIORITY_CLASS = "supaspin-low" +CERTS_NS = "supaspin-certs" + +# Image cap per volume, in MiB. The chart's separate imgproxy volume is not used: imgproxy has to +# read the uploaded files, so it mounts the storage volume instead (see instance_values). +VOLUMES = {"db": 2048, "storage": 1024, "functions": 256, + "snippets": 256, "deno": 256, "pgsodium": 256} +# The user Postgres runs as inside supabase/postgres 17.6.1.136 (`id postgres` in the image). +# Production's older image used 2000; do not copy that number. +DB_OWNED = ("db", "pgsodium") +DB_UID, DB_GID = 100, 101 + +# Memory per service: (request MiB, limit MiB). Half of maralago.ru production, except analytics +# and realtime: at half of production they were killed for running out of memory in a loop +# (measured on a fresh instance: analytics peaks at 624 MiB, realtime at 280 MiB). +SERVICES = ["db", "analytics", "kong", "auth", "functions", "imgproxy", "meta", + "realtime", "rest", "storage", "studio", "vector"] +MEMORY = {"db": (512, 2048), "analytics": (128, 768), "kong": (128, 512), "realtime": (128, 384)} +DEFAULT_MEMORY = (128, 256) + +DEFAULT_MAIL = {"host": "smtp.example.com", "port": 587, "user": "", "password": "", + "admin_email": "admin@example.com", "sender_name": "Supabase"} + +BOLD, DIM, RED, REV, OFF = "\x1b[1m", "\x1b[2m", "\x1b[31m", "\x1b[7m", "\x1b[0m" + + +# ------------------------------------------------------------ pure helpers -- + +def pg_port(n): + return 5440 + n + + +def studio_port(n): + return 3000 + n + + +def namespace(n): + return f"supaspin-{n}" + + +def release(n): + return f"supa{n}" + + +def fullname(n): + """Name the chart gives to its objects: -supabase.""" + return f"{release(n)}-supabase" + + +def pvc_name(n, vol): + return f"{fullname(n)}-{vol}" + + +def tls_secret(n): + return f"supa{n}-tls" + + +def lowest_free_number(slots, taken): + """Lowest slot number that is not in `taken`; None if every slot is used.""" + for n in sorted(int(k) for k in slots): + if n not in taken: + return n + return None + + +def b64url(data): + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def sign_jwt(secret, role, now=None, years=10): + """HS256 token with the claims Supabase expects for the anon/service keys.""" + now = int(now if now is not None else time.time()) + header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) + payload = b64url(json.dumps( + {"role": role, "iss": "supabase", "iat": now, "exp": now + years * 365 * 86400}, + separators=(",", ":")).encode()) + sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest() + return f"{header}.{payload}.{b64url(sig)}" + + +def rand(n, alphabet=string.ascii_letters + string.digits): + return "".join(secrets.choice(alphabet) for _ in range(n)) + + +def generate_secrets(): + jwt_secret = rand(48) + return { + "JWT_SECRET": jwt_secret, + "ANON_KEY": sign_jwt(jwt_secret, "anon"), + "SERVICE_KEY": sign_jwt(jwt_secret, "service_role"), + "DB_PASSWORD": rand(32), + "DASHBOARD_USERNAME": "supabase", + "DASHBOARD_PASSWORD": rand(24), + "LOGFLARE_PUBLIC": rand(40), + "LOGFLARE_PRIVATE": rand(40), + "S3_KEY_ID": secrets.token_hex(16), + "S3_ACCESS_KEY": secrets.token_hex(32), + "REALTIME_SECRET_KEY_BASE": base64.b64encode(secrets.token_bytes(48)).decode(), + "REALTIME_DB_ENC_KEY": secrets.token_hex(8), # must be exactly 16 characters + "META_CRYPTO_KEY": secrets.token_hex(16), + } + + +def find_template(): + for d in (os.environ.get("SUPASPIN_TEMPLATES"), + os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates"), + "/usr/share/supaspin/templates"): + if d and os.path.exists(os.path.join(d, "values.yaml.tpl")): + return os.path.join(d, "values.yaml.tpl") + raise SystemExit("values.yaml.tpl not found (reinstall supaspin)") + + +def instance_values(cfg, n, sec, mail, template_text): + """Fill the template, then add memory caps, priority class and volume sizes.""" + host = cfg["slots"][n] + subs = dict(sec) + subs.update({ + "PUBLIC_URL": f"https://{host}", "HOST": host, + "TLS_SECRET": tls_secret(n), "INGRESS_CLASS": cfg["ingress_class"], + "CLUSTER_ISSUER": ISSUER, "PROJECT_NAME": f"supaspin-{n}", + "SMTP_HOST": mail["host"], "SMTP_PORT": mail["port"], "SMTP_USER": mail["user"], + "SMTP_PASS": mail["password"], "SMTP_ADMIN_EMAIL": mail["admin_email"], + "SMTP_SENDER_NAME": mail["sender_name"], + }) + # json.dumps gives a quoted YAML-safe string; the port is quoted as well (env values are strings). + quoted = {k: json.dumps(str(v)) for k, v in subs.items()} + text = re.sub(r"\$\{(\w+)\}", lambda m: quoted[m.group(1)], template_text) + values = yaml.safe_load(text) + dep = values.setdefault("deployment", {}) + for svc in SERVICES: + req, lim = MEMORY.get(svc, DEFAULT_MEMORY) + d = dep.setdefault(svc, {}) + d["resources"] = {"requests": {"memory": f"{req}Mi"}, "limits": {"memory": f"{lim}Mi"}} + d["priorityClassName"] = PRIORITY_CLASS + persistence = values.setdefault("persistence", {}) + for vol, mib in VOLUMES.items(): + persistence[vol] = {"enabled": True, "storageClassName": STORAGE_CLASS, + "size": f"{mib}Mi"} + # With the file backend storage-api gives imgproxy a local path, so imgproxy must mount the + # very volume the uploads are written to; the chart's own imgproxy volume is empty and useless. + persistence["imgproxy"] = {"enabled": True, "existingClaim": pvc_name(n, "storage")} + return values + + +def total_memory_mib(): + """(requests, limits) in MiB, summed over all services.""" + req = sum(MEMORY.get(s, DEFAULT_MEMORY)[0] for s in SERVICES) + lim = sum(MEMORY.get(s, DEFAULT_MEMORY)[1] for s in SERVICES) + return req, lim + + +def render_env(cfg, n, sec, mail): + host = cfg["slots"][n] + url = f"https://{host}" + lan = cfg["lan_ip"] + pw = sec["DB_PASSWORD"] + lines = [ + f"# supaspin instance {n} - {url}", + "# Reach Postgres and Studio from outside the LAN through an SSH tunnel:", + f"# {tunnel_command(cfg, n)}", + "", + f"SUPABASE_URL={url}", f"SUPABASE_PUBLIC_URL={url}", + f"PUBLIC_SUPABASE_URL={url}", f"API_EXTERNAL_URL={url}", + "", + f"PUBLIC_SUPABASE_ANON_KEY={sec['ANON_KEY']}", f"ANON_KEY={sec['ANON_KEY']}", + f"JWT_ANON_KEY={sec['ANON_KEY']}", + f"SERVICE_ROLE_KEY={sec['SERVICE_KEY']}", f"JWT_SERVICE_KEY={sec['SERVICE_KEY']}", + f"JWT_SECRET={sec['JWT_SECRET']}", "JWT_EXPIRY=3600", + "", + f"POSTGRES_PASSWORD={pw}", "POSTGRES_DB=postgres", "POSTGRES_HOST=localhost", + f"POSTGRES_PORT={pg_port(n)}", + f"# On the LAN use {lan}:{pg_port(n)} instead of localhost.", + f"DATABASE_URL=postgresql://postgres:{pw}@localhost:{pg_port(n)}/postgres", + "", + f"DASHBOARD_USERNAME={sec['DASHBOARD_USERNAME']}", + f"DASHBOARD_PASSWORD={sec['DASHBOARD_PASSWORD']}", + "", + f"GOTRUE_SMTP_HOST={mail['host']}", f"GOTRUE_SMTP_PORT={mail['port']}", + f"GOTRUE_SMTP_USER={mail['user']}", f"GOTRUE_SMTP_PASS={mail['password']}", + f"GOTRUE_SMTP_ADMIN_EMAIL={mail['admin_email']}", + f"GOTRUE_SMTP_SENDER_NAME={mail['sender_name']}", + "", + ] + return "\n".join(lines) + + +SECRET_KEYS = ("KEY", "SECRET", "PASSWORD", "DATABASE_URL", "PASS") + + +def mask_env(text): + out = [] + for line in text.splitlines(): + if "=" in line and not line.startswith("#"): + k, _, v = line.partition("=") + if any(s in k for s in SECRET_KEYS) and v: + line = f"{k}=********" + out.append(line) + return "\n".join(out) + + +def tunnel_command(cfg, n): + lan = cfg["lan_ip"] + return (f"ssh -N -p {cfg['ssh_port']} -L {pg_port(n)}:{lan}:{pg_port(n)} " + f"-L {studio_port(n)}:{lan}:{studio_port(n)} {cfg['ssh_user']}@{cfg['ssh_host']}") + + +def tunnel_help(cfg, n, pw=""): + return "\n".join([ + "Run this in its own terminal on your computer and leave it open:", + f" {tunnel_command(cfg, n)}", + "Then, in the app's .env:", + f" DATABASE_URL=postgresql://postgres:{pw}@localhost:{pg_port(n)}/postgres", + " (the password is POSTGRES_PASSWORD in the instance's .env: supaspin env " + str(n) + ")", + f"Studio (admin UI): http://localhost:{studio_port(n)}", + f"On the LAN skip the tunnel: {cfg['lan_ip']}:{pg_port(n)} and http://{cfg['lan_ip']}:{studio_port(n)}", + ]) + + +def fstab_line(img, mount, root): + return f"{img} {mount} ext4 loop,nofail,x-systemd.requires-mounts-for={root} 0 0" + + +def fstab_without(text, mount): + """fstab text with every line whose mount point is `mount` removed.""" + keep = [] + for line in text.splitlines(): + f = line.split() + if len(f) >= 2 and not line.lstrip().startswith("#") and f[1] == mount: + continue + keep.append(line) + return "\n".join(keep) + "\n" + + +def fstab_mounts_under(text, prefix): + """Mount points in fstab that lie below `prefix` (a folder), in file order.""" + out = [] + for line in text.splitlines(): + f = line.split() + if len(f) >= 2 and not line.lstrip().startswith("#") and f[1].startswith(prefix.rstrip("/") + "/"): + out.append(f[1]) + return out + + +def fstab_has(text, mount): + return any(len(l.split()) >= 2 and not l.lstrip().startswith("#") and l.split()[1] == mount + for l in text.splitlines()) + + +class _Literal(str): + pass + + +def _literal_repr(dumper, data): + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + + +yaml.SafeDumper.add_representer(_Literal, _literal_repr) + + +def strip_kong_services(text, names): + """Cut whole `- name: X` service blocks out of kong.yml, leaving every other byte as it was. + + Kong's start script substitutes placeholders inside the quoted strings of this file, so it + must not be parsed and re-written: a changed quoting style breaks the substitution. + """ + out, skipping = [], False + for line in text.splitlines(keepends=True): + m = re.match(r"^ - name: (\S+)\s*$", line) + if m: + skipping = m.group(1) in names + elif skipping and line.strip() and not line.startswith(" "): + skipping = False + if not skipping: + out.append(line) + return "".join(out) + + +def post_render(text, n, lan_ip): + """Edit the rendered chart: fixed host ports on db and studio, no public Studio/pg-meta route.""" + docs = [d for d in yaml.safe_load_all(text) if d] + for d in docs: + kind, name = d.get("kind"), d.get("metadata", {}).get("name", "") + if kind == "StatefulSet" and name == f"{fullname(n)}-db": + _host_port(d, pg_port(n), lan_ip) + elif kind == "Deployment" and name == f"{fullname(n)}-studio": + _host_port(d, studio_port(n), lan_ip) + elif kind == "ConfigMap" and "kong.yml" in (d.get("data") or {}): + d["data"]["kong.yml"] = strip_kong_services(d["data"]["kong.yml"], ("meta", "dashboard")) + for d in docs: # keep multi-line scripts readable in the release manifest + for k, v in (d.get("data") or {}).items(): + if isinstance(v, str) and "\n" in v: + d["data"][k] = _Literal(v) + return yaml.safe_dump_all(docs, sort_keys=False, default_flow_style=False) + + +def _host_port(doc, port, ip): + c = doc["spec"]["template"]["spec"]["containers"][0] + p = c["ports"][0] + p["hostPort"] = port + p["hostIP"] = ip + + +def pv_manifest(cfg, n, vol): + return { + "apiVersion": "v1", "kind": "PersistentVolume", + "metadata": {"name": f"supaspin-{n}-{vol}", + "labels": {"supaspin/instance": str(n)}}, + "spec": { + "capacity": {"storage": f"{VOLUMES[vol]}Mi"}, + "accessModes": ["ReadWriteOnce"], + "persistentVolumeReclaimPolicy": "Retain", + "storageClassName": STORAGE_CLASS, + "claimRef": {"namespace": namespace(n), "name": pvc_name(n, vol)}, + "local": {"path": f"{cfg['storage_root']}/{n}/{vol}/data"}, + "nodeAffinity": {"required": {"nodeSelectorTerms": [{"matchExpressions": [ + {"key": "kubernetes.io/hostname", "operator": "In", + "values": [cfg["node_name"]]}]}]}}, + }, + } + + +# ------------------------------------------------------------------ system -- + +def run(args, input_=None, check=True, quiet=False): + p = subprocess.run(args, input=input_, capture_output=True, text=True) + if check and p.returncode != 0: + raise RuntimeError(f"{' '.join(args)}\n{(p.stderr or p.stdout).strip()}") + return p + + +def tool(name): + """kubectl / helm: the microk8s snap commands when present, else plain ones.""" + snap = {"kubectl": "/snap/bin/microk8s.kubectl", "helm": "/snap/bin/microk8s.helm3"}[name] + return snap if os.path.exists(snap) else (shutil.which(name) or name) + + +def kubectl(*args, input_=None, check=True): + return run([tool("kubectl"), *args], input_=input_, check=check) + + +def helm(*args, check=True): + return run([tool("helm"), *args], check=check) + + +def kjson(*args): + p = kubectl(*args, "-o", "json", check=False) + return json.loads(p.stdout) if p.returncode == 0 and p.stdout.strip() else None + + +def apply(manifests): + docs = manifests if isinstance(manifests, list) else [manifests] + kubectl("apply", "-f", "-", input_=yaml.safe_dump_all(docs)) + + +def put_secret(manifest): + """Create a Secret, replacing any old one. Not `kubectl apply`: apply stores a full copy of the + secret (private key included) in a last-applied annotation.""" + kubectl("delete", "secret", manifest["metadata"]["name"], "-n", manifest["metadata"]["namespace"], + "--ignore-not-found") + kubectl("create", "-f", "-", input_=yaml.safe_dump(manifest)) + + +def load_config(): + try: + with open(CONFIG_PATH) as f: + cfg = yaml.safe_load(f) or {} + except FileNotFoundError: + return None + cfg["slots"] = {int(k): v for k, v in cfg.get("slots", {}).items()} + return cfg + + +def save_config(cfg): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + data = dict(cfg) + data["slots"] = {int(k): v for k, v in cfg["slots"].items()} + tmp = CONFIG_PATH + ".tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + yaml.safe_dump(data, f, sort_keys=False) + os.replace(tmp, CONFIG_PATH) + + +def mounts(): + with open("/proc/mounts") as f: + return [l.split()[1] for l in f] + + +def is_mounted(path): + return os.path.realpath(path) in mounts() + + +def inst_dir(cfg, n): + return f"{cfg['storage_root']}/{n}" + + +def vol_paths(cfg, n, vol): + d = f"{inst_dir(cfg, n)}/{vol}" + return f"{d}/{vol}.img", f"{d}/data" + + +def existing_numbers(cfg): + """Instances that have any trace: folder, namespace or fstab line.""" + found = set() + root = cfg["storage_root"] + if os.path.isdir(root): + found |= {int(e) for e in os.listdir(root) if e.isdigit()} + ns = kjson("get", "ns") + for item in (ns or {}).get("items", []): + m = re.fullmatch(r"supaspin-(\d+)", item["metadata"]["name"]) + if m: + found.add(int(m.group(1))) + try: + with open("/etc/fstab") as f: + for m in re.finditer(re.escape(root) + r"/(\d+)/", f.read()): + found.add(int(m.group(1))) + except FileNotFoundError: + pass + return found + + +# --------------------------------------------------------------- volumes --- + +def provision_volume(cfg, n, vol, log): + img, mnt = vol_paths(cfg, n, vol) + root = cfg["storage_root"] + os.makedirs(mnt, exist_ok=True) + if not is_mounted(mnt): + # After a reboot Kubernetes can start a pod before the image is mounted. Nothing may be + # written into the empty folder meanwhile (Postgres would set up a fresh database there), + # so it is made read-only until the image is mounted over it. Best effort. + run(["chattr", "+i", mnt], check=False) + fresh = not os.path.exists(img) + if fresh: + mib = VOLUMES[vol] + if run(["fallocate", "-l", f"{mib}M", img], check=False).returncode != 0: + run(["truncate", "-s", f"{mib}M", img]) + run(["/sbin/mkfs.ext4", "-F", "-m", "0", "-L", f"supa{n}-{vol}", img]) + with open("/etc/fstab") as f: + fstab = f.read() + if not fstab_has(fstab, mnt): + with open("/etc/fstab", "a") as f: + if not fstab.endswith("\n"): + f.write("\n") + f.write(fstab_line(img, mnt, root) + "\n") + run(["systemctl", "daemon-reload"]) + if not is_mounted(mnt): + run(["mount", mnt]) + # ext4 puts an empty lost+found in every new filesystem. The chart copies its default Postgres + # config into the pgsodium volume only when that volume is empty, so the folder must go. + lost = os.path.join(mnt, "lost+found") + if os.path.isdir(lost) and not os.listdir(lost): + os.rmdir(lost) + if vol in DB_OWNED: + os.chown(mnt, DB_UID, DB_GID) + os.chmod(mnt, 0o770) + else: + os.chmod(mnt, 0o777) + log(f" volume {vol}: {VOLUMES[vol]} MiB mounted at {mnt}") + + +def instance_mounts(cfg, n): + """Every mount point of the instance found on the system (mounted now or listed in fstab), + not just the volumes this version creates: an instance made by an older version may have others.""" + d = inst_dir(cfg, n) + found = {m for m in mounts() if m.startswith(d + "/")} + try: + with open("/etc/fstab") as f: + found |= set(fstab_mounts_under(f.read(), d)) + except FileNotFoundError: + pass + return sorted(found) + + +def remove_mount(mnt, log): + if is_mounted(mnt): + r = run(["umount", mnt], check=False) + if r.returncode != 0: + raise RuntimeError(f"cannot unmount {mnt}: {r.stderr.strip()}") + with open("/etc/fstab") as f: + fstab = f.read() + if fstab_has(fstab, mnt): + with open("/etc/fstab", "w") as f: + f.write(fstab_without(fstab, mnt)) + run(["chattr", "-i", mnt], check=False) + log(f" unmounted {mnt}, fstab line removed") + + +# ------------------------------------------------------------- operations -- + +def ensure_cluster_objects(cfg, log): + docs = [ + {"apiVersion": "storage.k8s.io/v1", "kind": "StorageClass", + "metadata": {"name": STORAGE_CLASS}, "provisioner": "kubernetes.io/no-provisioner", + "reclaimPolicy": "Retain", "volumeBindingMode": "WaitForFirstConsumer"}, + {"apiVersion": "cert-manager.io/v1", "kind": "ClusterIssuer", + "metadata": {"name": ISSUER}, + "spec": {"acme": {"server": "https://acme-v02.api.letsencrypt.org/directory", + "email": cfg["acme_email"], + "privateKeySecretRef": {"name": ISSUER + "-account-key"}, + "solvers": [{"http01": {"ingress": {"class": cfg["ingress_class"]}}}]}}}, + {"apiVersion": "scheduling.k8s.io/v1", "kind": "PriorityClass", + "metadata": {"name": PRIORITY_CLASS}, "value": -100, + "preemptionPolicy": "Never", "globalDefault": False, + "description": "supaspin test instances: evicted first, never evict others"}, + {"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": CERTS_NS}}, + ] + for d in docs: + # Create only what is missing; never overwrite something already there. + r = kubectl("get", d["kind"].lower(), d["metadata"]["name"], check=False) + if r.returncode != 0: + apply(d) + log(f" created {d['kind']} {d['metadata']['name']}") + chart_dir = f"{STATE_DIR}/charts" + os.makedirs(chart_dir, exist_ok=True) + tgz = f"{chart_dir}/{CHART_NAME}-{cfg['chart_version']}.tgz" + if not os.path.exists(tgz): + log(f" downloading chart {cfg['chart_version']}") + for attempt in (1, 2, 3): # the chart host times out now and then + try: + helm("pull", CHART_NAME, "--repo", CHART_REPO, "--version", cfg["chart_version"], + "--destination", chart_dir) + break + except RuntimeError: + if attempt == 3: + raise + log(f" download failed, retrying ({attempt}/3)") + time.sleep(5) + + +def chart_path(cfg): + return f"{STATE_DIR}/charts/{CHART_NAME}-{cfg['chart_version']}.tgz" + + +def carry_secret(src, ns, name): + """Copy of a TLS secret for another namespace: data and type, plus the cert-manager.io + annotations. Without those annotations cert-manager cannot tell which issuer signed the + certificate, decides it is wrong ("IncorrectIssuer") and orders a new one from Let's Encrypt.""" + keep = {k: v for k, v in (src.get("metadata", {}).get("annotations") or {}).items() + if k.startswith("cert-manager.io/")} + meta = {"name": name, "namespace": ns} + if keep: + meta["annotations"] = keep + return {"apiVersion": "v1", "kind": "Secret", + "type": src.get("type", "kubernetes.io/tls"), "metadata": meta, "data": src["data"]} + + +def restore_cert(n, log): + r = kubectl("get", "secret", tls_secret(n), "-n", CERTS_NS, "-o", "json", check=False) + if r.returncode != 0: + return + put_secret(carry_secret(json.loads(r.stdout), namespace(n), tls_secret(n))) + kubectl("delete", "secret", tls_secret(n), "-n", CERTS_NS, "--ignore-not-found") + log(" restored saved TLS certificate") + + +def save_cert(n, log): + r = kubectl("get", "secret", tls_secret(n), "-n", namespace(n), "-o", "json", check=False) + if r.returncode != 0: + return + s = json.loads(r.stdout) + if not s.get("data", {}).get("tls.crt"): + return + put_secret(carry_secret(s, CERTS_NS, tls_secret(n))) + log(" kept TLS certificate in namespace " + CERTS_NS) + + +def pod_summary(n): + """(ready_count, total, problems) over pods that are meant to keep running.""" + # Only the release's own pods: cert-manager parks a short-lived helper pod in the namespace + # while it proves domain ownership to Let's Encrypt. + data = kjson("get", "pods", "-n", namespace(n), "-l", f"app.kubernetes.io/instance={release(n)}") \ + or {"items": []} + ready = total = 0 + problems = [] + for p in data["items"]: + if p["status"].get("phase") == "Succeeded": + continue + total += 1 + cs = p["status"].get("containerStatuses", []) + if p["status"].get("phase") == "Running" and cs and all(c.get("ready") for c in cs): + ready += 1 + else: + waiting = next((c["state"]["waiting"].get("reason") for c in cs + if "waiting" in c.get("state", {})), p["status"].get("phase")) + problems.append(f"{p['metadata']['name'].replace(fullname(n) + '-', '')}={waiting}") + return ready, total, problems + + +def wait_ready(n, log, timeout=1200): + end = time.time() + timeout + expected = len(SERVICES) + while time.time() < end: + ready, total, problems = pod_summary(n) + log(f" pods ready {ready}/{expected}" + (f" waiting: {', '.join(problems)}" if problems else ""), + live=True) + if ready >= expected and total == ready: + log("") + return + time.sleep(5) + raise RuntimeError("pods did not become ready in time") + + +def create_instance(cfg, mail, log, number=None): + taken = existing_numbers(cfg) + n = number if number is not None else lowest_free_number(cfg["slots"], taken) + if n is None: + raise RuntimeError("no free hostname slot: delete an instance or add slots in Settings") + if n in taken or n not in cfg["slots"]: + raise RuntimeError(f"instance {n} is not available") + host = cfg["slots"][n] + log(f"Creating instance {n} ({host})") + ensure_cluster_objects(cfg, log) + try: + os.makedirs(inst_dir(cfg, n), exist_ok=True) + for vol in VOLUMES: + provision_volume(cfg, n, vol, log) + kubectl("create", "namespace", namespace(n)) + apply([pv_manifest(cfg, n, v) for v in VOLUMES]) + restore_cert(n, log) + sec = generate_secrets() + with open(find_template()) as f: + values = instance_values(cfg, n, sec, mail, f.read()) + vfile = f"{STATE_DIR}/values-{n}.yaml" + os.makedirs(STATE_DIR, exist_ok=True) + fd = os.open(vfile, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + yaml.safe_dump(values, f, sort_keys=False) + log(" installing chart") + try: + helm("install", release(n), chart_path(cfg), "-n", namespace(n), "-f", vfile, + "--post-renderer", os.path.realpath(sys.argv[0]), + "--post-renderer-args", "post-render", "--post-renderer-args", str(n)) + finally: + os.remove(vfile) # holds every secret + wait_ready(n, log) + env_path = f"{inst_dir(cfg, n)}/.env" + fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(render_env(cfg, n, sec, mail)) + log(f"Instance {n} is ready: https://{host}\n.env written to {env_path}") + return n + except Exception: + log(f"{RED}Create failed.{OFF}") + if sys.stdin.isatty() and ask(f"Delete the partial instance {n} now? (Y/n)", "y").lower().startswith("y"): + delete_instance(cfg, n, log) + else: + log(f"Run 'supaspin delete {n}' to clean up the partial instance.") + raise + + +def delete_instance(cfg, n, log): + log(f"Deleting instance {n}") + save_cert(n, log) + helm("uninstall", release(n), "-n", namespace(n), check=False) + kubectl("delete", "namespace", namespace(n), "--ignore-not-found", "--wait=true") + kubectl("delete", "pv", "-l", f"supaspin/instance={n}", "--ignore-not-found") + for mnt in instance_mounts(cfg, n): + remove_mount(mnt, log) + run(["systemctl", "daemon-reload"]) + d = inst_dir(cfg, n) + for mnt in glob.glob(f"{d}/*/data"): # also the folders of a create that stopped before mounting + if not is_mounted(mnt): + run(["chattr", "-i", mnt], check=False) + if os.path.isdir(d): + busy = [m for m in mounts() if m == d or m.startswith(d + "/")] + if busy: + raise RuntimeError(f"refusing to delete {d}: still mounted: {', '.join(busy)}") + shutil.rmtree(d) + log(f" removed {d}") + log(f"Instance {n} deleted.") + + +def read_env(cfg, n): + path = f"{inst_dir(cfg, n)}/.env" + with open(path) as f: + return f.read() + + +def memory_use(n): + """Memory the instance's pods use right now, read from the kernel's per-pod counters + (the cluster has no metrics server, so `kubectl top` does not work).""" + pods = kjson("get", "pods", "-n", namespace(n)) or {"items": []} + total, found = 0, False + for p in pods["items"]: + uid = p["metadata"]["uid"] + for pattern in (f"/sys/fs/cgroup/kubepods/*/pod{uid}/memory.current", + f"/sys/fs/cgroup/kubepods/pod{uid}/memory.current", + f"/sys/fs/cgroup/kubepods.slice/*/kubepods-*-pod{uid.replace('-', '_')}.slice/memory.current"): + hits = glob.glob(pattern) + if hits: + with open(hits[0]) as f: + total += int(f.read()) // 2**20 + found = True + break + if not found: + return "n/a" + return f"{total}/{total_memory_mib()[1]} MiB" + + +def disk_use(cfg, n): + parts = [] + d = inst_dir(cfg, n) + names = sorted({os.path.basename(os.path.dirname(m)) for m in instance_mounts(cfg, n)} | set(VOLUMES)) + for vol in names: + mnt = f"{d}/{vol}/data" + if is_mounted(mnt): + u = shutil.disk_usage(mnt) + parts.append(f"{vol} {u.used // 2**20}/{u.total // 2**20}M") + else: + parts.append(f"{vol} -") + return ", ".join(parts) + + +def list_rows(cfg): + rows = [] + for n in sorted(existing_numbers(cfg)): + ready, total, _ = pod_summary(n) + env = f"{inst_dir(cfg, n)}/.env" + rows.append({"n": n, "host": cfg["slots"].get(n, "?"), "pods": f"{ready}/{total}", + "memory": memory_use(n), "disk": disk_use(cfg, n), + "env": env if os.path.exists(env) else "(none)"}) + return rows + + +# --------------------------------------------------------------------- ui -- + +def log_line(msg, live=False): + if live and sys.stdout.isatty(): + sys.stdout.write("\r\x1b[K" + msg) + sys.stdout.flush() + else: + print(msg, flush=True) + + +def ask(prompt, default=None): + suffix = f" [{default}]" if default not in (None, "") else "" + try: + val = input(f"{prompt}{suffix}: ").strip() + except EOFError: + val = "" + return val or (default if default is not None else "") + + +def confirm_number(n, what): + return ask(f"Type the instance number ({n}) to confirm {what}") == str(n) + + +def menu(title, items): + """Arrow-key menu; returns index or None (q / Esc). Falls back to numbers without a terminal.""" + if not sys.stdin.isatty(): + print(title) + for i, it in enumerate(items, 1): + print(f" {i}. {it}") + v = ask("Choose") + return int(v) - 1 if v.isdigit() and 0 < int(v) <= len(items) else None + fd = sys.stdin.fileno() + saved = termios.tcgetattr(fd) + sel = 0 + try: + tty.setraw(fd) + while True: + sys.stdout.write("\x1b[H\x1b[2J" + BOLD + title + OFF + "\r\n\r\n") + for i, it in enumerate(items): + sys.stdout.write((f" {REV} {it} {OFF}" if i == sel else f" {it}") + "\r\n") + sys.stdout.write(DIM + "\r\n up/down move, Enter choose, q back" + OFF + "\r\n") + sys.stdout.flush() + ch = os.read(fd, 8) + if ch in (b"\x1b[A", b"k"): + sel = (sel - 1) % len(items) + elif ch in (b"\x1b[B", b"j"): + sel = (sel + 1) % len(items) + elif ch in (b"\r", b"\n"): + return sel + elif ch in (b"q", b"\x1b", b"\x03"): + return None + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + + +def pause(): + if sys.stdin.isatty(): + input("\nPress Enter to go back.") + + +def pick_instance(cfg, title): + nums = sorted(existing_numbers(cfg)) + if not nums: + print("No instances.") + return None + i = menu(title, [f"{n} {cfg['slots'].get(n, '?')}" for n in nums]) + return None if i is None else nums[i] + + +def mail_dialog(cfg): + m = dict(cfg.get("mail") or DEFAULT_MAIL) + for key, label in (("host", "SMTP host"), ("port", "SMTP port"), ("user", "SMTP user"), + ("password", "SMTP password"), ("admin_email", "Admin e-mail"), + ("sender_name", "Sender name")): + m[key] = ask(label, m.get(key, "")) + m["port"] = int(m["port"]) + return m + + +def choose_mail(cfg): + saved = cfg.get("mail") or DEFAULT_MAIL + print(f"Saved mail settings: {saved['host']}:{saved['port']} as {saved['user'] or '(no user)'}") + if ask("Use the saved mail settings? (Y/n)", "y").lower().startswith("n"): + return mail_dialog(cfg) + return saved + + +def detect_defaults(): + d = {"node_name": "", "lan_ip": "", "ssh_user": os.environ.get("SUDO_USER", "root")} + try: + d["node_name"] = kubectl("get", "nodes", "-o", "jsonpath={.items[0].metadata.name}").stdout.strip() + except Exception: + pass + try: + m = re.search(r"src (\S+)", run(["ip", "-4", "route", "get", "1.1.1.1"]).stdout) + d["lan_ip"] = m.group(1) if m else "" + except Exception: + pass + return d + + +def setup_dialog(cfg, log): + d = detect_defaults() + cfg = cfg or {} + cfg["storage_root"] = ask("Storage root (images live here)", cfg.get("storage_root", "/home/igor/k8s-storage/supabase")) + cfg["node_name"] = ask("Kubernetes node name", cfg.get("node_name", d["node_name"])) + cfg["lan_ip"] = ask("Server LAN IP (Postgres/Studio ports bind here)", cfg.get("lan_ip", d["lan_ip"])) + cfg["acme_email"] = ask("Let's Encrypt e-mail", cfg.get("acme_email", "")) + cfg["ingress_class"] = ask("Ingress class", cfg.get("ingress_class", "public")) + cur = ",".join(cfg["slots"][k] for k in sorted(cfg.get("slots", {}))) if cfg.get("slots") else "" + hosts = [h.strip() for h in ask("Hostname slots, comma separated (first = instance 1)", cur).split(",") if h.strip()] + cfg["slots"] = {i + 1: h for i, h in enumerate(hosts)} + cfg["ssh_host"] = ask("Public SSH host for tunnels", cfg.get("ssh_host", "")) + cfg["ssh_port"] = int(ask("Public SSH port", cfg.get("ssh_port", 22))) + cfg["ssh_user"] = ask("SSH user", cfg.get("ssh_user", d["ssh_user"])) + cfg["chart_version"] = cfg.get("chart_version", CHART_VERSION) + print("Default mail settings:") + cfg["mail"] = mail_dialog(cfg) + save_config(cfg) + ensure_cluster_objects(cfg, log) + log("Settings saved to " + CONFIG_PATH) + return cfg + + +def show_menu(cfg, n): + while True: + i = menu(f"Instance {n} - {cfg['slots'].get(n, '?')}", + [".env", ".env (secrets shown)", "Tunnel command", "Pods and events", "Back"]) + if i is None or i == 4: + return + print() + try: + if i in (0, 1): + text = read_env(cfg, n) + print(f"{inst_dir(cfg, n)}/.env\n\n" + (text if i == 1 else mask_env(text))) + elif i == 2: + print(tunnel_help(cfg, n)) + else: + print(kubectl("get", "pods", "-n", namespace(n), "-o", "wide").stdout) + print(kubectl("get", "events", "-n", namespace(n), "--sort-by=.lastTimestamp").stdout[-3000:]) + except Exception as e: + print(f"{RED}{e}{OFF}") + pause() + + +def print_list(cfg): + rows = list_rows(cfg) + if not rows: + print("No instances.") + return + for r in rows: + print(f"{BOLD}{r['n']}{OFF} {r['host']} pods {r['pods']} memory {r['memory']}") + print(f" disk {r['disk']}\n env {r['env']}") + + +def main_menu(cfg, log): + while True: + i = menu(f"supaspin {VERSION}", ["Create instance", "List instances", "Show instance", + "Delete instance", "Mail settings", "Settings", "Quit"]) + if i is None or i == 6: + return + print() + try: + if i == 0: + create_instance(cfg, choose_mail(cfg), log) + elif i == 1: + print_list(cfg) + elif i == 2: + n = pick_instance(cfg, "Show which instance?") + if n: + show_menu(cfg, n) + continue + elif i == 3: + n = pick_instance(cfg, "Delete which instance?") + if n and confirm_number(n, "deleting it"): + delete_instance(cfg, n, log) + elif i == 4: + cfg["mail"] = mail_dialog(cfg) + save_config(cfg) + elif i == 5: + cfg = setup_dialog(cfg, log) + except Exception as e: + print(f"{RED}{e}{OFF}") + pause() + + +def main(argv): + if len(argv) >= 2 and argv[1] == "post-render": # called by Helm; no root check, no output but YAML + cfg = load_config() + sys.stdout.write(post_render(sys.stdin.read(), int(argv[2]), cfg["lan_ip"])) + return 0 + if len(argv) > 1 and argv[1] in ("help", "-h", "--help"): + print(__doc__) + return 0 + if os.geteuid() != 0: + print("supaspin must run as root: sudo supaspin", file=sys.stderr) + return 1 + cmd = argv[1] if len(argv) > 1 else "menu" + cfg = load_config() + if cmd in ("--version", "version"): + print(VERSION) + return 0 + try: + if cfg is None or cmd == "setup": + print("First-time setup." if cfg is None else "Settings.") + cfg = setup_dialog(cfg, log_line) + if cmd == "setup": + return 0 + n = int(argv[2]) if len(argv) > 2 and argv[2].isdigit() else None + if cmd == "menu": + main_menu(cfg, log_line) + elif cmd == "create": + mail = cfg.get("mail") or DEFAULT_MAIL + if "--ask-mail" in argv: + mail = choose_mail(cfg) + create_instance(cfg, mail, log_line) + elif cmd == "list": + print_list(cfg) + elif cmd == "show" and n: + print(mask_env(read_env(cfg, n))) + print("\n" + tunnel_help(cfg, n)) + elif cmd == "env" and n: + print(read_env(cfg, n), end="") + elif cmd == "tunnel" and n: + print(tunnel_help(cfg, n)) + elif cmd == "delete" and n: + if "--yes" in argv or confirm_number(n, "deleting it"): + delete_instance(cfg, n, log_line) + elif cmd == "mail": + cfg["mail"] = mail_dialog(cfg) + save_config(cfg) + elif cmd in ("help", "-h", "--help"): + print(__doc__) + else: + print(__doc__) + return 2 + except (RuntimeError, KeyboardInterrupt) as e: + print(f"{RED}{e}{OFF}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/templates/values.yaml.tpl b/templates/values.yaml.tpl new file mode 100644 index 0000000..05d615c --- /dev/null +++ b/templates/values.yaml.tpl @@ -0,0 +1,88 @@ +# Generic Helm values for one supaspin instance (upstream supabase-kubernetes chart). +# Every dollar-brace placeholder below (for example the ANON_KEY one) is replaced by supaspin +# with a quoted string before Helm sees this file. +# Memory limits, priority class and volume sizes are added by supaspin itself (see +# instance_values() in the supaspin script) so they stay in one place next to the caps. +secret: + jwt: + anonKey: ${ANON_KEY} + serviceKey: ${SERVICE_KEY} + secret: ${JWT_SECRET} + db: + password: ${DB_PASSWORD} + database: postgres + analytics: + publicAccessToken: ${LOGFLARE_PUBLIC} + privateAccessToken: ${LOGFLARE_PRIVATE} + dashboard: + username: ${DASHBOARD_USERNAME} + password: ${DASHBOARD_PASSWORD} + s3: + keyId: ${S3_KEY_ID} + accessKey: ${S3_ACCESS_KEY} + realtime: + secretKeyBase: ${REALTIME_SECRET_KEY_BASE} + dbEncKey: ${REALTIME_DB_ENC_KEY} + meta: + cryptoKey: ${META_CRYPTO_KEY} + smtp: + username: ${SMTP_USER} + password: ${SMTP_PASS} + +deployment: + analytics: + enabled: true + vector: + enabled: true + +environment: + auth: + - {name: API_EXTERNAL_URL, value: ${PUBLIC_URL}} + - {name: GOTRUE_API_HOST, value: "0.0.0.0"} + - {name: GOTRUE_API_PORT, value: "9999"} + - {name: GOTRUE_SITE_URL, value: ${PUBLIC_URL}} + - {name: GOTRUE_URI_ALLOW_LIST, value: "*"} + - {name: GOTRUE_DISABLE_SIGNUP, value: "false"} + - {name: GOTRUE_JWT_DEFAULT_GROUP_NAME, value: authenticated} + - {name: GOTRUE_JWT_ADMIN_ROLES, value: service_role} + - {name: GOTRUE_JWT_AUD, value: authenticated} + - {name: GOTRUE_JWT_EXP, value: "3600"} + - {name: GOTRUE_EXTERNAL_EMAIL_ENABLED, value: "true"} + - {name: GOTRUE_MAILER_AUTOCONFIRM, value: "true"} + - {name: GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED, value: "false"} + - {name: GOTRUE_SMTP_ADMIN_EMAIL, value: ${SMTP_ADMIN_EMAIL}} + - {name: GOTRUE_SMTP_HOST, value: ${SMTP_HOST}} + - {name: GOTRUE_SMTP_PORT, value: ${SMTP_PORT}} + - {name: GOTRUE_EXTERNAL_PHONE_ENABLED, value: "false"} + - {name: GOTRUE_SMS_AUTOCONFIRM, value: "false"} + - {name: GOTRUE_SMTP_SENDER_NAME, value: ${SMTP_SENDER_NAME}} + - {name: GOTRUE_MAILER_URLPATHS_INVITE, value: /auth/v1/verify} + - {name: GOTRUE_MAILER_URLPATHS_CONFIRMATION, value: /auth/v1/verify} + - {name: GOTRUE_MAILER_URLPATHS_RECOVERY, value: /auth/v1/verify} + - {name: GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE, value: /auth/v1/verify} + studio: + - {name: HOSTNAME, value: "::"} + - {name: STUDIO_PORT, value: "3000"} + - {name: DEFAULT_ORGANIZATION_NAME, value: Default Organization} + - {name: DEFAULT_PROJECT_NAME, value: ${PROJECT_NAME}} + - {name: SUPABASE_PUBLIC_URL, value: ${PUBLIC_URL}} + - {name: NEXT_ANALYTICS_BACKEND_PROVIDER, value: postgres} + +ingress: + enabled: true + className: ${INGRESS_CLASS} + annotations: + # The chart default rewrites every path to "/", which breaks all APIs; null removes it. + nginx.ingress.kubernetes.io/rewrite-target: null + nginx.ingress.kubernetes.io/proxy-body-size: 50m + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + cert-manager.io/cluster-issuer: ${CLUSTER_ISSUER} + tls: + - hosts: [${HOST}] + secretName: ${TLS_SECRET} + hosts: + - host: ${HOST} + paths: + - path: / + pathType: Prefix diff --git a/tests/test_supaspin.py b/tests/test_supaspin.py new file mode 100644 index 0000000..dee097e --- /dev/null +++ b/tests/test_supaspin.py @@ -0,0 +1,327 @@ +"""Unit tests for the pure parts of supaspin. Run: python3 -m unittest discover -s tests""" +import base64 +import hashlib +import hmac +import importlib.machinery +import importlib.util +import json +import os +import unittest + +import yaml + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +loader = importlib.machinery.SourceFileLoader("supaspin", os.path.join(ROOT, "supaspin")) +spec = importlib.util.spec_from_loader("supaspin", loader) +sp = importlib.util.module_from_spec(spec) +loader.exec_module(sp) + +CFG = { + "storage_root": "/data/supabase", "node_name": "node1", "lan_ip": "192.168.10.30", + "acme_email": "a@b.c", "ingress_class": "public", + "slots": {1: "supa1.example.com", 2: "supa2.example.com", 3: "supa3.example.com"}, + "ssh_host": "203.0.113.5", "ssh_port": 2222, "ssh_user": "igor", + "chart_version": "0.8.0", +} +MAIL = {"host": "smtp.x.com", "port": 587, "user": "u", "password": "p", + "admin_email": "admin@x.com", "sender_name": "Sender"} + + +def template(): + with open(os.path.join(ROOT, "templates", "values.yaml.tpl")) as f: + return f.read() + + +class Numbering(unittest.TestCase): + def test_lowest_free_number_fills_gaps(self): + self.assertEqual(sp.lowest_free_number(CFG["slots"], set()), 1) + self.assertEqual(sp.lowest_free_number(CFG["slots"], {1, 3}), 2) + self.assertEqual(sp.lowest_free_number(CFG["slots"], {1, 2}), 3) + + def test_no_free_slot(self): + self.assertIsNone(sp.lowest_free_number(CFG["slots"], {1, 2, 3})) + + def test_ports_and_names(self): + self.assertEqual((sp.pg_port(1), sp.pg_port(3)), (5441, 5443)) + self.assertEqual((sp.studio_port(1), sp.studio_port(3)), (3001, 3003)) + self.assertEqual(sp.namespace(2), "supaspin-2") + self.assertEqual(sp.pvc_name(2, "db"), "supa2-supabase-db") + self.assertEqual(sp.tls_secret(2), "supa2-tls") + + +class Jwt(unittest.TestCase): + def test_signature_and_claims(self): + tok = sp.sign_jwt("s" * 40, "anon", now=1000) + h, p, s = tok.split(".") + pad = lambda x: x + "=" * (-len(x) % 4) + self.assertEqual(json.loads(base64.urlsafe_b64decode(pad(h))), {"alg": "HS256", "typ": "JWT"}) + claims = json.loads(base64.urlsafe_b64decode(pad(p))) + self.assertEqual((claims["role"], claims["iss"], claims["iat"]), ("anon", "supabase", 1000)) + self.assertGreater(claims["exp"], 1000 + 9 * 365 * 86400) + want = hmac.new(b"s" * 40, f"{h}.{p}".encode(), hashlib.sha256).digest() + self.assertEqual(base64.urlsafe_b64decode(pad(s)), want) + + def test_generated_secrets_are_consistent(self): + s = sp.generate_secrets() + self.assertEqual(len(s["REALTIME_DB_ENC_KEY"]), 16) + self.assertGreaterEqual(len(s["JWT_SECRET"]), 32) + self.assertGreaterEqual(len(s["META_CRYPTO_KEY"]), 32) + h, p, sig = s["ANON_KEY"].split(".") + want = hmac.new(s["JWT_SECRET"].encode(), f"{h}.{p}".encode(), hashlib.sha256).digest() + pad = lambda x: x + "=" * (-len(x) % 4) + self.assertEqual(base64.urlsafe_b64decode(pad(sig)), want) + self.assertNotEqual(sp.generate_secrets()["DB_PASSWORD"], s["DB_PASSWORD"]) + + +class Values(unittest.TestCase): + def setUp(self): + self.sec = sp.generate_secrets() + self.v = sp.instance_values(CFG, 2, self.sec, MAIL, template()) + + def test_memory_caps_add_up(self): + req, lim = sp.total_memory_mib() + # db 2048, analytics 768, kong 512, realtime 384, the other eight 256 each + self.assertEqual(lim, 2048 + 768 + 512 + 384 + 8 * 256) + self.assertEqual(lim / 1024, 5.625) + self.assertEqual(req, 512 + 11 * 128) # 1.875 GiB + # analytics and realtime were killed for running out of memory at half of production + self.assertGreaterEqual(sp.MEMORY["analytics"][1], 768) + self.assertGreaterEqual(sp.MEMORY["realtime"][1], 384) + self.assertEqual(req / 1024, 1.875) + for svc in sp.SERVICES: + self.assertEqual(self.v["deployment"][svc]["priorityClassName"], "supaspin-low") + self.assertIn("memory", self.v["deployment"][svc]["resources"]["limits"]) + + def test_all_twelve_services_enabled(self): + self.assertEqual(len(sp.SERVICES), 12) + self.assertTrue(self.v["deployment"]["analytics"]["enabled"]) + self.assertTrue(self.v["deployment"]["vector"]["enabled"]) + + def test_no_group_override_on_db_pod(self): + # The db image's own user is 100:101; a forced fsGroup would lock Postgres out of its volume. + self.assertNotIn("podSecurityContext", self.v["deployment"]["db"]) + self.assertEqual((sp.DB_UID, sp.DB_GID), (100, 101)) + + def test_volume_caps(self): + p = self.v["persistence"] + self.assertEqual(p["db"]["size"], "2048Mi") + self.assertEqual(p["storage"]["size"], "1024Mi") + for vol in ("functions", "snippets", "deno", "pgsodium"): + self.assertEqual(p[vol]["size"], "256Mi") + self.assertEqual(set(sp.VOLUMES), {"db", "storage", "functions", "snippets", "deno", "pgsodium"}) + self.assertTrue(all(p[v]["storageClassName"] == "supaspin-local-sc" for v in sp.VOLUMES)) + + def test_imgproxy_reads_the_storage_volume(self): + img = self.v["persistence"]["imgproxy"] + self.assertEqual(img["existingClaim"], "supa2-supabase-storage") + self.assertNotIn("imgproxy", sp.VOLUMES) # no separate image file for it + + def test_ingress_and_urls(self): + ing = self.v["ingress"] + self.assertEqual(ing["hosts"][0]["host"], "supa2.example.com") + self.assertEqual(ing["tls"][0]["secretName"], "supa2-tls") + self.assertEqual(ing["className"], "public") + self.assertIsNone(ing["annotations"]["nginx.ingress.kubernetes.io/rewrite-target"]) + env = {e["name"]: e["value"] for e in self.v["environment"]["auth"]} + self.assertEqual(env["API_EXTERNAL_URL"], "https://supa2.example.com") + self.assertEqual(env["GOTRUE_SMTP_HOST"], "smtp.x.com") + self.assertEqual(env["GOTRUE_SMTP_PORT"], "587") + self.assertEqual(self.v["secret"]["smtp"]["password"], "p") + + def test_secrets_reach_values(self): + self.assertEqual(self.v["secret"]["jwt"]["secret"], self.sec["JWT_SECRET"]) + self.assertEqual(self.v["secret"]["db"]["password"], self.sec["DB_PASSWORD"]) + + def test_special_characters_stay_strings(self): + m = dict(MAIL, password='p"a$s: #x', sender_name="A & B") + v = sp.instance_values(CFG, 1, self.sec, m, template()) + self.assertEqual(v["secret"]["smtp"]["password"], 'p"a$s: #x') + + +class EnvFile(unittest.TestCase): + def setUp(self): + self.sec = sp.generate_secrets() + self.text = sp.render_env(CFG, 1, self.sec, MAIL) + self.kv = dict(l.split("=", 1) for l in self.text.splitlines() if "=" in l and not l.startswith("#")) + + def test_expected_variables(self): + for k in ("SUPABASE_URL", "SUPABASE_PUBLIC_URL", "PUBLIC_SUPABASE_URL", "API_EXTERNAL_URL", + "PUBLIC_SUPABASE_ANON_KEY", "ANON_KEY", "JWT_ANON_KEY", "SERVICE_ROLE_KEY", + "JWT_SERVICE_KEY", "JWT_SECRET", "JWT_EXPIRY", "POSTGRES_PASSWORD", "POSTGRES_DB", + "POSTGRES_HOST", "POSTGRES_PORT", "DATABASE_URL", "DASHBOARD_USERNAME", + "DASHBOARD_PASSWORD", "GOTRUE_SMTP_HOST", "GOTRUE_SMTP_PORT", "GOTRUE_SMTP_USER", + "GOTRUE_SMTP_PASS", "GOTRUE_SMTP_ADMIN_EMAIL", "GOTRUE_SMTP_SENDER_NAME"): + self.assertIn(k, self.kv) + self.assertEqual(self.kv["SUPABASE_URL"], "https://supa1.example.com") + + def test_database_url_and_tunnel_comment(self): + self.assertEqual(self.kv["DATABASE_URL"], + f"postgresql://postgres:{self.sec['DB_PASSWORD']}@localhost:5441/postgres") + self.assertIn("192.168.10.30:5441", self.text) + self.assertIn("ssh -N -p 2222 -L 5441:192.168.10.30:5441 -L 3001:192.168.10.30:3001 igor@203.0.113.5", + self.text) + + def test_mask_hides_secrets_only(self): + masked = sp.mask_env(self.text) + self.assertNotIn(self.sec["DB_PASSWORD"], masked) + self.assertNotIn(self.sec["JWT_SECRET"], masked) + self.assertIn("SUPABASE_URL=https://supa1.example.com", masked) + self.assertIn("POSTGRES_PORT=5441", masked) + + +class Fstab(unittest.TestCase): + def test_line_and_removal(self): + line = sp.fstab_line("/r/1/db/db.img", "/r/1/db/data", "/r") + self.assertEqual(line, "/r/1/db/db.img /r/1/db/data ext4 loop,nofail,x-systemd.requires-mounts-for=/r 0 0") + text = "UUID=x / ext4 defaults 0 1\n" + line + "\n# " + line + "\n" + self.assertTrue(sp.fstab_has(text, "/r/1/db/data")) + out = sp.fstab_without(text, "/r/1/db/data") + self.assertFalse(sp.fstab_has(out, "/r/1/db/data")) + self.assertIn("UUID=x / ext4", out) + self.assertIn("# /r/1/db/db.img", out) # comments are left alone + + def test_mounts_under_an_instance_folder(self): + text = ("a /r/1/db/data ext4 x 0 0\n" + "b /r/1/imgproxy/data ext4 x 0 0\n" # a volume an older version created + "c /r/10/db/data ext4 x 0 0\n" # another instance + "# d /r/1/old/data ext4 x 0 0\n" + "e /r/1 ext4 x 0 0\n") # the folder itself is not below itself + self.assertEqual(sp.fstab_mounts_under(text, "/r/1"), ["/r/1/db/data", "/r/1/imgproxy/data"]) + + def test_similar_paths_are_not_removed(self): + text = "a /r/1/db/data ext4 x 0 0\nb /r/10/db/data ext4 x 0 0\n" + self.assertIn("/r/10/db/data", sp.fstab_without(text, "/r/1/db/data")) + + +RENDERED = """ +apiVersion: apps/v1 +kind: StatefulSet +metadata: {name: supa1-supabase-db} +spec: {template: {spec: {containers: [{name: db, ports: [{name: http, containerPort: 5432}]}]}}} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: {name: supa1-supabase-studio} +spec: {template: {spec: {containers: [{name: studio, ports: [{name: http, containerPort: 3000}]}]}}} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: {name: supa1-supabase-rest} +spec: {template: {spec: {containers: [{name: rest, ports: [{name: http, containerPort: 3000}]}]}}} +--- +apiVersion: v1 +kind: ConfigMap +metadata: {name: supa1-supabase-kong} +data: + kong-entrypoint.sh: "echo hi\\necho there\\n" + kong.yml: | + _format_version: '2.1' + services: + - name: auth-v1 + url: http://auth:9999 + routes: [{name: auth-v1-all, paths: [/auth/v1/]}] + plugins: + - name: request-transformer + config: + add: + headers: + - "Authorization: ${LUA_AUTH_EXPR}" + - name: meta + url: http://meta:8080/ + routes: [{name: meta-all, paths: [/pg/]}] + - name: dashboard + url: http://studio:3000/ + routes: [{name: dashboard-all, paths: [/]}] +""" + + +class PostRender(unittest.TestCase): + def setUp(self): + self.docs = list(yaml.safe_load_all(sp.post_render(RENDERED, 1, "192.168.10.30"))) + + def find(self, kind, name): + return next(d for d in self.docs if d["kind"] == kind and d["metadata"]["name"] == name) + + def test_db_and_studio_get_fixed_host_ports(self): + db = self.find("StatefulSet", "supa1-supabase-db")["spec"]["template"]["spec"]["containers"][0]["ports"][0] + self.assertEqual((db["hostPort"], db["hostIP"], db["containerPort"]), (5441, "192.168.10.30", 5432)) + st = self.find("Deployment", "supa1-supabase-studio")["spec"]["template"]["spec"]["containers"][0]["ports"][0] + self.assertEqual((st["hostPort"], st["hostIP"]), (3001, "192.168.10.30")) + + def test_other_pods_untouched(self): + rest = self.find("Deployment", "supa1-supabase-rest")["spec"]["template"]["spec"]["containers"][0]["ports"][0] + self.assertNotIn("hostPort", rest) + + def test_studio_and_pgmeta_routes_removed(self): + kong = yaml.safe_load(self.find("ConfigMap", "supa1-supabase-kong")["data"]["kong.yml"]) + self.assertEqual([s["name"] for s in kong["services"]], ["auth-v1"]) + self.assertEqual(kong["_format_version"], "2.1") + + def test_kong_file_is_cut_not_rewritten(self): + # Kong's start script substitutes ${LUA_AUTH_EXPR} inside these exact double quotes. + text = self.find("ConfigMap", "supa1-supabase-kong")["data"]["kong.yml"] + self.assertIn('- "Authorization: ${LUA_AUTH_EXPR}"', text) + self.assertIn("_format_version: '2.1'", text) + self.assertNotIn("name: meta", text) + self.assertNotIn("name: dashboard", text) + + def test_strip_keeps_following_top_level_keys(self): + src = "services:\n - name: meta\n url: x\n - name: keep\n url: y\nother: 1\n" + self.assertEqual(sp.strip_kong_services(src, ("meta",)), + "services:\n - name: keep\n url: y\nother: 1\n") + src2 = "services:\n - name: dashboard\n url: x\nother: 1\n" + self.assertEqual(sp.strip_kong_services(src2, ("dashboard",)), "services:\nother: 1\n") + + def test_other_config_keys_survive(self): + cm = self.find("ConfigMap", "supa1-supabase-kong") + self.assertEqual(cm["data"]["kong-entrypoint.sh"], "echo hi\necho there\n") + + def test_instance_number_selects_ports(self): + docs = list(yaml.safe_load_all(sp.post_render(RENDERED.replace("supa1", "supa3"), 3, "10.0.0.1"))) + db = next(d for d in docs if d["kind"] == "StatefulSet")["spec"]["template"]["spec"]["containers"][0]["ports"][0] + self.assertEqual((db["hostPort"], db["hostIP"]), (5443, "10.0.0.1")) + + +class Manifests(unittest.TestCase): + def test_pv_is_pinned_and_reserved_for_the_chart_claim(self): + pv = sp.pv_manifest(CFG, 2, "db") + self.assertEqual(pv["spec"]["claimRef"], {"namespace": "supaspin-2", "name": "supa2-supabase-db"}) + self.assertEqual(pv["spec"]["local"]["path"], "/data/supabase/2/db/data") + self.assertEqual(pv["spec"]["capacity"]["storage"], "2048Mi") + self.assertEqual(pv["spec"]["persistentVolumeReclaimPolicy"], "Retain") + self.assertEqual(pv["spec"]["nodeAffinity"]["required"]["nodeSelectorTerms"][0] + ["matchExpressions"][0]["values"], ["node1"]) + self.assertEqual(sp.pv_manifest(CFG, 2, "deno")["spec"]["capacity"]["storage"], "256Mi") + + def test_saved_certificate_keeps_issuer_annotations(self): + src = {"type": "kubernetes.io/tls", + "metadata": {"name": "supa1-tls", "namespace": "supaspin-1", "uid": "x", + "resourceVersion": "9", "ownerReferences": [{"name": "o"}], + "annotations": {"cert-manager.io/issuer-name": "supaspin-letsencrypt", + "cert-manager.io/issuer-kind": "ClusterIssuer", + "cert-manager.io/issuer-group": "cert-manager.io", + "kubectl.kubernetes.io/last-applied-configuration": "{}"}}, + "data": {"tls.crt": "Yg==", "tls.key": "Yw=="}} + out = sp.carry_secret(src, "supaspin-certs", "supa1-tls") + self.assertEqual(out["metadata"], {"name": "supa1-tls", "namespace": "supaspin-certs", + "annotations": {"cert-manager.io/issuer-name": "supaspin-letsencrypt", + "cert-manager.io/issuer-kind": "ClusterIssuer", + "cert-manager.io/issuer-group": "cert-manager.io"}}) + self.assertEqual(out["data"], src["data"]) + self.assertEqual(out["type"], "kubernetes.io/tls") + + def test_secret_without_annotations_still_copies(self): + out = sp.carry_secret({"data": {"tls.crt": "a"}}, "ns", "n") + self.assertNotIn("annotations", out["metadata"]) + self.assertEqual(out["type"], "kubernetes.io/tls") + + def test_tunnel_help_mentions_all_steps(self): + text = sp.tunnel_help(CFG, 1, "PW") + self.assertIn("postgresql://postgres:PW@localhost:5441/postgres", text) + self.assertIn("http://localhost:3001", text) + self.assertIn("192.168.10.30:5441", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..02cf30a --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Remove supaspin. Run on the server: sudo bash uninstall.sh +# Asks before deleting instances (their data is gone for good) and before removing the +# shared cluster objects that the first-run setup created. +set -uo pipefail + +[ "$(id -u)" -eq 0 ] || { echo "Run as root: sudo bash uninstall.sh" >&2; exit 1; } + +if [ -x /snap/bin/microk8s.kubectl ]; then K=/snap/bin/microk8s.kubectl; else K=kubectl; fi + +# Ask on the terminal (works when the script itself arrives through a pipe), else on stdin. +ask() { + if { : /dev/null; then read -r -p "$1" a . + nums="$($K get ns -o name 2>/dev/null | sed -n 's#^namespace/supaspin-\([0-9][0-9]*\)$#\1#p' | sort -n)" + if [ -n "$nums" ]; then + echo "Instances found: $(echo $nums)" + ask "Delete ALL of them, with their databases and files? Type yes: " + if [ "$a" = "yes" ]; then + for n in $nums; do /usr/local/bin/supaspin delete "$n" --yes; done + else + echo "Keeping the instances; they will keep running without the supaspin command." + fi + fi + ask "Also remove the shared cluster objects (StorageClass supaspin-local-sc, issuer supaspin-letsencrypt, PriorityClass supaspin-low, namespace supaspin-certs with saved certificates)? Type yes: " + if [ "$a" = "yes" ]; then + $K delete storageclass supaspin-local-sc --ignore-not-found + $K delete clusterissuer supaspin-letsencrypt --ignore-not-found + $K delete priorityclass supaspin-low --ignore-not-found + $K delete namespace supaspin-certs --ignore-not-found + fi +fi + +rm -f /usr/local/bin/supaspin +rm -rf /usr/share/supaspin /etc/supaspin /var/lib/supaspin +echo "supaspin removed."