387 lines
17 KiB
Python
387 lines
17 KiB
Python
import copy
|
|
import unittest
|
|
|
|
import controller as c
|
|
|
|
LLAMA = {
|
|
"metadata": {
|
|
"name": "llama-server", "namespace": "ai", "uid": "u-1",
|
|
"labels": {"dns.renner.uno/public": "true", "kustomize.toolkit.fluxcd.io/name": "llama-server"},
|
|
"annotations": {
|
|
"cert-manager.io/cluster-issuer": "letsencrypt-cluster",
|
|
"auth.renner.uno/login-flow": "sso-authentication",
|
|
"auth.renner.uno/skip-path": r"^/(manifest\.webmanifest|sw\.js)$",
|
|
"traefik.ingress.kubernetes.io/router.middlewares":
|
|
"authentik-gate@kubernetescrd, ingress-foo@kubernetescrd",
|
|
},
|
|
},
|
|
"spec": {
|
|
"ingressClassName": "traefik",
|
|
"tls": [{"hosts": ["llm.renner.uno"], "secretName": "llama-server-tls"}],
|
|
"rules": [{"host": "llm.renner.uno", "http": {"paths": [
|
|
{"path": "/", "pathType": "Prefix",
|
|
"backend": {"service": {"name": "llama-server", "port": {"number": 8080}}}}]}}],
|
|
},
|
|
}
|
|
|
|
SERVICE = {"spec": {"selector": {"app.kubernetes.io/name": "llama-server"},
|
|
"ports": [{"name": "http", "port": 8080, "targetPort": "http"}]}}
|
|
|
|
|
|
def ing(ns, name):
|
|
return {"metadata": {"namespace": ns, "name": name}}
|
|
|
|
|
|
class Names(unittest.TestCase):
|
|
def test_unambiguous(self):
|
|
self.assertNotEqual(c.gate_name(ing("a-b", "c")), c.gate_name(ing("a", "b-c")))
|
|
|
|
def test_stable_and_readable(self):
|
|
self.assertEqual(c.gate_name(ing("ai", "llama-server")), c.gate_name(ing("ai", "llama-server")))
|
|
self.assertTrue(c.gate_name(ing("ai", "llama-server")).startswith("ai-llama-server-"))
|
|
|
|
def test_bounded(self):
|
|
n = c.gate_name(ing("n" * 63, "i" * 253))
|
|
self.assertLessEqual(len(n), 63)
|
|
self.assertNotIn("--", n)
|
|
|
|
def test_group_expression_is_a_literal(self):
|
|
expr = c.group_expression("x'); import os; os.system('id'); ('")
|
|
self.assertEqual(expr, "return ak_is_group_member(request.user, name="
|
|
+ repr("x'); import os; os.system('id'); ('") + ")")
|
|
compile(expr.replace("return ", "_ = "), "<expr>", "exec")
|
|
|
|
|
|
class GateSpec(unittest.TestCase):
|
|
def test_llama(self):
|
|
g = c.gate_spec(LLAMA)
|
|
self.assertEqual(g["name"], c.gate_name(LLAMA))
|
|
self.assertEqual((g["host"], g["scheme"], g["service"], g["port"]),
|
|
("llm.renner.uno", "https", "llama-server", 8080))
|
|
self.assertIsNone(g["group"])
|
|
self.assertEqual(g["login_flow"], "sso-authentication")
|
|
self.assertEqual(g["authorization_flow"], c.DEFAULT_AUTHZ_FLOW)
|
|
|
|
def test_group_and_flow_override(self):
|
|
ing = copy.deepcopy(LLAMA)
|
|
ing["metadata"]["annotations"]["auth.renner.uno/group"] = "Home Assistant Config Editors"
|
|
ing["metadata"]["annotations"]["auth.renner.uno/authorization-flow"] = "implicit"
|
|
g = c.gate_spec(ing)
|
|
self.assertEqual(g["group"], "Home Assistant Config Editors")
|
|
self.assertEqual(g["authorization_flow"], "implicit")
|
|
|
|
def test_rejects_two_hosts(self):
|
|
ing = copy.deepcopy(LLAMA)
|
|
ing["spec"]["rules"].append(copy.deepcopy(ing["spec"]["rules"][0]))
|
|
ing["spec"]["rules"][1]["host"] = "other.renner.uno"
|
|
with self.assertRaises(c.GateError):
|
|
c.gate_spec(ing)
|
|
|
|
def test_rejects_two_backends(self):
|
|
ing = copy.deepcopy(LLAMA)
|
|
paths = ing["spec"]["rules"][0]["http"]["paths"]
|
|
paths.append({"path": "/api", "pathType": "Prefix",
|
|
"backend": {"service": {"name": "other", "port": {"number": 80}}}})
|
|
with self.assertRaises(c.GateError):
|
|
c.gate_spec(ing)
|
|
|
|
def test_rejects_wildcard(self):
|
|
ing = copy.deepcopy(LLAMA)
|
|
ing["spec"]["rules"][0]["host"] = "*.renner.uno"
|
|
with self.assertRaises(c.GateError):
|
|
c.gate_spec(ing)
|
|
|
|
def test_shared_hosts(self):
|
|
other = copy.deepcopy(LLAMA)
|
|
other["metadata"]["name"] = "llama-admin"
|
|
self.assertEqual(c.shared_hosts([LLAMA, other]), {"llm.renner.uno"})
|
|
other["spec"]["rules"][0]["host"] = "admin.renner.uno"
|
|
self.assertEqual(c.shared_hosts([LLAMA, other]), set())
|
|
|
|
def test_gated_by_middleware_reference(self):
|
|
self.assertTrue(c.is_gated(LLAMA))
|
|
ing = copy.deepcopy(LLAMA)
|
|
ing["metadata"]["annotations"]["traefik.ingress.kubernetes.io/router.middlewares"] = \
|
|
"ingress-foo@kubernetescrd,authentik-gate-other@kubernetescrd"
|
|
self.assertFalse(c.is_gated(ing))
|
|
del ing["metadata"]["annotations"]
|
|
self.assertFalse(c.is_gated(ing))
|
|
|
|
|
|
class Backend(unittest.TestCase):
|
|
def test_named_target_port_stays_named(self):
|
|
g = c.gate_spec(LLAMA)
|
|
self.assertEqual(c.resolve_backend(g, SERVICE),
|
|
(8080, {"app.kubernetes.io/name": "llama-server"}, "http"))
|
|
cnp = c.backend_ingress_cnp(g, {"app.kubernetes.io/name": "llama-server"}, "http")
|
|
self.assertEqual(cnp["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"], "http")
|
|
|
|
def test_numeric_target_port(self):
|
|
g = c.gate_spec(LLAMA)
|
|
svc = copy.deepcopy(SERVICE)
|
|
svc["spec"]["ports"][0]["targetPort"] = 9090
|
|
self.assertEqual(c.resolve_backend(g, svc)[2], 9090)
|
|
|
|
def test_missing_target_port_defaults_to_port(self):
|
|
g = c.gate_spec(LLAMA)
|
|
svc = copy.deepcopy(SERVICE)
|
|
del svc["spec"]["ports"][0]["targetPort"]
|
|
self.assertEqual(c.resolve_backend(g, svc)[2], 8080)
|
|
|
|
def test_service_without_selector_fails(self):
|
|
with self.assertRaises(c.GateError):
|
|
c.resolve_backend(c.gate_spec(LLAMA), {"spec": {"ports": SERVICE["spec"]["ports"]}})
|
|
|
|
def test_missing_port_fails(self):
|
|
g = c.gate_spec(LLAMA)
|
|
g["port"] = 1234
|
|
with self.assertRaises(c.GateError):
|
|
c.resolve_backend(g, SERVICE)
|
|
|
|
|
|
class Objects(unittest.TestCase):
|
|
def setUp(self):
|
|
self.g = c.gate_spec(LLAMA)
|
|
|
|
def test_backend_admits_traefik_only(self):
|
|
pods = {"app.kubernetes.io/name": "llama-server"}
|
|
i = c.backend_ingress_cnp(self.g, pods, 8080)
|
|
self.assertEqual(i["metadata"]["namespace"], "ai")
|
|
self.assertEqual(i["spec"]["ingress"][0]["fromEndpoints"],
|
|
[{"matchLabels": {"k8s:io.kubernetes.pod.namespace": "ingress",
|
|
"app.kubernetes.io/name": "traefik"}}])
|
|
self.assertEqual(i["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"], "8080")
|
|
|
|
def test_provider_body(self):
|
|
flows = {c.DEFAULT_AUTHZ_FLOW: "az", "sso-authentication": "au",
|
|
c.INVALIDATION_FLOW: "inv"}
|
|
p = c.provider_body(self.g, flows)
|
|
self.assertEqual(p["mode"], "forward_single")
|
|
self.assertEqual(p["external_host"], "https://llm.renner.uno")
|
|
self.assertEqual(p["internal_host"], "")
|
|
self.assertEqual((p["authorization_flow"], p["authentication_flow"], p["invalidation_flow"]),
|
|
("az", "au", "inv"))
|
|
self.assertEqual(p["skip_path_regex"], r"^/(manifest\.webmanifest|sw\.js)$")
|
|
|
|
def test_drift(self):
|
|
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
|
|
|
|
def test_embedded_config_keeps_other_keys(self):
|
|
cfg = c.embedded_config({"log_level": "debug", "authentik_host": c.AK_URL})
|
|
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_BROWSER_URL,
|
|
"authentik_host_insecure": False, "authentik_host_browser": c.AK_BROWSER_URL})
|
|
|
|
|
|
class FakeAuthentik:
|
|
"""In-memory stand-in for the endpoints the controller uses; records calls."""
|
|
|
|
def __init__(self):
|
|
self.db = {"providers/proxy/": {}, "core/applications/": {}, "outposts/instances/": {},
|
|
"policies/bindings/": {}, "policies/expression/": {}, "core/groups/": {}}
|
|
self.calls = []
|
|
self.next = 1
|
|
|
|
def add(self, path, obj):
|
|
obj.setdefault("pk", self.next)
|
|
self.next += 1
|
|
self.db[path][obj["pk"]] = obj
|
|
return obj
|
|
|
|
def _split(self, path):
|
|
for coll in self.db:
|
|
if path.startswith(coll):
|
|
rest = path[len(coll):].strip("/")
|
|
return coll, rest or None
|
|
raise AssertionError(path)
|
|
|
|
def _find(self, coll, key):
|
|
if coll == "core/applications/":
|
|
return next((a for a in self.db[coll].values() if a["slug"] == key), None)
|
|
return self.db[coll].get(int(key) if str(key).isdigit() else key)
|
|
|
|
def list(self, path, **q):
|
|
out = []
|
|
for o in self.db[path].values():
|
|
ok = True
|
|
for k, v in q.items():
|
|
field = k.replace("__iexact", "")
|
|
ok &= str(o.get(field)).lower() == str(v).lower() if "__iexact" in k else o.get(field) == v
|
|
if ok:
|
|
out.append(copy.deepcopy(o))
|
|
return out
|
|
|
|
def one(self, path, **q):
|
|
found = self.list(path, **q)
|
|
return found[0] if found else None
|
|
|
|
def get(self, path):
|
|
coll, key = self._split(path)
|
|
o = self._find(coll, key)
|
|
return copy.deepcopy(o) if o else None
|
|
|
|
def req(self, method, path, body=None):
|
|
self.calls.append((method, path))
|
|
coll, key = self._split(path)
|
|
if method == "POST":
|
|
obj = self.add(coll, dict(body))
|
|
if coll == "core/applications/":
|
|
obj["pbm_uuid"] = f"pbm-{obj['pk']}"
|
|
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = obj["slug"]
|
|
return copy.deepcopy(obj)
|
|
obj = self._find(coll, key)
|
|
if method == "PATCH":
|
|
# ProxyProviderSerializer.validate sees only the request body.
|
|
if coll == "providers/proxy/" and body.get("mode", "proxy") == "proxy" \
|
|
and body.get("internal_host", "") == "":
|
|
raise AssertionError("400 internal_host")
|
|
obj.update(body)
|
|
return copy.deepcopy(obj)
|
|
if method == "DELETE":
|
|
del self.db[coll][obj["pk"]]
|
|
if coll == "core/applications/":
|
|
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = None
|
|
for b in [b for b in self.db["policies/bindings/"].values() if b["target"] == obj["pbm_uuid"]]:
|
|
del self.db["policies/bindings/"][b["pk"]]
|
|
return None
|
|
raise AssertionError(method)
|
|
|
|
|
|
FLOWS = {c.DEFAULT_AUTHZ_FLOW: "az", "sso-authentication": "au", c.INVALIDATION_FLOW: "inv"}
|
|
|
|
|
|
class Reconcile(unittest.TestCase):
|
|
def setUp(self):
|
|
self.ak = FakeAuthentik()
|
|
self.g = c.gate_spec(LLAMA)
|
|
self.embedded = self.ak.add("outposts/instances/", {
|
|
"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST,
|
|
"providers": [], "config": {"log_level": "info"}})
|
|
|
|
def gate(self, group=None):
|
|
g = dict(self.g, group=group)
|
|
c.ensure_gate(self.ak, g, FLOWS)
|
|
return g
|
|
|
|
def embedded_providers(self):
|
|
return self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["providers"]
|
|
|
|
def test_creates_owned_gate(self):
|
|
self.gate()
|
|
app = self.ak.get(f"core/applications/{self.g['name']}/")
|
|
self.assertEqual(app["meta_publisher"], c.MANAGER)
|
|
self.assertEqual(self.embedded_providers(), [app["provider"]])
|
|
self.assertEqual(self.ak.list("outposts/instances/", name__iexact=self.g["name"]), [])
|
|
self.assertEqual(self.ak.list("policies/bindings/"), [])
|
|
|
|
def test_keeps_other_providers_on_embedded_outpost(self):
|
|
self.ak.db["outposts/instances/"][self.embedded["pk"]]["providers"] = [999]
|
|
self.gate()
|
|
app = self.ak.get(f"core/applications/{self.g['name']}/")
|
|
self.assertEqual(self.embedded_providers(), [999, app["provider"]])
|
|
|
|
def test_removes_dedicated_outpost(self):
|
|
self.gate()
|
|
app = self.ak.get(f"core/applications/{self.g['name']}/")
|
|
self.ak.add("outposts/instances/", {"name": self.g["name"], "providers": [app["provider"]]})
|
|
shared = self.ak.add("outposts/instances/", {"name": self.g["name"].upper(),
|
|
"providers": [app["provider"], 999]})
|
|
self.gate()
|
|
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/", name__iexact=self.g["name"])],
|
|
[shared["pk"]])
|
|
|
|
def test_embedded_lookup_ignores_unfiltered_results(self):
|
|
ldap = self.ak.add("outposts/instances/", {"name": "LDAP Outpost", "managed": None, "providers": [7]})
|
|
unfiltered = self.ak.list
|
|
self.ak.list = lambda path, **q: unfiltered(path) # a server that ignores the filter
|
|
self.assertEqual(c.embedded_outpost(self.ak)["pk"], self.embedded["pk"])
|
|
del self.ak.db["outposts/instances/"][self.embedded["pk"]]
|
|
with self.assertRaises(c.ApiError):
|
|
c.embedded_outpost(self.ak)
|
|
self.assertEqual(self.ak.get(f"outposts/instances/{ldap['pk']}/")["providers"], [7])
|
|
|
|
def test_embedded_config(self):
|
|
c.ensure_embedded_config(self.ak)
|
|
cfg = self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["config"]
|
|
self.assertEqual((cfg["log_level"], cfg["authentik_host"], cfg["authentik_host_browser"]),
|
|
("info", c.AK_BROWSER_URL, c.AK_BROWSER_URL))
|
|
before = len(self.ak.calls)
|
|
c.ensure_embedded_config(self.ak)
|
|
self.assertEqual(self.ak.calls[before:], [])
|
|
|
|
def test_provider_moves_from_proxy_mode(self):
|
|
self.ak.add("providers/proxy/", {"name": self.g["name"], "mode": "proxy",
|
|
"internal_host": "http://llama-server.ai.svc.cluster.local:8080",
|
|
"assigned_application_slug": None})
|
|
self.gate()
|
|
provider = self.ak.one("providers/proxy/", name__iexact=self.g["name"])
|
|
self.assertEqual((provider["mode"], provider["internal_host"]), ("forward_single", ""))
|
|
|
|
def test_second_pass_is_a_noop(self):
|
|
self.gate()
|
|
before = len(self.ak.calls)
|
|
self.gate()
|
|
self.assertEqual(self.ak.calls[before:], [])
|
|
|
|
def test_flow_change_updates_provider(self):
|
|
self.gate()
|
|
self.g = dict(self.g, login_flow=None)
|
|
self.gate()
|
|
provider = self.ak.one("providers/proxy/", name__iexact=self.g["name"])
|
|
self.assertIsNone(provider["authentication_flow"])
|
|
|
|
def test_ignores_foreign_provider_names(self):
|
|
self.ak.add("providers/proxy/", {"name": "aaa-first-alphabetically", "assigned_application_slug": "x"})
|
|
self.gate()
|
|
self.assertEqual(len(self.ak.list("providers/proxy/")), 2)
|
|
|
|
def test_refuses_unowned_application(self):
|
|
self.ak.add("core/applications/", {"slug": self.g["name"], "meta_publisher": "", "provider": None})
|
|
with self.assertRaises(c.GateError):
|
|
self.gate()
|
|
|
|
def test_missing_group_is_a_gate_error(self):
|
|
with self.assertRaises(c.GateError):
|
|
self.gate(group="Nope")
|
|
|
|
def test_group_change_binds_before_unbinding(self):
|
|
self.ak.add("core/groups/", {"name": "A"})
|
|
self.ak.add("core/groups/", {"name": "B"})
|
|
self.gate(group="A")
|
|
bindings = self.ak.list("policies/bindings/")
|
|
self.assertEqual(len(bindings), 1)
|
|
policy = self.ak.one("policies/expression/", name=self.g["name"])
|
|
self.assertIn("'A'", policy["expression"])
|
|
self.gate(group="B")
|
|
policy = self.ak.one("policies/expression/", name=self.g["name"])
|
|
self.assertIn("'B'", policy["expression"])
|
|
self.assertEqual([b["pk"] for b in self.ak.list("policies/bindings/")], [bindings[0]["pk"]])
|
|
|
|
def test_dropping_group_removes_binding_after_nothing_new(self):
|
|
self.ak.add("core/groups/", {"name": "A"})
|
|
self.gate(group="A")
|
|
self.gate(group=None)
|
|
self.assertEqual(self.ak.list("policies/bindings/"), [])
|
|
self.assertIsNone(self.ak.one("policies/expression/", name=self.g["name"]))
|
|
|
|
def test_remove_gate_and_ownership_scan(self):
|
|
foreign = self.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
|
|
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": foreign["pk"]})
|
|
self.gate()
|
|
owned = list(c.owned_applications(self.ak))
|
|
self.assertEqual([a["slug"] for a in owned], [self.g["name"]])
|
|
c.remove_gate(self.ak, owned[0])
|
|
self.assertIsNone(self.ak.get(f"core/applications/{self.g['name']}/"))
|
|
self.assertEqual([p["name"] for p in self.ak.list("providers/proxy/")], ["foreign"])
|
|
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/")], [self.embedded["pk"]])
|
|
|
|
def test_adopts_provider_orphaned_by_interrupted_teardown(self):
|
|
self.gate()
|
|
app = self.ak.get(f"core/applications/{self.g['name']}/")
|
|
self.ak.req("DELETE", f"core/applications/{self.g['name']}/")
|
|
self.gate()
|
|
self.assertEqual(self.ak.get(f"core/applications/{self.g['name']}/")["provider"], app["provider"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|