From 14beb85b65c469e4775d0196cf2db122ae681e4a Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Wed, 26 Aug 2026 00:54:12 -0500 Subject: [PATCH] fix(render): emit reciprocal ingress-allow for declared dependencies (R-NET-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `dependencies` edge is declared ONCE on the SOURCE, but with the default-deny- BOTH-directions baseline (docs/03 R-NET-01/02) it needs TWO matching halves: the source egress-allow AND the destination ingress-allow. render.py emitted only the egress half, so every declared east-west dependency was silently dropped by the destination's default-deny ingress — verified in-cluster: sonarr->qbittorrent:8080, sonarr->jackett:9117 and seerr->sonarr:8989 were all BLOCKED. Add a cross-service reverse-dependency pass (build_reverse_map) and render the reciprocal ingress half (r_reverse_dependency_policies): for each source that declares a dependency on X, X gains an additive ingress-allow CiliumNetworkPolicy `X-allow--` whose fromEndpoints matches the source identity, mirroring the egress half's L3/L4 granularity. Deterministic (sorted), additive (default-deny ingress stays []), egress side unchanged. A lone render with no cross-catalog context renders no reciprocal ingress, so the L1 render goldens are byte-identical. New ingress CNPs: qbittorrent<-{sonarr,radarr}:8080, jackett<-{sonarr,radarr}:9117, sonarr<-seerr:8989, radarr<-seerr:7878, plex<-seerr:32400. Selftest + L0 (17/17) + L1 (3/3) + lint all pass. Co-Authored-By: Claude Opus 4.8 --- apps/jackett.yaml | 42 ++++++++++ apps/plex.yaml | 21 +++++ apps/qbittorrent.yaml | 42 ++++++++++ apps/radarr.yaml | 21 +++++ apps/sonarr.yaml | 21 +++++ catalog/render.py | 184 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 328 insertions(+), 3 deletions(-) diff --git a/apps/jackett.yaml b/apps/jackett.yaml index df03587..eaa0863 100644 --- a/apps/jackett.yaml +++ b/apps/jackett.yaml @@ -105,6 +105,48 @@ spec: port: 9117 targetPort: 9117 --- +# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): jackett-allow-radarr-9117 +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: jackett-allow-radarr-9117 + namespace: jackett + labels: + app.kubernetes.io/name: jackett +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: jackett + ingress: + - fromEndpoints: + - matchLabels: + app.kubernetes.io/name: radarr + toPorts: + - ports: + - port: '9117' + protocol: TCP +--- +# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): jackett-allow-sonarr-9117 +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: jackett-allow-sonarr-9117 + namespace: jackett + labels: + app.kubernetes.io/name: jackett +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: jackett + ingress: + - fromEndpoints: + - matchLabels: + app.kubernetes.io/name: sonarr + toPorts: + - ports: + - port: '9117' + protocol: TCP +--- # CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): jackett-allow-gateway-ingress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy diff --git a/apps/plex.yaml b/apps/plex.yaml index e1d4517..120d128 100644 --- a/apps/plex.yaml +++ b/apps/plex.yaml @@ -102,6 +102,27 @@ spec: port: 32400 targetPort: 32400 --- +# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-seerr-32400 +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: plex-allow-seerr-32400 + namespace: plex + labels: + app.kubernetes.io/name: plex +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: plex + ingress: + - fromEndpoints: + - matchLabels: + app.kubernetes.io/name: seerr + toPorts: + - ports: + - port: '32400' + protocol: TCP +--- # CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): plex-allow-gateway-ingress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy diff --git a/apps/qbittorrent.yaml b/apps/qbittorrent.yaml index f7569e0..40489ee 100644 --- a/apps/qbittorrent.yaml +++ b/apps/qbittorrent.yaml @@ -105,6 +105,48 @@ spec: port: 8080 targetPort: 8080 --- +# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): qbittorrent-allow-radarr-8080 +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: qbittorrent-allow-radarr-8080 + namespace: qbittorrent + labels: + app.kubernetes.io/name: qbittorrent +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: qbittorrent + ingress: + - fromEndpoints: + - matchLabels: + app.kubernetes.io/name: radarr + toPorts: + - ports: + - port: '8080' + protocol: TCP +--- +# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): qbittorrent-allow-sonarr-8080 +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: qbittorrent-allow-sonarr-8080 + namespace: qbittorrent + labels: + app.kubernetes.io/name: qbittorrent +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: qbittorrent + ingress: + - fromEndpoints: + - matchLabels: + app.kubernetes.io/name: sonarr + toPorts: + - ports: + - port: '8080' + protocol: TCP +--- # CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): qbittorrent-allow-gateway-ingress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy diff --git a/apps/radarr.yaml b/apps/radarr.yaml index 6525a54..54bf1f3 100644 --- a/apps/radarr.yaml +++ b/apps/radarr.yaml @@ -147,6 +147,27 @@ spec: - port: '9117' protocol: TCP --- +# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): radarr-allow-seerr-7878 +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: radarr-allow-seerr-7878 + namespace: radarr + labels: + app.kubernetes.io/name: radarr +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: radarr + ingress: + - fromEndpoints: + - matchLabels: + app.kubernetes.io/name: seerr + toPorts: + - ports: + - port: '7878' + protocol: TCP +--- # CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): radarr-allow-gateway-ingress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy diff --git a/apps/sonarr.yaml b/apps/sonarr.yaml index f39e26e..572b2e0 100644 --- a/apps/sonarr.yaml +++ b/apps/sonarr.yaml @@ -147,6 +147,27 @@ spec: - port: '9117' protocol: TCP --- +# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): sonarr-allow-seerr-8989 +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: sonarr-allow-seerr-8989 + namespace: sonarr + labels: + app.kubernetes.io/name: sonarr +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: sonarr + ingress: + - fromEndpoints: + - matchLabels: + app.kubernetes.io/name: seerr + toPorts: + - ports: + - port: '8989' + protocol: TCP +--- # CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): sonarr-allow-gateway-ingress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy diff --git a/catalog/render.py b/catalog/render.py index 13e1fd0..e84f0f2 100644 --- a/catalog/render.py +++ b/catalog/render.py @@ -326,6 +326,91 @@ def r_dependency_policies(s: dict) -> list: return out +def build_reverse_map(sds: list) -> dict: + """Build the cross-service reverse-dependency map (T-2.3 reciprocal ingress). + + A `dependencies` edge is declared ONCE, in the SOURCE's catalog entry, as + `{to: X, port: P}`. With the default-deny-BOTH-directions baseline (R-NET-01), that one + declaration must compile into TWO matching halves (docs/03 "Explicit allow rules"; the + conformance fixtures spell it out: "egress permit on the source, ingress permit on the + destination"): (a) source egress-allow to X:P — rendered by r_dependency_policies on the + source — AND (b) X ingress-allow from source:P — the reciprocal half rendered here. + + render.py otherwise processes each ServiceDefinition independently, so the destination X + never sees the SOURCE's declaration. This pass scans ALL ServiceDefinitions once and + returns `{destination_name: [{source, port, protocol}, ...]}` so that, when rendering X, + r_reverse_dependency_policies can emit the ingress half for every source that declares a + dependency ON X. Deterministic: each destination's inbound list is sorted by + (source, port, protocol) so the render stays byte-reproducible (golden-diffable). + """ + rev: dict = {} + for sd in sds: + if not isinstance(sd, dict) or "name" not in sd: + continue # tolerate non-ServiceDefinition siblings (e.g. a stray yaml) + s = apply_defaults(sd) + src = s["name"] + for dep in s["dependencies"]: + rev.setdefault(dep["to"], []).append( + { + "source": src, + "port": dep["port"], + "protocol": dep.get("protocol", "tcp"), + } + ) + for dest in rev: + rev[dest].sort(key=lambda e: (e["source"], int(e["port"]), e["protocol"])) + return rev + + +def r_reverse_dependency_policies(s: dict, inbound: list) -> list: + """Reciprocal ingress-allow CiliumNetworkPolicies on THIS service (R-NET-02). + + `inbound` is this service's slice of the cross-service reverse map (build_reverse_map): + the list of sources that declared a dependency ON this service. Each becomes an ADDITIVE + ingress allow on top of default-deny, mirroring the source-side egress allow + (r_dependency_policies) exactly but inverted: `fromEndpoints` matching the SOURCE's + identity (its app.kubernetes.io/name — globally unique here, one app per namespace) on the + declared port/protocol. Same L3/L4 granularity and cross-namespace selector semantics as + the egress half, so the two halves compose into a working east-west path. Without this, + the destination's default-deny-ingress drops the source's traffic and the declared + dependency does not actually work (docs/03 R-NET-01/02). + """ + out = [] + for edge in inbound or []: + proto = edge.get("protocol", "tcp").upper() + out.append( + { + "apiVersion": "cilium.io/v2", + "kind": "CiliumNetworkPolicy", + "metadata": { + "name": f"{s['name']}-allow-{edge['source']}-{edge['port']}", + "namespace": s["name"], + "labels": {"app.kubernetes.io/name": s["name"]}, + }, + "spec": { + "endpointSelector": { + "matchLabels": {"app.kubernetes.io/name": s["name"]} + }, + "ingress": [ + { + "fromEndpoints": [ + {"matchLabels": {"app.kubernetes.io/name": edge["source"]}} + ], + "toPorts": [ + { + "ports": [ + {"port": str(edge["port"]), "protocol": proto} + ] + } + ], + } + ], + }, + } + ) + return out + + def r_gateway_ingress_policy(s: dict): """North-south allow: let the shared Cilium Gateway reach an exposed service's port(s). @@ -665,13 +750,19 @@ def r_virtualmachine(s: dict) -> dict: # ============================================================================= # Top-level render + emit # ============================================================================= -def render(sd: dict, image_automation: bool = True) -> list: +def render(sd: dict, image_automation: bool = True, inbound: list | None = None) -> list: """Return an ordered list of (header_comment, resource_dict) for a ServiceDefinition. image_automation=False omits the Flux ImageRepository/ImagePolicy CRs — used when onboarding apps before the Flux image-reflector/-automation controllers are installed (their CRDs would otherwise be missing and the apps Kustomization would fail). The L1 render goldens use the default (True), so their expected output is unchanged. + + inbound is this service's slice of the cross-service reverse-dependency map + (build_reverse_map): the sources that declared a dependency ON this service. It renders + the reciprocal ingress-allow half of each declared dependency (R-NET-02). None/[] (the + default, and every single-service render with no cross-catalog context) renders no + reciprocal ingress — so a lone render is unchanged and stays golden-diffable. """ s = apply_defaults(sd) if s["kind"] not in ("container", "vm"): @@ -701,6 +792,15 @@ def render(sd: dict, image_automation: bool = True) -> list: for cnp in r_dependency_policies(s): docs.append((f"CiliumNetworkPolicy egress allow (R-NET-02): {cnp['metadata']['name']}", cnp)) + for cnp in r_reverse_dependency_policies(s, inbound or []): + docs.append( + ( + "CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): " + f"{cnp['metadata']['name']}", + cnp, + ) + ) + gw_allow = r_gateway_ingress_policy(s) if gw_allow: docs.append( @@ -756,8 +856,38 @@ def load_sd(path: Path) -> dict: return yaml.safe_load(fh) -def render_file(path: Path, image_automation: bool = True) -> str: - return emit(render(load_sd(path), image_automation=image_automation)) +def _load_catalog(dir_: Path) -> list: + """Load every *.yaml ServiceDefinition in dir_ (tolerating unparseable siblings). + + Used to build the cross-service reverse-dependency map for a single-file render, so + `render.py catalog/X.yaml` still emits X's reciprocal ingress for sources elsewhere in + the same catalog. Deterministic (sorted); render.py itself is not a *.yaml so it is skipped. + """ + sds = [] + for p in sorted(dir_.glob("*.yaml")): + try: + doc = load_sd(p) + except Exception: # pragma: no cover - a malformed sibling must not break the render + continue + if isinstance(doc, dict): + sds.append(doc) + return sds + + +def render_file(path: Path, image_automation: bool = True, catalog_dir: Path | None = None) -> str: + """Render one ServiceDefinition file, wiring in its reciprocal ingress allows. + + The reverse-dependency map is built across the whole catalog (catalog_dir, defaulting to + the file's own directory) so this service receives the ingress half of every dependency + that OTHER services declare on it (R-NET-02). A directory with only this file (or with no + inbound edges — e.g. the L1 render fixtures) yields an empty inbound list, leaving the + render byte-identical to the pre-change output. + """ + sd = load_sd(path) + src_dir = catalog_dir if catalog_dir is not None else path.parent + rev = build_reverse_map(_load_catalog(src_dir)) + inbound = rev.get(sd["name"], []) + return emit(render(sd, image_automation=image_automation, inbound=inbound)) # ============================================================================= @@ -834,6 +964,54 @@ def _selftest() -> int: "every app (egress class) must render a world/internet egress allow", ) + # 4a) reciprocal ingress: a dependency declared ONCE on the SOURCE must render the + # matching ingress-allow on the DESTINATION (R-NET-02). Without it, default-deny- + # ingress drops the source and the declared dependency does not actually work. + src_sd = { + "name": "src", + "source": {"image": "a:1"}, + "dependencies": [{"to": "dst", "port": 8080, "protocol": "tcp"}], + } + dst_sd = {"name": "dst", "source": {"image": "b:1"}} + rev = build_reverse_map([src_sd, dst_sd]) + check( + rev.get("dst") == [{"source": "src", "port": 8080, "protocol": "tcp"}], + "reverse map must record src->dst:8080 under the destination", + ) + check("src" not in rev, "a service nothing depends on must have no inbound edges") + dst_docs = render(dst_sd, inbound=rev.get("dst", [])) + rev_cnps = [ + d[1] + for d in dst_docs + if d[1]["kind"] == "CiliumNetworkPolicy" + and d[1]["metadata"]["name"] == "dst-allow-src-8080" + ] + check(len(rev_cnps) == 1, "destination must render one reciprocal ingress allow") + if rev_cnps: + ing = rev_cnps[0]["spec"]["ingress"] + check( + ing[0]["fromEndpoints"] == [{"matchLabels": {"app.kubernetes.io/name": "src"}}], + "reciprocal ingress must be from the SOURCE's identity", + ) + allowed = {p["port"] for r in ing for tp in r["toPorts"] for p in tp["ports"]} + check(allowed == {"8080"}, "reciprocal ingress must open only the declared port") + # additive: default-deny ingress stays [] even with the reciprocal allow present. + dst_dd = next(d[1] for d in dst_docs if d[1]["kind"] == "NetworkPolicy") + check( + dst_dd["spec"]["ingress"] == [], + "reciprocal ingress must be ADDITIVE — default-deny ingress stays [] (R-NET-01)", + ) + # a lone render (no inbound) must NOT gain any reciprocal ingress (golden-stable). + lone_docs = render(dst_sd) + check( + not any( + d[1]["kind"] == "CiliumNetworkPolicy" + and d[1]["metadata"]["name"].startswith("dst-allow-src") + for d in lone_docs + ), + "a render with no inbound context must be unchanged (no reciprocal ingress)", + ) + # 4b) exposure (ingressRoutes) -> an ADDITIVE gateway-ingress CiliumNetworkPolicy that # allows ONLY the reserved `ingress` (Gateway proxy) identity to the exposed port, # while default-deny still denies all other ingress (R-NET-01/02).