feat: supaspin, on-demand throwaway Supabase instances on microk8s #1
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -1,3 +1,192 @@
|
||||
# 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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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."
|
||||
Reference in New Issue
Block a user