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 <LAN IP>:544N and Studio on <LAN IP>: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 <noreply@anthropic.com>
This commit was merged in pull request #1.
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
11802ce8cd
commit
cfbc4f1612
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user