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)
This commit is contained in:
Igor K.
2026-10-08 14:40:24 -04:00
parent 2f47ddedf1
commit d4fedd718f
5 changed files with 340 additions and 51 deletions
+161 -5
View File
@@ -51,6 +51,25 @@ class Numbering(unittest.TestCase):
def test_no_free_slot(self):
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):
self.assertEqual((sp.pg_port(1), sp.pg_port(3)), (5441, 5443))
self.assertEqual((sp.studio_port(1), sp.studio_port(3)), (3001, 3003))
@@ -136,13 +155,21 @@ class Values(unittest.TestCase):
# 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("dblink_connect", 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):
img = self.v["persistence"]["imgproxy"]
@@ -171,6 +198,118 @@ class Values(unittest.TestCase):
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):
def setUp(self):
self.sec = sp.generate_secrets()
@@ -270,7 +409,7 @@ data:
class PostRender(unittest.TestCase):
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):
return next(d for d in self.docs if d["kind"] == kind and d["metadata"]["name"] == name)
@@ -281,6 +420,23 @@ class PostRender(unittest.TestCase):
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_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):
rest = self.find("Deployment", "supa1-supabase-rest")["spec"]["template"]["spec"]["containers"][0]["ports"][0]
self.assertNotIn("hostPort", rest)
@@ -310,9 +466,9 @@ class PostRender(unittest.TestCase):
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")))
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]
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):
@@ -1103,7 +1259,7 @@ class HostPortRollout(unittest.TestCase):
"template": {"spec": {"containers": [
{"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}
def test_studio_stops_the_old_pod_before_starting_the_new_one(self):