8 Commits
Author SHA1 Message Date
igor 4a267c5336 Merge pull request 'feat: Traefik ingress, optional Lets Encrypt, configurable port bind, slot numbers from hostnames' (#12) from feat/traefik-optional-le-port-bind-slot-numbers into main 2026-10-09 14:32:43 +00:00
Igor K. d4fedd718f feat: Traefik support, optional Let's Encrypt, configurable port bind, hostname-numbered slots, dblink in extensions schema
- Traefik is a first-class ingress controller choice: per-instance Middleware
  caps uploads at 50 MiB, nginx keeps its proxy-* annotations; picked in setup
- Let's Encrypt is optional: off skips the ClusterIssuer and gives each instance
  a 10-year self-signed certificate instead of a Let's Encrypt one
- New port_bind setting: lan (default, LAN IP only), all (LAN + localhost),
  local (localhost only)
- Hostname slots are keyed by the digit in the name (supa4 -> slot 4), so the
  instance number, ports and hostname stay in step on every machine
- The log-retention job installs dblink and its helper into the extensions
  schema instead of public: in public they break the app's
  REVOKE ALL ON ALL ROUTINES IN SCHEMA public for the non-superuser postgres
  role (42501 permission denied for function dblink_connect_u)
2026-10-08 14:40:24 -04:00
igor 2f47ddedf1 Merge pull request 'fix: run the log-retention SQL in the postgres database' (#11) from fix/log-retention-run-in-postgres into main 2026-10-06 17:35:54 +00:00
Igor K. 80b84afb1e fix: run the log-retention SQL in the postgres database
1.3.3 put the dblink job into the SQL but still ran psql against
_supabase, where pg_cron refuses the extension; the psql session now runs
in postgres, the only database pg_cron accepts in this image
version 1.3.4
2026-10-06 20:35:43 +03:00
igor 4d0fa4fe69 Merge pull request 'fix: reach the log-retention job into _supabase over dblink' (#10) from fix/log-retention-pg-cron-dblink into main 2026-10-06 17:34:48 +00:00
Igor K. 2c6e383617 fix: reach the log-retention job into _supabase over dblink
the supabase image pins cron.database_name to postgres, so pg_cron refuses
jobs scheduled from _supabase, where the log tables live; 1.3.2's upgrade
failed on the first live run with 'can only create extension in database
postgres'. The job now runs in postgres and deletes the _analytics
log_events_* rows in _supabase over a local dblink connection, which needs
no password because the image trusts local connections for supabase_admin.
version 1.3.3
2026-10-06 20:34:39 +03:00
igor ea3b705008 Merge pull request 'fix: delete analytics logs older than 2 days and give vector a 512 MiB cap' (#9) from fix/log-retention-vector-512 into main 2026-10-06 17:28:48 +00:00
Igor K. a923839c47 fix: delete analytics logs older than 2 days and give vector a 512 MiB cap
- every instance gets a pg_cron job (set up on create, upgrade and resume)
  that deletes _analytics.log_events_* rows older than 2 days and runs the
  first cleanup right away; Logflare never deletes stored logs, and instance
  3's 2 GB db image died 100% full of them on 2026-10-06, after which
  analytics 500'd every delivery and vector was OOM-killed by its
  undeliverable backlog every few minutes
- vector's memory limit back to 512 MiB, the ceiling the production
  supabase charts carry; the retention job bounds its backlog, so 1024 MiB
  is not needed anymore
- version 1.3.2
2026-10-06 20:28:29 +03:00
5 changed files with 438 additions and 60 deletions
+41 -23
View File
@@ -1,6 +1,6 @@
# supaspin # supaspin
Version 1.3.1 Version 1.6.1
Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a
single-node [microk8s](https://microk8s.io) server, from a terminal menu. single-node [microk8s](https://microk8s.io) server, from a terminal menu.
@@ -13,11 +13,11 @@ endangering them. Nothing in it is specific to any one app.
| | | | | |
|---|---| |---|---|
| Web API | `https://<slot hostname>` (auth, rest, graphql, realtime, storage, functions), with a Let's Encrypt certificate | | Web API | `https://<slot hostname>` (auth, rest, graphql, realtime, storage, functions); the certificate is from Let's Encrypt when that is enabled, otherwise self-signed |
| Postgres | `<LAN IP>:544N` (instance 1 → 5441, 2 → 5442, …), bound on the LAN address only | | Postgres | `<LAN IP>:544N` (instance 1 → 5441, 2 → 5442, …); which addresses listen is set by **Port bind** |
| Studio (admin UI) | `http://<LAN IP>:300N` (3001, 3002, …), not on the public hostname | | 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** | | 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 **9.5 GiB** per instance, requests to 1.875 GiB (table below) | | Memory | limits add up to **9 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 | | 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 | | 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 | | Uploads | stored in the instance's own storage image (no MinIO); deleted with the instance |
@@ -32,10 +32,14 @@ of instances at once. Numbers are reused: create picks the lowest free one.
## Requirements ## Requirements
- A Debian/Ubuntu server with **microk8s** (or any Kubernetes with `kubectl` and `helm` on the - 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. path), with an ingress controller installed: **ingress-nginx** or **Traefik** (the microk8s
- DNS `A` records for every hostname slot pointing at the server, and ports 80 and 443 forwarded to `ingress` addon is either, depending on the version).
it (Let's Encrypt fetches a test file over port 80 to issue each certificate). - With **Let's Encrypt** enabled: **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). Without it: no cert-manager, no public DNS — each
instance gets a self-signed certificate instead.
- Root access on that server. `python3` and `python3-yaml` (the installer adds the latter with `apt`). - Root access on that server. `python3` and `python3-yaml` (the installer adds the latter with `apt`).
Self-signed certificates additionally need `openssl` on the server.
## Install ## Install
@@ -70,17 +74,21 @@ sudo supaspin
|---|---| |---|---|
| Storage root | folder that holds every instance's images and `.env` (`<root>/<N>/…`) | | Storage root | folder that holds every instance's images and `.env` (`<root>/<N>/…`) |
| Node name | Kubernetes node the volumes are pinned to (detected) | | Node name | Kubernetes node the volumes are pinned to (detected) |
| Server LAN IP | address the Postgres and Studio ports are bound to (detected) | | Server LAN IP | address shown for the Postgres and Studio ports (detected) |
| Let's Encrypt e-mail | for certificate expiry notices | | Port bind | `lan` = LAN IP only (default; keeps Postgres and the login-less Studio off a public interface), `all` = every interface incl. localhost (for a box behind NAT running its own apps), `local` = localhost only |
| Ingress class | usually `public` on microk8s | | Ingress class | usually `public` on microk8s |
| Hostname slots | comma-separated; the first is instance 1, the second instance 2, … | | Ingress controller | `traefik` or `nginx`; Traefik instances get a Middleware that caps uploads at 50 MiB, nginx gets the equivalent annotations |
| Let's Encrypt | `y` fetches a real certificate per hostname (needs public DNS, ports 80/443, cert-manager); `n` gives every instance a self-signed certificate and creates no cert-manager objects |
| Let's Encrypt e-mail | for certificate expiry notices (asked only when Let's Encrypt is on) |
| Hostname slots | comma-separated; a name with a number in it keys that slot (`supa4.didgibot.com` = slot 4, so instance 4 gets it with ports 5444/3004), a plain name takes its position in the list |
| Public SSH host / port / user | only used to print the tunnel command | | Public SSH host / port / user | only used to print the tunnel command |
| Mail settings | default SMTP settings for the auth service (changeable later) | | 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, 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, only if missing: the `supaspin-local-sc` StorageClass, the `supaspin-low` PriorityClass and the
the `supaspin-low` PriorityClass and the `supaspin-certs` namespace, and downloads the pinned `supaspin-certs` namespace; with Let's Encrypt on it also creates the `supaspin-letsencrypt`
Supabase Helm chart (0.8.0, Postgres 17.6.1.136) once into `/var/lib/supaspin/charts/`. ClusterIssuer. It then downloads the pinned Supabase Helm chart (0.8.0, Postgres 17.6.1.136) once
into `/var/lib/supaspin/charts/`.
## Using it ## Using it
@@ -93,7 +101,7 @@ Supabase Helm chart (0.8.0, Postgres 17.6.1.136) once into `/var/lib/supaspin/ch
| Show instance (and suspend or resume it) | the `.env` (secrets hidden unless you ask), the tunnel command, pod status and recent events - and the one line that stops this instance or starts it again, worded for whichever state it is in | | Show instance (and suspend or resume it) | the `.env` (secrets hidden unless you ask), the tunnel command, pod status and recent events - and the one line that stops this instance or starts it again, worded for whichever state it is in |
| Delete instance | asks you to type the number; removes everything (see below) | | Delete instance | asks you to type the number; removes everything (see below) |
| Copy instance values into another .env file | puts an instance's `.env` values into your app's `.env`, on this server or on another computer (see below) | | Copy instance values into another .env file | puts an instance's `.env` values into your app's `.env`, on this server or on another computer (see below) |
| Apply current memory caps to running instances | puts this version's memory limits on instances that already exist, replacing their pods one service at a time; databases, uploads and `.env` files are not touched | | Apply current memory caps and log retention to running instances | puts this version's memory limits and the log-retention job on instances that already exist, replacing their pods one service at a time; databases, uploads and `.env` files are not touched |
| Mail settings | view and change the saved default mail settings | | Mail settings | view and change the saved default mail settings |
| Settings | change any first-run setting | | Settings | change any first-run setting |
@@ -262,11 +270,11 @@ stores the new memory limits and leaves it asleep; they take effect when you res
|---|---|---|---| |---|---|---|---|
| db | 512 MiB | 2048 MiB | 273 MiB | | db | 512 MiB | 2048 MiB | 273 MiB |
| analytics | 128 MiB | 1536 MiB | 694 MiB | | analytics | 128 MiB | 1536 MiB | 694 MiB |
| vector | 128 MiB | 1024 MiB | 290 MiB | | vector | 128 MiB | 512 MiB | 290 MiB |
| kong | 128 MiB | 768 MiB | 269 MiB | | kong | 128 MiB | 768 MiB | 269 MiB |
| realtime | 128 MiB | 768 MiB | 300 MiB | | realtime | 128 MiB | 768 MiB | 300 MiB |
| auth, functions, imgproxy, meta, rest, storage, studio | 128 MiB each | 512 MiB each | 203 MiB (studio) | | auth, functions, imgproxy, meta, rest, storage, studio | 128 MiB each | 512 MiB each | 203 MiB (studio) |
| **Total** | **1.875 GiB** | **9.5 GiB** | | | **Total** | **1.875 GiB** | **9 GiB** | |
The request is what the scheduler sets aside for a service and can never be given to anything else. The request is what the scheduler sets aside for a service and can never be given to anything else.
The limit is the ceiling the kernel kills the container at. They do different jobs here. The limit is the ceiling the kernel kills the container at. They do different jobs here.
@@ -278,13 +286,23 @@ in. The limits are roughly twice the highest use measured across three running i
The earlier limits sat right on top of normal use and the kernel kept killing the two logging The earlier limits sat right on top of normal use and the kernel kept killing the two logging
services. Analytics (Logflare, which stores the logs Studio shows) used 612, 694 and 662 MiB of a services. Analytics (Logflare, which stores the logs Studio shows) used 612, 694 and 662 MiB of a
768 MiB ceiling and was killed 36 times on one instance. Vector, the log collector that reads the 768 MiB ceiling and was killed 36 times on one instance. Vector, the log collector that ships the
log files of every pod on the server and not only its own instance's, was killed 297 times on that instance's pod logs to analytics, was killed 297 times on that same instance, where a backlog it
same instance, where a backlog it could not deliver kept growing in its memory. The two feed each could not deliver kept growing in its memory. The two feed each other: analytics dies, vector
other: analytics dies, vector cannot deliver, vector fills up and dies, and on restart it replays cannot deliver, vector fills up and dies, and on restart it replays the backlog into analytics.
the backlog into analytics. Realtime at 300 MiB of 384 and studio at 203 MiB of 256 were the next Realtime at 300 MiB of 384 and studio at 203 MiB of 256 were the next in line. Image resizing
in line. Image resizing (imgproxy) reads the uploads from the storage volume, so it has no image of (imgproxy) reads the uploads from the storage volume, so it has no image of its own.
its own.
On 2026-10-06 instance 3 died of the same pipeline in a different place: Logflare stores the logs
it receives in the `_supabase` database and never deletes them, so the 2 GB db image filled to
100% over eight days, Postgres crashed and could not even start its recovery, analytics answered
every delivery with an error, and vector was killed against its 1024 MiB ceiling every few
minutes with the undeliverable backlog. The fix is log retention: every instance gets a pg_cron
job (set up on create, upgrade and resume) that deletes analytics logs older than **2 days** every
night. pg_cron in this image only runs jobs from the `postgres` database, so the job lives there
and reaches the log tables in `_supabase` over a local dblink connection. That bounds both the
db image and vector's backlog, so vector is back to the 512 MiB ceiling the production supabase
charts carry.
Changing the numbers in this table only affects instances created afterwards. To put them on Changing the numbers in this table only affects instances created afterwards. To put them on
instances that already run, use `sudo supaspin upgrade`: it reads back the values Helm stored when instances that already run, use `sudo supaspin upgrade`: it reads back the values Helm stored when
+1 -1
View File
@@ -1 +1 @@
1.3.1 1.6.1
+207 -18
View File
@@ -39,7 +39,7 @@ import tty
import yaml import yaml
VERSION = "1.3.1" VERSION = "1.6.1"
CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml") CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml")
STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin") STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin")
CHART_REPO = "https://supabase-community.github.io/supabase-kubernetes" CHART_REPO = "https://supabase-community.github.io/supabase-kubernetes"
@@ -72,14 +72,62 @@ DB_UID, DB_GID = 100, 101
# kong 260 / 269 / 265 MiB against 512 # kong 260 / 269 / 265 MiB against 512
# studio 203, storage 187, rest 162 MiB against 256 # studio 203, storage 187, rest 162 MiB against 256
# db 163 / 175 / 273 MiB against 2048 - left alone, it has room # db 163 / 175 / 273 MiB against 2048 - left alone, it has room
# On 2026-10-06 instance 3 filled its 2 GB database image with logs: analytics (Logflare) never
# deletes what it stored, the db died of it, and vector's undelivered backlog was killed against
# the 1024 MiB ceiling every few minutes. The fix is setup_log_retention: a pg_cron job deletes
# analytics logs older than LOG_RETENTION in every instance, which also bounds vector's backlog,
# so vector is back to the 512 MiB ceiling the production supabase charts carry.
# Raising a limit costs nothing until a container actually asks for the memory; raising a request # Raising a limit costs nothing until a container actually asks for the memory; raising a request
# would take the memory away from everything else on the node straight away. # would take the memory away from everything else on the node straight away.
SERVICES = ["db", "analytics", "kong", "auth", "functions", "imgproxy", "meta", SERVICES = ["db", "analytics", "kong", "auth", "functions", "imgproxy", "meta",
"realtime", "rest", "storage", "studio", "vector"] "realtime", "rest", "storage", "studio", "vector"]
MEMORY = {"db": (512, 2048), "analytics": (128, 1536), "kong": (128, 768), "realtime": (128, 768), MEMORY = {"db": (512, 2048), "analytics": (128, 1536), "kong": (128, 768), "realtime": (128, 768),
"vector": (128, 1024)} "vector": (128, 512)}
DEFAULT_MEMORY = (128, 512) DEFAULT_MEMORY = (128, 512)
# Analytics logs: deleted from every instance's _supabase database when older than this. Logflare
# stores logs in _analytics.log_events_* tables and never deletes them; without this job the
# capped db image fills up and the database dies (instance 3, 2026-10-06).
# pg_cron in the supabase image only runs jobs from the database named in cron.database_name,
# which the image pins to `postgres`, while the logs live in `_supabase`. The job therefore runs
# in `postgres` and reaches the logs over a local dblink connection, which needs no password: the
# image trusts local socket connections for supabase_admin.
# dblink and the helper function MUST live in the `extensions` schema, not `public`: the app's
# migrations revoke API-role privileges on every routine in public, and `postgres` (the role
# Prisma connects as) is no superuser here - revoking someone else's routines fails with
# "permission denied for function dblink_connect_u" (42501, maralago 0_init, 2026-10-08).
LOG_RETENTION = "2 days"
LOG_RETENTION_JOB = "supaspin-log-retention"
RETENTION_SQL = f"""
create extension if not exists pg_cron;
create extension if not exists dblink with schema extensions;
alter extension dblink set schema extensions;
drop function if exists public.supaspin_delete_old_logs();
create or replace function extensions.supaspin_delete_old_logs() returns void
language plpgsql as $fn$
declare
conn text := 'supaspin_lr_' || substr(md5(clock_timestamp()::text), 1, 8);
t text;
begin
perform extensions.dblink_connect(conn, 'dbname=_supabase user=supabase_admin');
for t in
select tablename from extensions.dblink(conn,
'select tablename from pg_tables where schemaname = ''_analytics'' and tablename like ''log_events\\_%''')
as log_tables(tablename text)
loop
perform extensions.dblink_exec(conn,
format('delete from _analytics.%I where timestamp < now() - interval ''{LOG_RETENTION}''', t));
end loop;
perform extensions.dblink_disconnect(conn);
exception when others then
begin perform extensions.dblink_disconnect(conn); exception when others then null; end;
raise;
end $fn$;
select extensions.supaspin_delete_old_logs();
delete from cron.job where jobname = '{LOG_RETENTION_JOB}';
select cron.schedule('{LOG_RETENTION_JOB}', '17 3 * * *', 'select extensions.supaspin_delete_old_logs()');
""".strip()
DEFAULT_MAIL = {"host": "smtp.example.com", "port": 587, "user": "", "password": "", DEFAULT_MAIL = {"host": "smtp.example.com", "port": 587, "user": "", "password": "",
"admin_email": "admin@example.com", "sender_name": "Supabase"} "admin_email": "admin@example.com", "sender_name": "Supabase"}
@@ -117,6 +165,51 @@ def tls_secret(n):
return f"supa{n}-tls" return f"supa{n}-tls"
_SLOT_NUM = re.compile(r"(\d+)")
def parse_slots(text):
"""Slots {number: hostname} from the comma-separated setup answer. A hostname with a
number in it keys the slot by that number - supa4.didgibot.com is slot 4 - so the
instance number, its ports and the hostname digit stay in step; a name without a
number takes its position in the list (the first name is slot 1)."""
slots = {}
for i, h in enumerate([p.strip() for p in text.split(",") if p.strip()], 1):
m = _SLOT_NUM.search(h)
n = int(m.group(1)) if m else i
if n in slots:
raise ValueError(f"'{h}' wants slot {n}, which '{slots[n]}' already takes.")
slots[n] = h
return slots
def buffering_middleware(n):
"""Name of the Traefik Middleware that caps the request body size for instance n."""
return f"supa{n}-buffering"
def ingress_annotations(cfg, n):
"""Ingress annotations for instance n, shaped for the configured ingress controller.
With Traefik the upload cap lives in a Middleware object created next to the instance
and the ingress points at it; with nginx it is proxy-body-size, and the chart's own
rewrite-target default must be nullified or every API path breaks. The cert-manager
annotation orders a certificate from the supaspin-letsencrypt issuer; without it the
TLS secret keeps whatever supaspin put there (a saved or a self-signed certificate)."""
anns = {}
if cfg.get("lets_encrypt", True):
anns["cert-manager.io/cluster-issuer"] = ISSUER
if cfg.get("ingress_controller", "nginx") == "traefik":
anns["traefik.ingress.kubernetes.io/router.middlewares"] = \
f"{namespace(n)}-{buffering_middleware(n)}@kubernetescrd"
else:
# The chart default rewrites every path to "/", which breaks all APIs; null removes it.
anns["nginx.ingress.kubernetes.io/rewrite-target"] = None
anns["nginx.ingress.kubernetes.io/proxy-body-size"] = "50m"
anns["nginx.ingress.kubernetes.io/proxy-read-timeout"] = "3600"
anns["nginx.ingress.kubernetes.io/proxy-send-timeout"] = "3600"
return anns
def lowest_free_number(slots, taken): def lowest_free_number(slots, taken):
"""Lowest slot number that is not in `taken`; None if every slot is used.""" """Lowest slot number that is not in `taken`; None if every slot is used."""
for n in sorted(int(k) for k in slots): for n in sorted(int(k) for k in slots):
@@ -179,13 +272,15 @@ def instance_values(cfg, n, sec, mail, template_text):
subs.update({ subs.update({
"PUBLIC_URL": f"https://{host}", "HOST": host, "PUBLIC_URL": f"https://{host}", "HOST": host,
"TLS_SECRET": tls_secret(n), "INGRESS_CLASS": cfg["ingress_class"], "TLS_SECRET": tls_secret(n), "INGRESS_CLASS": cfg["ingress_class"],
"CLUSTER_ISSUER": ISSUER, "PROJECT_NAME": f"supaspin-{n}", "PROJECT_NAME": f"supaspin-{n}",
"INGRESS_ANNOTATIONS": ingress_annotations(cfg, n),
"SMTP_HOST": mail["host"], "SMTP_PORT": mail["port"], "SMTP_USER": mail["user"], "SMTP_HOST": mail["host"], "SMTP_PORT": mail["port"], "SMTP_USER": mail["user"],
"SMTP_PASS": mail["password"], "SMTP_ADMIN_EMAIL": mail["admin_email"], "SMTP_PASS": mail["password"], "SMTP_ADMIN_EMAIL": mail["admin_email"],
"SMTP_SENDER_NAME": mail["sender_name"], "SMTP_SENDER_NAME": mail["sender_name"],
}) })
# json.dumps gives a quoted YAML-safe string; the port is quoted as well (env values are strings). # 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()} # A dict is dumped as JSON too: JSON flow syntax is valid YAML, so the annotation map drops in.
quoted = {k: (json.dumps(v) if isinstance(v, dict) else json.dumps(str(v))) for k, v in subs.items()}
text = re.sub(r"\$\{(\w+)\}", lambda m: quoted[m.group(1)], template_text) text = re.sub(r"\$\{(\w+)\}", lambda m: quoted[m.group(1)], template_text)
values = yaml.safe_load(text) values = yaml.safe_load(text)
apply_memory(values) apply_memory(values)
@@ -436,15 +531,16 @@ def strip_kong_services(text, names):
return "".join(out) return "".join(out)
def post_render(text, n, lan_ip): def post_render(text, n, cfg):
"""Edit the rendered chart: fixed host ports on db and studio, no public Studio/pg-meta route.""" """Edit the rendered chart: fixed host ports on db and studio, no public Studio/pg-meta route."""
bind_ip = host_bind_ip(cfg)
docs = [d for d in yaml.safe_load_all(text) if d] docs = [d for d in yaml.safe_load_all(text) if d]
for d in docs: for d in docs:
kind, name = d.get("kind"), d.get("metadata", {}).get("name", "") kind, name = d.get("kind"), d.get("metadata", {}).get("name", "")
if kind == "StatefulSet" and name == f"{fullname(n)}-db": if kind == "StatefulSet" and name == f"{fullname(n)}-db":
_host_port(d, pg_port(n), lan_ip) _host_port(d, pg_port(n), bind_ip)
elif kind == "Deployment" and name == f"{fullname(n)}-studio": elif kind == "Deployment" and name == f"{fullname(n)}-studio":
_host_port(d, studio_port(n), lan_ip) _host_port(d, studio_port(n), bind_ip)
elif kind == "ConfigMap" and "kong.yml" in (d.get("data") or {}): elif kind == "ConfigMap" and "kong.yml" in (d.get("data") or {}):
d["data"]["kong.yml"] = strip_kong_services(d["data"]["kong.yml"], ("meta", "dashboard")) 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 d in docs: # keep multi-line scripts readable in the release manifest
@@ -454,10 +550,21 @@ def post_render(text, n, lan_ip):
return yaml.safe_dump_all(docs, sort_keys=False, default_flow_style=False) return yaml.safe_dump_all(docs, sort_keys=False, default_flow_style=False)
def host_bind_ip(cfg):
"""Address the Postgres/Studio host ports listen on. `lan` (the default) is the LAN IP
only; on a machine with a public interface that keeps Postgres and the login-less Studio
off the internet. `all` is every interface - LAN and localhost - for a box behind NAT
that runs its own apps talking to localhost. `local` is localhost only."""
return {"lan": cfg["lan_ip"], "local": "127.0.0.1", "all": None}[cfg.get("port_bind", "lan")]
def _host_port(doc, port, ip): def _host_port(doc, port, ip):
c = doc["spec"]["template"]["spec"]["containers"][0] c = doc["spec"]["template"]["spec"]["containers"][0]
p = c["ports"][0] p = c["ports"][0]
p["hostPort"] = port p["hostPort"] = port
if ip is not None:
# None = bind every interface (the `all` mode); omitting hostIP makes the kubelet
# listen on 0.0.0.0, which a hostIP cannot express.
p["hostIP"] = ip p["hostIP"] = ip
if doc.get("kind") == "Deployment": if doc.get("kind") == "Deployment":
# A port on the server itself can be held by one pod at a time. The default rollout starts # A port on the server itself can be held by one pod at a time. The default rollout starts
@@ -660,18 +767,21 @@ def ensure_cluster_objects(cfg, log):
{"apiVersion": "storage.k8s.io/v1", "kind": "StorageClass", {"apiVersion": "storage.k8s.io/v1", "kind": "StorageClass",
"metadata": {"name": STORAGE_CLASS}, "provisioner": "kubernetes.io/no-provisioner", "metadata": {"name": STORAGE_CLASS}, "provisioner": "kubernetes.io/no-provisioner",
"reclaimPolicy": "Retain", "volumeBindingMode": "WaitForFirstConsumer"}, "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", {"apiVersion": "scheduling.k8s.io/v1", "kind": "PriorityClass",
"metadata": {"name": PRIORITY_CLASS}, "value": -100, "metadata": {"name": PRIORITY_CLASS}, "value": -100,
"preemptionPolicy": "Never", "globalDefault": False, "preemptionPolicy": "Never", "globalDefault": False,
"description": "supaspin test instances: evicted first, never evict others"}, "description": "supaspin test instances: evicted first, never evict others"},
{"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": CERTS_NS}}, {"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": CERTS_NS}},
] ]
if cfg.get("lets_encrypt", True):
# cert-manager needs this object to know where to order certificates from; without
# Let's Encrypt it is dead weight, and cert-manager may not even be installed.
docs.insert(1, {"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"]}}}]}}})
for d in docs: for d in docs:
# Create only what is missing; never overwrite something already there. # Create only what is missing; never overwrite something already there.
r = kubectl("get", d["kind"].lower(), d["metadata"]["name"], check=False) r = kubectl("get", d["kind"].lower(), d["metadata"]["name"], check=False)
@@ -768,6 +878,55 @@ def wait_ready(n, log, timeout=1200):
raise RuntimeError("pods did not become ready in time") raise RuntimeError("pods did not become ready in time")
def setup_log_retention(cfg, n, log):
"""Give the instance a pg_cron job that deletes analytics logs older than LOG_RETENTION every
night. Logflare stores the logs it receives in the _supabase database and never deletes them;
without this job the capped db image fills up and the database dies (instance 3 on
2026-10-06). The job runs in the postgres database, the only one pg_cron accepts in this
image, and deletes in _supabase over a local dblink connection. Runs the first cleanup right
away, so an upgrade also frees a full disk. Runs after create, upgrade and resume;
scheduling under a fixed job name makes it idempotent."""
log(f" log retention: deleting analytics logs older than {LOG_RETENTION}, every night")
kubectl("-n", namespace(n), "exec", f"{fullname(n)}-db-0", "-c", "supabase-db", "--",
"psql", "-U", "supabase_admin", "-d", "postgres", "-v", "ON_ERROR_STOP=1",
"-c", RETENTION_SQL)
log(" log retention is set")
def middleware_manifest(n):
"""The Traefik request-size cap the ingress annotation of instance n points at. nginx gets
the same cap from proxy-body-size; Traefik has no such annotation, the rule lives here."""
return {"apiVersion": "traefik.io/v1alpha1", "kind": "Middleware",
"metadata": {"name": buffering_middleware(n), "namespace": namespace(n)},
"spec": {"buffering": {"maxRequestBodyBytes": 50 * 1024 * 1024}}} # 50 MiB
def ensure_middleware(cfg, n, log):
"""Create the Middleware for instance n on Traefik. On nginx the annotation is not
emitted, and applying a traefik.io object would fail there - the CRD is not installed."""
if cfg.get("ingress_controller", "nginx") != "traefik":
return
apply(middleware_manifest(n))
log(f" created Middleware {buffering_middleware(n)} (50 MiB request cap)")
def ensure_self_signed_cert(cfg, n, host, log):
"""A self-signed certificate for the hostname, so https answers without Let's Encrypt.
Skipped when a certificate is already in place (restored from supaspin-certs)."""
r = kubectl("get", "secret", tls_secret(n), "-n", namespace(n), check=False)
if r.returncode == 0:
return
with tempfile.TemporaryDirectory(prefix="supaspin-cert-") as d:
key, crt = f"{d}/tls.key", f"{d}/tls.crt"
run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-sha256", "-nodes",
"-keyout", key, "-out", crt, "-days", "3650",
"-subj", f"/CN={host}", "-addext", f"subjectAltName=DNS:{host}"])
kubectl("create", "secret", "tls", tls_secret(n), "-n", namespace(n),
"--cert", crt, "--key", key)
log(f" self-signed certificate for {host} (valid 10 years);"
" clients must trust it or ignore the warning")
def create_instance(cfg, mail, log, number=None): def create_instance(cfg, mail, log, number=None):
taken = existing_numbers(cfg) taken = existing_numbers(cfg)
n = number if number is not None else lowest_free_number(cfg["slots"], taken) n = number if number is not None else lowest_free_number(cfg["slots"], taken)
@@ -785,10 +944,13 @@ def create_instance(cfg, mail, log, number=None):
kubectl("create", "namespace", namespace(n)) kubectl("create", "namespace", namespace(n))
apply([pv_manifest(cfg, n, v) for v in VOLUMES]) apply([pv_manifest(cfg, n, v) for v in VOLUMES])
restore_cert(n, log) restore_cert(n, log)
if not cfg.get("lets_encrypt", True):
ensure_self_signed_cert(cfg, n, host, log)
sec = generate_secrets() sec = generate_secrets()
with open(find_template()) as f: with open(find_template()) as f:
values = instance_values(cfg, n, sec, mail, f.read()) values = instance_values(cfg, n, sec, mail, f.read())
vfile = write_values_file(n, values) vfile = write_values_file(n, values)
ensure_middleware(cfg, n, log)
log(" installing chart") log(" installing chart")
try: try:
helm("install", release(n), chart_path(cfg), "-n", namespace(n), "-f", vfile, helm("install", release(n), chart_path(cfg), "-n", namespace(n), "-f", vfile,
@@ -796,6 +958,7 @@ def create_instance(cfg, mail, log, number=None):
finally: finally:
os.remove(vfile) # holds every secret os.remove(vfile) # holds every secret
wait_ready(n, log) wait_ready(n, log)
setup_log_retention(cfg, n, log)
env_path = f"{inst_dir(cfg, n)}/.env" env_path = f"{inst_dir(cfg, n)}/.env"
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f: with os.fdopen(fd, "w") as f:
@@ -891,6 +1054,7 @@ def resume_instance(cfg, n, log):
apply_values(cfg, n, set_replicas(values, 1)) apply_values(cfg, n, set_replicas(values, 1))
log(" the database answers first; the services that need it retry until it does") log(" the database answers first; the services that need it retry until it does")
wait_ready(n, log) wait_ready(n, log)
setup_log_retention(cfg, n, log)
log(f" instance {n} is running again: https://{cfg['slots'].get(n, '?')}") log(f" instance {n} is running again: https://{cfg['slots'].get(n, '?')}")
return True return True
@@ -917,9 +1081,10 @@ def upgrade_instance(cfg, n, log):
if asleep: if asleep:
# A suspended instance stays suspended: the new caps are stored and take effect the moment # A suspended instance stays suspended: the new caps are stored and take effect the moment
# it is resumed. Waiting for its pods would wait for ever - by design it has none. # it is resumed. Waiting for its pods would wait for ever - by design it has none.
log(f" instance {n} is suspended; the new caps apply when you resume it") log(f" instance {n} is suspended; the new caps and log retention apply when you resume it")
return bool(changed) return bool(changed)
wait_ready(n, log) wait_ready(n, log)
setup_log_retention(cfg, n, log)
log(f" instance {n} is running with the current settings") log(f" instance {n} is running with the current settings")
return bool(changed) return bool(changed)
@@ -1702,11 +1867,35 @@ def setup_dialog(cfg, log):
cfg["storage_root"] = ask("Storage root (images live here)", cfg.get("storage_root", "/home/igor/k8s-storage/supabase")) 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["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["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", "")) bind_default = cfg.get("port_bind", "lan")
bind = ask("Port bind: lan (LAN only), all (LAN + localhost), local (localhost only)",
bind_default).lower()
while bind not in ("lan", "all", "local"):
print(f"{RED}Answer 'lan', 'all' or 'local'.{OFF}")
bind = ask("Port bind: lan (LAN only), all (LAN + localhost), local (localhost only)",
bind_default).lower()
cfg["port_bind"] = bind
cfg["ingress_class"] = ask("Ingress class", cfg.get("ingress_class", "public")) cfg["ingress_class"] = ask("Ingress class", cfg.get("ingress_class", "public"))
controller_default = cfg.get("ingress_controller", "nginx")
controller = ask("Ingress controller, traefik or nginx", controller_default).lower()
while controller not in ("traefik", "nginx"):
print(f"{RED}Answer 'traefik' or 'nginx'.{OFF}")
controller = ask("Ingress controller, traefik or nginx", controller_default).lower()
cfg["ingress_controller"] = controller
le_default = "y" if cfg.get("lets_encrypt", True) else "n"
le = ask("Fetch certificates from Let's Encrypt? Needs public DNS and ports 80/443 (Y/n)", le_default)
cfg["lets_encrypt"] = le.lower().startswith("y")
if cfg["lets_encrypt"]:
cfg["acme_email"] = ask("Let's Encrypt e-mail", cfg.get("acme_email", ""))
cur = ",".join(cfg["slots"][k] for k in sorted(cfg.get("slots", {}))) if cfg.get("slots") else "" 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()] while True:
cfg["slots"] = {i + 1: h for i, h in enumerate(hosts)} try:
cfg["slots"] = parse_slots(ask(
"Hostname slots, comma separated (a name with a number keys that slot: "
"supa4 = slot 4; a plain name takes its position)", cur))
break
except ValueError as e:
print(f"{RED}{e}{OFF}")
cfg["ssh_host"] = ask("Public SSH host for tunnels", cfg.get("ssh_host", "")) 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_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["ssh_user"] = ask("SSH user", cfg.get("ssh_user", d["ssh_user"]))
@@ -1815,7 +2004,7 @@ def main_menu(cfg, log):
def main(argv): def main(argv):
if len(argv) >= 2 and argv[1] == "post-render": # called by Helm; no root check, no output but YAML if len(argv) >= 2 and argv[1] == "post-render": # called by Helm; no root check, no output but YAML
cfg = load_config() cfg = load_config()
sys.stdout.write(post_render(sys.stdin.read(), int(argv[2]), cfg["lan_ip"])) sys.stdout.write(post_render(sys.stdin.read(), int(argv[2]), cfg))
return 0 return 0
if len(argv) > 1 and argv[1] in ("help", "-h", "--help"): if len(argv) > 1 and argv[1] in ("help", "-h", "--help"):
print(__doc__) print(__doc__)
+3 -7
View File
@@ -71,13 +71,9 @@ environment:
ingress: ingress:
enabled: true enabled: true
className: ${INGRESS_CLASS} className: ${INGRESS_CLASS}
annotations: # The annotation map is built in code (ingress_annotations): nginx gets proxy-* and a
# The chart default rewrites every path to "/", which breaks all APIs; null removes it. # nulled rewrite-target, Traefik gets its Middleware reference, cert-manager its issuer.
nginx.ingress.kubernetes.io/rewrite-target: null annotations: ${INGRESS_ANNOTATIONS}
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: tls:
- hosts: [${HOST}] - hosts: [${HOST}]
secretName: ${TLS_SECRET} secretName: ${TLS_SECRET}
+185 -10
View File
@@ -51,6 +51,25 @@ class Numbering(unittest.TestCase):
def test_no_free_slot(self): def test_no_free_slot(self):
self.assertIsNone(sp.lowest_free_number(CFG["slots"], {1, 2, 3})) self.assertIsNone(sp.lowest_free_number(CFG["slots"], {1, 2, 3}))
def test_slots_are_keyed_by_the_hostname_number(self):
# The digit in the name is the slot number, so instance N, its ports and the
# hostname stay in step on every machine.
self.assertEqual(sp.parse_slots("supa4.didgibot.com, supa5.didgibot.com"),
{4: "supa4.didgibot.com", 5: "supa5.didgibot.com"})
def test_plain_names_take_their_position(self):
self.assertEqual(sp.parse_slots("db.example.com, db2.example.com"),
{1: "db.example.com", 2: "db2.example.com"})
self.assertEqual(sp.parse_slots("supa4.didgibot.com, spare.example.com"),
{4: "supa4.didgibot.com", 2: "spare.example.com"})
def test_duplicates_are_an_error(self):
with self.assertRaises(ValueError):
sp.parse_slots("supa4.didgibot.com, supa4.other.com")
def test_empty_answer_gives_no_slots(self):
self.assertEqual(sp.parse_slots(""), {})
def test_ports_and_names(self): def test_ports_and_names(self):
self.assertEqual((sp.pg_port(1), sp.pg_port(3)), (5441, 5443)) 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.studio_port(1), sp.studio_port(3)), (3001, 3003))
@@ -90,14 +109,17 @@ class Values(unittest.TestCase):
def test_memory_caps_add_up(self): def test_memory_caps_add_up(self):
req, lim = sp.total_memory_mib() req, lim = sp.total_memory_mib()
# db 2048, analytics 1536, kong 768, realtime 768, vector 1024, the other seven 512 each # db 2048, analytics 1536, kong 768, realtime 768, vector 512, the other seven 512 each
self.assertEqual(lim, 2048 + 1536 + 768 + 768 + 1024 + 7 * 512) self.assertEqual(lim, 2048 + 1536 + 768 + 768 + 512 + 7 * 512)
self.assertEqual(lim / 1024, 9.5) self.assertEqual(lim / 1024, 9.0)
self.assertEqual(req, 512 + 11 * 128) # 1.875 GiB self.assertEqual(req, 512 + 11 * 128) # 1.875 GiB
# Measured on three running instances on 2026-09-22; the ceiling has to stay clear of the # Measured on three running instances on 2026-09-22; the ceiling has to stay clear of the
# highest use, or the kernel kills the container in a loop the way it did on instance 1. # highest use, or the kernel kills the container in a loop the way it did on instance 1.
self.assertGreaterEqual(sp.MEMORY["analytics"][1], 1536) # used 694 MiB, died at 768 self.assertGreaterEqual(sp.MEMORY["analytics"][1], 1536) # used 694 MiB, died at 768
self.assertGreaterEqual(sp.MEMORY["vector"][1], 1024) # used 290 MiB, died at 512 self.assertGreaterEqual(sp.MEMORY["vector"][1], 512) # used 290 MiB; the nightly log
# retention keeps its delivery backlog from growing past this the way it did on
# 2026-10-06, when instance 3's full db image killed analytics and vector filled with
# the undeliverable backlog every few minutes against 1024 MiB.
self.assertGreaterEqual(sp.MEMORY["realtime"][1], 768) # used 300 MiB against 384 self.assertGreaterEqual(sp.MEMORY["realtime"][1], 768) # used 300 MiB against 384
self.assertGreaterEqual(sp.MEMORY["kong"][1], 768) # used 269 MiB against 512 self.assertGreaterEqual(sp.MEMORY["kong"][1], 768) # used 269 MiB against 512
self.assertGreaterEqual(sp.DEFAULT_MEMORY[1], 512) # studio used 203 against 256 self.assertGreaterEqual(sp.DEFAULT_MEMORY[1], 512) # studio used 203 against 256
@@ -128,6 +150,27 @@ class Values(unittest.TestCase):
self.assertEqual(set(sp.VOLUMES), {"db", "storage", "functions", "snippets", "deno", "pgsodium"}) 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)) self.assertTrue(all(p[v]["storageClassName"] == "supaspin-local-sc" for v in sp.VOLUMES))
def test_log_retention_sql(self):
# Logflare stores logs in _analytics.log_events_* and never deletes them; the job deletes
# rows older than LOG_RETENTION and is scheduled under a fixed name so re-runs overwrite.
# pg_cron in the supabase image only runs jobs from the `postgres` database, so the job
# reaches the log tables in `_supabase` over a local dblink connection (trusted there).
# dblink and the helper function live in the `extensions` schema: in `public` they belong
# to someone else's routines and the app's `REVOKE ALL ON ALL ROUTINES IN SCHEMA public`
# fails as the non-superuser postgres role (42501, dblink_connect_u).
self.assertIn("schemaname = ''_analytics''", sp.RETENTION_SQL)
self.assertIn("''log_events\\_%''", sp.RETENTION_SQL)
self.assertIn(f"interval ''{sp.LOG_RETENTION}''", sp.RETENTION_SQL)
self.assertIn(f"'{sp.LOG_RETENTION_JOB}'", sp.RETENTION_SQL)
self.assertIn("cron.schedule", sp.RETENTION_SQL)
self.assertIn("extensions.dblink_connect", sp.RETENTION_SQL)
self.assertIn("user=supabase_admin", sp.RETENTION_SQL)
self.assertIn("create extension if not exists dblink with schema extensions", sp.RETENTION_SQL)
self.assertIn("alter extension dblink set schema extensions", sp.RETENTION_SQL)
self.assertIn("extensions.supaspin_delete_old_logs", sp.RETENTION_SQL)
self.assertIn("drop function if exists public.supaspin_delete_old_logs", sp.RETENTION_SQL)
self.assertNotIn("\ndblink_", sp.RETENTION_SQL) # no unqualified dblink call remains
def test_imgproxy_reads_the_storage_volume(self): def test_imgproxy_reads_the_storage_volume(self):
img = self.v["persistence"]["imgproxy"] img = self.v["persistence"]["imgproxy"]
self.assertEqual(img["existingClaim"], "supa2-supabase-storage") self.assertEqual(img["existingClaim"], "supa2-supabase-storage")
@@ -155,6 +198,118 @@ class Values(unittest.TestCase):
self.assertEqual(v["secret"]["smtp"]["password"], 'p"a$s: #x') self.assertEqual(v["secret"]["smtp"]["password"], 'p"a$s: #x')
class IngressShaping(unittest.TestCase):
"""The ingress annotation map per controller and per Let's Encrypt setting."""
def setUp(self):
self.sec = sp.generate_secrets()
def ann(self, cfg=None, n=2):
return sp.instance_values(dict(CFG, **(cfg or {})), n, self.sec, MAIL, template()) \
["ingress"]["annotations"]
def test_default_is_nginx_with_lets_encrypt(self):
# Configs written before these settings existed must render exactly as before.
a = self.ann()
self.assertEqual(a["cert-manager.io/cluster-issuer"], "supaspin-letsencrypt")
self.assertIsNone(a["nginx.ingress.kubernetes.io/rewrite-target"])
self.assertEqual(a["nginx.ingress.kubernetes.io/proxy-body-size"], "50m")
self.assertEqual(a["nginx.ingress.kubernetes.io/proxy-read-timeout"], "3600")
self.assertNotIn("traefik.ingress.kubernetes.io/router.middlewares", a)
def test_traefik_native(self):
a = self.ann({"ingress_controller": "traefik"})
self.assertEqual(a["traefik.ingress.kubernetes.io/router.middlewares"],
"supaspin-2-supa2-buffering@kubernetescrd")
for nginx_key in ("rewrite-target", "proxy-body-size", "proxy-read-timeout",
"proxy-send-timeout"):
self.assertNotIn(f"nginx.ingress.kubernetes.io/{nginx_key}", a)
self.assertEqual(a["cert-manager.io/cluster-issuer"], "supaspin-letsencrypt")
def test_lets_encrypt_off_drops_cert_manager_annotation(self):
self.assertNotIn("cert-manager.io/cluster-issuer", self.ann({"lets_encrypt": False}))
self.assertNotIn("cert-manager.io/cluster-issuer",
self.ann({"lets_encrypt": False, "ingress_controller": "traefik"}))
def test_middleware_manifest(self):
m = sp.middleware_manifest(2)
self.assertEqual(m["apiVersion"], "traefik.io/v1alpha1")
self.assertEqual(m["kind"], "Middleware")
self.assertEqual(m["metadata"], {"name": "supa2-buffering", "namespace": "supaspin-2"})
self.assertEqual(m["spec"]["buffering"]["maxRequestBodyBytes"], 50 * 1024 * 1024)
class ClusterObjects(unittest.TestCase):
"""ensure_cluster_objects skips the ClusterIssuer when Let's Encrypt is off."""
def created_docs(self, cfg):
applied = []
class R:
returncode = 1
stdout = stderr = ""
def fake_kubectl(*args, **kw):
if args and args[0] == "apply":
applied.extend(yaml.safe_load_all(kw["input_"]))
return R()
with tempfile.TemporaryDirectory() as tmp, \
mock.patch.object(sp, "STATE_DIR", tmp), \
mock.patch.object(sp, "kubectl", side_effect=fake_kubectl), \
mock.patch.object(sp, "helm"):
sp.ensure_cluster_objects(cfg, lambda *a: None)
return applied
def test_issuer_created_when_lets_encrypt_on(self):
self.assertIn("ClusterIssuer", [d["kind"] for d in self.created_docs(CFG)])
def test_no_issuer_without_lets_encrypt(self):
kinds = [d["kind"] for d in self.created_docs(dict(CFG, lets_encrypt=False))]
self.assertNotIn("ClusterIssuer", kinds)
self.assertIn("StorageClass", kinds)
self.assertIn("PriorityClass", kinds)
self.assertIn("Namespace", kinds)
class SelfSignedCert(unittest.TestCase):
def test_generated_when_missing(self):
calls = []
class R:
returncode = 1
stdout = stderr = ""
def fake_kubectl(*args, **kw):
calls.append(args)
return R()
def fake_run(args, **kw):
calls.append(("run", args))
class P:
returncode = 0
return P()
with mock.patch.object(sp, "kubectl", side_effect=fake_kubectl), \
mock.patch.object(sp, "run", side_effect=fake_run):
sp.ensure_self_signed_cert(CFG, 2, "supa2.example.com", lambda *a: None)
openssl = next(c[1] for c in calls if c[0] == "run" and c[1][0] == "openssl")
self.assertIn("subjectAltName=DNS:supa2.example.com", openssl)
create = next(c for c in calls if c[:3] == ("create", "secret", "tls"))
self.assertEqual(create[3], "supa2-tls")
self.assertIn("supaspin-2", create)
def test_kept_when_present(self):
class R:
returncode = 0
stdout = stderr = ""
with mock.patch.object(sp, "kubectl", return_value=R()) as k, \
mock.patch.object(sp, "run") as r:
sp.ensure_self_signed_cert(CFG, 2, "supa2.example.com", lambda *a: None)
k.assert_called_once()
r.assert_not_called()
class EnvFile(unittest.TestCase): class EnvFile(unittest.TestCase):
def setUp(self): def setUp(self):
self.sec = sp.generate_secrets() self.sec = sp.generate_secrets()
@@ -254,7 +409,7 @@ data:
class PostRender(unittest.TestCase): class PostRender(unittest.TestCase):
def setUp(self): def setUp(self):
self.docs = list(yaml.safe_load_all(sp.post_render(RENDERED, 1, "192.168.10.30"))) self.docs = list(yaml.safe_load_all(sp.post_render(RENDERED, 1, CFG)))
def find(self, kind, name): def find(self, kind, name):
return next(d for d in self.docs if d["kind"] == kind and d["metadata"]["name"] == name) return next(d for d in self.docs if d["kind"] == kind and d["metadata"]["name"] == name)
@@ -265,6 +420,23 @@ class PostRender(unittest.TestCase):
st = self.find("Deployment", "supa1-supabase-studio")["spec"]["template"]["spec"]["containers"][0]["ports"][0] 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")) self.assertEqual((st["hostPort"], st["hostIP"]), (3001, "192.168.10.30"))
def test_all_binds_localhost_too(self):
# `all` omits hostIP: the kubelet then listens on every interface, localhost included.
docs = list(yaml.safe_load_all(sp.post_render(RENDERED, 1, dict(CFG, port_bind="all"))))
find = lambda k, name: next(d for d in docs if d["kind"] == k and d["metadata"]["name"] == name)
db = find("StatefulSet", "supa1-supabase-db")["spec"]["template"]["spec"]["containers"][0]["ports"][0]
st = find("Deployment", "supa1-supabase-studio")["spec"]["template"]["spec"]["containers"][0]["ports"][0]
self.assertEqual(db["hostPort"], 5441)
self.assertNotIn("hostIP", db)
self.assertEqual(st["hostPort"], 3001)
self.assertNotIn("hostIP", st)
def test_local_binds_localhost_only(self):
docs = list(yaml.safe_load_all(sp.post_render(RENDERED, 1, dict(CFG, port_bind="local"))))
find = lambda k, name: next(d for d in docs if d["kind"] == k and d["metadata"]["name"] == name)
db = find("StatefulSet", "supa1-supabase-db")["spec"]["template"]["spec"]["containers"][0]["ports"][0]
self.assertEqual((db["hostPort"], db["hostIP"]), (5441, "127.0.0.1"))
def test_other_pods_untouched(self): def test_other_pods_untouched(self):
rest = self.find("Deployment", "supa1-supabase-rest")["spec"]["template"]["spec"]["containers"][0]["ports"][0] rest = self.find("Deployment", "supa1-supabase-rest")["spec"]["template"]["spec"]["containers"][0]["ports"][0]
self.assertNotIn("hostPort", rest) self.assertNotIn("hostPort", rest)
@@ -294,9 +466,9 @@ class PostRender(unittest.TestCase):
self.assertEqual(cm["data"]["kong-entrypoint.sh"], "echo hi\necho there\n") self.assertEqual(cm["data"]["kong-entrypoint.sh"], "echo hi\necho there\n")
def test_instance_number_selects_ports(self): 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"))) docs = list(yaml.safe_load_all(sp.post_render(RENDERED.replace("supa1", "supa3"), 3, CFG)))
db = next(d for d in docs if d["kind"] == "StatefulSet")["spec"]["template"]["spec"]["containers"][0]["ports"][0] 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")) self.assertEqual((db["hostPort"], db["hostIP"]), (5443, "192.168.10.30"))
class Manifests(unittest.TestCase): class Manifests(unittest.TestCase):
@@ -975,6 +1147,7 @@ class Upgrade(unittest.TestCase):
with mock.patch.object(sp, "helm", helm), \ with mock.patch.object(sp, "helm", helm), \
mock.patch.object(sp, "wait_ready", lambda n, log, **kw: None), \ mock.patch.object(sp, "wait_ready", lambda n, log, **kw: None), \
mock.patch.object(sp, "setup_log_retention", lambda cfg, n, log: None), \
mock.patch.object(sp, "STATE_DIR", tempfile.mkdtemp()): mock.patch.object(sp, "STATE_DIR", tempfile.mkdtemp()):
yield yield
@@ -1086,7 +1259,7 @@ class HostPortRollout(unittest.TestCase):
"template": {"spec": {"containers": [ "template": {"spec": {"containers": [
{"name": "rest", "ports": [{"containerPort": 3000}]}]}}}}, {"name": "rest", "ports": [{"containerPort": 3000}]}]}}}},
]) ])
out = list(yaml.safe_load_all(sp.post_render(text, n, CFG["lan_ip"]))) out = list(yaml.safe_load_all(sp.post_render(text, n, CFG)))
return {d["metadata"]["name"]: d for d in out} return {d["metadata"]["name"]: d for d in out}
def test_studio_stops_the_old_pod_before_starting_the_new_one(self): def test_studio_stops_the_old_pod_before_starting_the_new_one(self):
@@ -1147,6 +1320,8 @@ class SuspendResume(unittest.TestCase):
lambda n, log, **kw: self.waited.append(("ready", n))), \ lambda n, log, **kw: self.waited.append(("ready", n))), \
mock.patch.object(sp, "wait_gone", mock.patch.object(sp, "wait_gone",
lambda n, log, **kw: self.waited.append(("gone", n))), \ lambda n, log, **kw: self.waited.append(("gone", n))), \
mock.patch.object(sp, "setup_log_retention",
lambda cfg, n, log: self.waited.append(("retention", n))), \
mock.patch.object(sp, "STATE_DIR", tempfile.mkdtemp()): mock.patch.object(sp, "STATE_DIR", tempfile.mkdtemp()):
yield yield
@@ -1210,7 +1385,7 @@ class SuspendResume(unittest.TestCase):
for svc in sp.SERVICES: for svc in sp.SERVICES:
self.assertEqual(self.written["deployment"][svc]["replicaCount"], 1) self.assertEqual(self.written["deployment"][svc]["replicaCount"], 1)
self.assertFalse(sp.is_suspended(self.written)) self.assertFalse(sp.is_suspended(self.written))
self.assertEqual(self.waited, [("ready", 3)]) self.assertEqual(self.waited, [("ready", 3), ("retention", 3)])
def test_resuming_a_running_instance_changes_nothing(self): def test_resuming_a_running_instance_changes_nothing(self):
with self.fake_helm(self.RUNNING): with self.fake_helm(self.RUNNING):
@@ -1237,7 +1412,7 @@ class SuspendResume(unittest.TestCase):
def test_upgrade_still_waits_for_a_running_instance(self): def test_upgrade_still_waits_for_a_running_instance(self):
with self.fake_helm(self.RUNNING): with self.fake_helm(self.RUNNING):
sp.upgrade_instance(CFG, 1, self.log.append) sp.upgrade_instance(CFG, 1, self.log.append)
self.assertEqual(self.waited, [("ready", 1)]) self.assertEqual(self.waited, [("ready", 1), ("retention", 1)])
class InstanceState(unittest.TestCase): class InstanceState(unittest.TestCase):