fix(render): emit reciprocal ingress-allow for declared dependencies (R-NET-02)
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-<source>-<port>` 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 <[email protected]>
This commit is contained in:
1 parent
b6f72eab0c
commit
14beb85b65
6 files changed
+328
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+181
-3
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user