feat: supaspin, on-demand throwaway Supabase instances on microk8s #1

Merged
igor merged 1 commits from feat/initial-supaspin into main 2026-09-18 20:03:22 +00:00
7 changed files with 1720 additions and 1 deletions
Showing only changes of commit cfbc4f1612 - Show all commits
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+190 -1
View File
@@ -1,3 +1,192 @@
# supaspin # supaspin
On-demand throwaway Supabase instances on microk8s 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://<slot hostname>` (auth, rest, graphql, realtime, storage, functions), with a Let's Encrypt certificate |
| Postgres | `<LAN IP>:544N` (instance 1 → 5441, 2 → 5442, …), bound on the LAN address only |
| Studio (admin UI) | `http://<LAN IP>: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` (`<root>/<N>/…`) |
| 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 `<root>/<N>/.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 `<root>/<N>/.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:<password>@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 `<LAN IP>:5441` and `http://<LAN IP>: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 `<root>/<N>/`.
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`.
Executable
+69
View File
@@ -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=<branch or tag> installs that version instead of main
# SUPASPIN_SRC=<folder> installs from a local checkout instead of downloading
# SUPASPIN_TOKEN=<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=<read-only 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"
Executable
+1005
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -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
+327
View File
@@ -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()
Executable
+39
View File
@@ -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/tty; } 2>/dev/null; then read -r -p "$1" a </dev/tty; else read -r -p "$1" a; fi
}
if [ -x /usr/local/bin/supaspin ] && [ -f /etc/supaspin/config.yaml ]; then
# Same discovery as `supaspin list`: namespaces named supaspin-<N>.
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."