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:
+161
-5
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user