From b7844223de9ef6853383479445b0da2dae58e67e Mon Sep 17 00:00:00 2001 From: frozenprocess <54559947+frozenprocess@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:33:53 -0700 Subject: [PATCH] Add Cilium as a pluggable CNI engine Adds Cilium (v1.19.5, vendored source tree) as a third out-of-process engine alongside Calico and Antrea, following the same pattern: its own Go module (engines/cilium) built over Cilium's untouched source in a separate workspace, speaking the vendor-neutral JSON contract. The engine drives Cilium's real pkg/policy rather than reimplementing semantics: it builds a security identity per workload from its labels, seeds a policy.Repository, parses k8s NetworkPolicy via pkg/k8s.ParseNetworkPolicy, and resolves each pod-pair flow through policy.LookupFlow (two-sided: allow only if neither egress nor ingress denies). Surface today is upstream k8s NetworkPolicy only; CRD kinds and world/CIDR peers are recognised but not yet evaluated. - proxy.go: register the cilium externalProvider (-provider cilium) - Makefile: CILIUM_* vars, clone/fetch/workspace/build-cilium targets, e2e wiring; the workspace target re-adds the genproto replace that go work init drops (else "ambiguous import") - e2e: generalise the antrea-only unsupported-kind skip to a k8sOnlyProvider gate covering both antrea and cilium; add hacks/provision/cilium-{up,down}.sh + cilium-kind.yaml - e2e/testdata: add 14 generic k8s NetworkPolicy cases (pass on all three engines) make verify-all PROVIDER=cilium passes; the new k8s-* cases pass on calico, antrea, and cilium. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + Makefile | 59 +++++- e2e/e2e_test.go | 45 +++-- .../k8s-allow-all-to-app/assertions.yaml | 9 + e2e/testdata/k8s-allow-all-to-app/meta.yaml | 3 + e2e/testdata/k8s-allow-all-to-app/policy.yaml | 16 ++ .../k8s-allow-all-to-app/topology.yaml | 24 +++ .../assertions.yaml | 11 ++ .../k8s-allow-external-to-port/meta.yaml | 3 + .../k8s-allow-external-to-port/policy.yaml | 17 ++ .../k8s-allow-external-to-port/topology.yaml | 20 +++ .../assertions.yaml | 13 ++ .../k8s-allow-from-a-namespace/meta.yaml | 3 + .../k8s-allow-from-a-namespace/policy.yaml | 18 ++ .../k8s-allow-from-a-namespace/topology.yaml | 34 ++++ .../assertions.yaml | 9 + .../k8s-allow-from-all-namespaces/meta.yaml | 3 + .../k8s-allow-from-all-namespaces/policy.yaml | 16 ++ .../topology.yaml | 26 +++ .../assertions.yaml | 13 ++ .../meta.yaml | 3 + .../policy.yaml | 22 +++ .../topology.yaml | 32 ++++ .../k8s-allow-only-to-a-port/assertions.yaml | 16 ++ .../k8s-allow-only-to-a-port/meta.yaml | 3 + .../k8s-allow-only-to-a-port/policy.yaml | 20 +++ .../k8s-allow-only-to-a-port/topology.yaml | 26 +++ .../assertions.yaml | 13 ++ .../meta.yaml | 3 + .../policy.yaml | 14 ++ .../topology.yaml | 26 +++ .../k8s-deny-all-in-namespace/assertions.yaml | 13 ++ .../k8s-deny-all-in-namespace/meta.yaml | 3 + .../k8s-deny-all-in-namespace/policy.yaml | 12 ++ .../k8s-deny-all-in-namespace/topology.yaml | 26 +++ .../k8s-deny-all-to-app/assertions.yaml | 9 + e2e/testdata/k8s-deny-all-to-app/meta.yaml | 3 + e2e/testdata/k8s-deny-all-to-app/policy.yaml | 14 ++ .../k8s-deny-all-to-app/topology.yaml | 18 ++ .../k8s-deny-egress-from-app/assertions.yaml | 9 + .../k8s-deny-egress-from-app/meta.yaml | 3 + .../k8s-deny-egress-from-app/policy.yaml | 16 ++ .../k8s-deny-egress-from-app/topology.yaml | 18 ++ .../k8s-deny-external-egress/assertions.yaml | 12 ++ .../k8s-deny-external-egress/meta.yaml | 3 + .../k8s-deny-external-egress/policy.yaml | 34 ++++ .../k8s-deny-external-egress/topology.yaml | 26 +++ .../assertions.yaml | 9 + .../k8s-deny-from-other-namespaces/meta.yaml | 3 + .../policy.yaml | 15 ++ .../topology.yaml | 26 +++ .../k8s-limit-traffic-to-app/assertions.yaml | 9 + .../k8s-limit-traffic-to-app/meta.yaml | 3 + .../k8s-limit-traffic-to-app/policy.yaml | 19 ++ .../k8s-limit-traffic-to-app/topology.yaml | 24 +++ .../k8s-multiple-selectors/assertions.yaml | 17 ++ e2e/testdata/k8s-multiple-selectors/meta.yaml | 3 + .../k8s-multiple-selectors/policy.yaml | 28 +++ .../k8s-multiple-selectors/topology.yaml | 36 ++++ engines/cilium/eval.go | 112 ++++++++++++ engines/cilium/eval_test.go | 79 ++++++++ engines/cilium/go.mod | 9 + engines/cilium/harness.go | 169 ++++++++++++++++++ engines/cilium/main.go | 100 +++++++++++ hacks/provision/cilium-down.sh | 5 + hacks/provision/cilium-kind.yaml | 14 ++ hacks/provision/cilium-up.sh | 67 +++++++ proxy.go | 7 +- 68 files changed, 1468 insertions(+), 28 deletions(-) create mode 100644 e2e/testdata/k8s-allow-all-to-app/assertions.yaml create mode 100644 e2e/testdata/k8s-allow-all-to-app/meta.yaml create mode 100644 e2e/testdata/k8s-allow-all-to-app/policy.yaml create mode 100644 e2e/testdata/k8s-allow-all-to-app/topology.yaml create mode 100644 e2e/testdata/k8s-allow-external-to-port/assertions.yaml create mode 100644 e2e/testdata/k8s-allow-external-to-port/meta.yaml create mode 100644 e2e/testdata/k8s-allow-external-to-port/policy.yaml create mode 100644 e2e/testdata/k8s-allow-external-to-port/topology.yaml create mode 100644 e2e/testdata/k8s-allow-from-a-namespace/assertions.yaml create mode 100644 e2e/testdata/k8s-allow-from-a-namespace/meta.yaml create mode 100644 e2e/testdata/k8s-allow-from-a-namespace/policy.yaml create mode 100644 e2e/testdata/k8s-allow-from-a-namespace/topology.yaml create mode 100644 e2e/testdata/k8s-allow-from-all-namespaces/assertions.yaml create mode 100644 e2e/testdata/k8s-allow-from-all-namespaces/meta.yaml create mode 100644 e2e/testdata/k8s-allow-from-all-namespaces/policy.yaml create mode 100644 e2e/testdata/k8s-allow-from-all-namespaces/topology.yaml create mode 100644 e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/assertions.yaml create mode 100644 e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/meta.yaml create mode 100644 e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/policy.yaml create mode 100644 e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/topology.yaml create mode 100644 e2e/testdata/k8s-allow-only-to-a-port/assertions.yaml create mode 100644 e2e/testdata/k8s-allow-only-to-a-port/meta.yaml create mode 100644 e2e/testdata/k8s-allow-only-to-a-port/policy.yaml create mode 100644 e2e/testdata/k8s-allow-only-to-a-port/topology.yaml create mode 100644 e2e/testdata/k8s-deny-all-egress-in-namespace/assertions.yaml create mode 100644 e2e/testdata/k8s-deny-all-egress-in-namespace/meta.yaml create mode 100644 e2e/testdata/k8s-deny-all-egress-in-namespace/policy.yaml create mode 100644 e2e/testdata/k8s-deny-all-egress-in-namespace/topology.yaml create mode 100644 e2e/testdata/k8s-deny-all-in-namespace/assertions.yaml create mode 100644 e2e/testdata/k8s-deny-all-in-namespace/meta.yaml create mode 100644 e2e/testdata/k8s-deny-all-in-namespace/policy.yaml create mode 100644 e2e/testdata/k8s-deny-all-in-namespace/topology.yaml create mode 100644 e2e/testdata/k8s-deny-all-to-app/assertions.yaml create mode 100644 e2e/testdata/k8s-deny-all-to-app/meta.yaml create mode 100644 e2e/testdata/k8s-deny-all-to-app/policy.yaml create mode 100644 e2e/testdata/k8s-deny-all-to-app/topology.yaml create mode 100644 e2e/testdata/k8s-deny-egress-from-app/assertions.yaml create mode 100644 e2e/testdata/k8s-deny-egress-from-app/meta.yaml create mode 100644 e2e/testdata/k8s-deny-egress-from-app/policy.yaml create mode 100644 e2e/testdata/k8s-deny-egress-from-app/topology.yaml create mode 100644 e2e/testdata/k8s-deny-external-egress/assertions.yaml create mode 100644 e2e/testdata/k8s-deny-external-egress/meta.yaml create mode 100644 e2e/testdata/k8s-deny-external-egress/policy.yaml create mode 100644 e2e/testdata/k8s-deny-external-egress/topology.yaml create mode 100644 e2e/testdata/k8s-deny-from-other-namespaces/assertions.yaml create mode 100644 e2e/testdata/k8s-deny-from-other-namespaces/meta.yaml create mode 100644 e2e/testdata/k8s-deny-from-other-namespaces/policy.yaml create mode 100644 e2e/testdata/k8s-deny-from-other-namespaces/topology.yaml create mode 100644 e2e/testdata/k8s-limit-traffic-to-app/assertions.yaml create mode 100644 e2e/testdata/k8s-limit-traffic-to-app/meta.yaml create mode 100644 e2e/testdata/k8s-limit-traffic-to-app/policy.yaml create mode 100644 e2e/testdata/k8s-limit-traffic-to-app/topology.yaml create mode 100644 e2e/testdata/k8s-multiple-selectors/assertions.yaml create mode 100644 e2e/testdata/k8s-multiple-selectors/meta.yaml create mode 100644 e2e/testdata/k8s-multiple-selectors/policy.yaml create mode 100644 e2e/testdata/k8s-multiple-selectors/topology.yaml create mode 100644 engines/cilium/eval.go create mode 100644 engines/cilium/eval_test.go create mode 100644 engines/cilium/go.mod create mode 100644 engines/cilium/harness.go create mode 100644 engines/cilium/main.go create mode 100755 hacks/provision/cilium-down.sh create mode 100644 hacks/provision/cilium-kind.yaml create mode 100755 hacks/provision/cilium-up.sh diff --git a/.gitignore b/.gitignore index e012658..c12e256 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ # Per-engine workspaces (one isolated module graph per CNI binary). /engines/antrea/go.work /engines/antrea/go.work.sum +/engines/cilium/go.work +/engines/cilium/go.work.sum # Build artifacts. /bin/ @@ -14,3 +16,4 @@ /.telepathy-base/ /e2e/logs/ +/engines/cilium/cilium diff --git a/Makefile b/Makefile index 2a93d9a..4ee5ef6 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ # # make build # clone Calico (if needed), create go.work, build the binary # make test # build, then feed a sample Request in and print the raw output -# make fetch # vendor source trees (CNI=all|calico|antrea, comma-list ok) +# make fetch # vendor source trees (CNI=all|calico|antrea|cilium, comma-list ok) # make clean # remove build artifacts # make distclean # also remove the Calico checkout and go.work @@ -25,6 +25,14 @@ ANTREA_REPO ?= https://github.com/antrea-io/antrea.git ANTREA_VERSION ?= v2.6.1 ANTREA_DIR ?= third_party/antrea +# Cilium source tree — the third CNI provider. Built as its OWN binary +# (engines/cilium) over this tree, in a separate module/workspace, same as +# Antrea: Cilium's dependency graph (eBPF, envoy, its own controller-runtime) +# never has to reconcile with Calico's. +CILIUM_REPO ?= https://github.com/cilium/cilium.git +CILIUM_VERSION ?= v1.19.5 +CILIUM_DIR ?= third_party/cilium + # The shell binary (Calico in-process + the api contract). BIN ?= bin/telepathy # The out-of-process Antrea engine binary, placed next to BIN so the shell finds @@ -32,6 +40,12 @@ BIN ?= bin/telepathy ANTREA_BIN ?= bin/telepathy-engine-antrea ANTREA_MOD ?= engines/antrea +# The out-of-process Cilium engine binary, placed next to BIN so the shell finds +# it automatically (see proxy.go locate()). Built over Cilium's tree in its own +# workspace, same as the Antrea engine. +CILIUM_BIN ?= bin/telepathy-engine-cilium +CILIUM_MOD ?= engines/cilium + # Docker build environment. GO_VERSION tracks .go-version so the container # toolchain matches what the repo pins; override either var to retarget. GO_VERSION ?= $(shell cat .go-version 2>/dev/null || echo 1.25.6) @@ -75,7 +89,7 @@ LDFLAGS := -X main.engineVersion=$(ENGINE_VERSION) \ # and avoids needing libbpf C headers on the build host. export CGO_ENABLED=0 -.PHONY: help all build build-shell build-antrea build-docker image test fetch e2e e2e-help e2e-down clean distclean +.PHONY: help all build build-shell build-antrea build-cilium build-docker image test fetch e2e e2e-help e2e-down clean distclean # Running `make` with no target prints the help below. .DEFAULT_GOAL := help @@ -115,6 +129,12 @@ $(ANTREA_DIR): @echo ">> cloning $(ANTREA_REPO) @ $(ANTREA_VERSION) into $(ANTREA_DIR)" git clone --depth 1 --branch $(ANTREA_VERSION) $(ANTREA_REPO) $(ANTREA_DIR) +# --- Cilium source --------------------------------------------------------- +# Shallow clone of the pinned Cilium tag, vendored the same way as Calico/Antrea. +$(CILIUM_DIR): + @echo ">> cloning $(CILIUM_REPO) @ $(CILIUM_VERSION) into $(CILIUM_DIR)" + git clone --depth 1 --branch $(CILIUM_VERSION) $(CILIUM_REPO) $(CILIUM_DIR) + # --- Fetch ----------------------------------------------------------------- # Single entry point for vendoring source trees. Pick which with CNI (a # case-insensitive comma list, or "all"); override tags with CALICO_VERSION / @@ -125,14 +145,15 @@ $(ANTREA_DIR): # make fetch CNI=Calico,antrea CNI ?= all -fetch: ## Fetch source trees: CNI=all|calico|antrea (comma-list ok); tags via CALICO_VERSION/ANTREA_VERSION +fetch: ## Fetch source trees: CNI=all|calico|antrea|cilium (comma-list ok); tags via *_VERSION @cni="$$(echo '$(CNI)' | tr 'A-Z,' 'a-z ')"; \ - [ "$$cni" = "all" ] && cni="calico antrea"; \ + [ "$$cni" = "all" ] && cni="calico antrea cilium"; \ for c in $$cni; do \ case "$$c" in \ calico) $(MAKE) --no-print-directory $(CALICO_DIR) ;; \ antrea) $(MAKE) --no-print-directory $(ANTREA_DIR) ;; \ - *) echo ">> unknown CNI '$$c' (want: all, calico, antrea)" >&2; exit 1 ;; \ + cilium) $(MAKE) --no-print-directory $(CILIUM_DIR) ;; \ + *) echo ">> unknown CNI '$$c' (want: all, calico, antrea, cilium)" >&2; exit 1 ;; \ esac; \ done @@ -156,8 +177,21 @@ $(ANTREA_MOD)/go.work: $(ANTREA_DIR) rm -f $@ cd $(ANTREA_MOD) && GOWORK="$(CURDIR)/$(ANTREA_MOD)/go.work" go work init . ../../api ../../$(ANTREA_DIR) +# Cilium engine: its module + the api contract + Cilium's tree, with Cilium's +# native dependency versions. The genproto replace disambiguates the old +# monolithic google.golang.org/genproto (pulled transitively by grpc/etcd) from +# the split googleapis/{rpc,api} submodules Cilium uses — without it the build +# fails with "ambiguous import". Pin to a post-split genproto that no longer +# carries those subpackages. +$(CILIUM_MOD)/go.work: $(CILIUM_DIR) + @echo ">> initialising cilium engine workspace ($(CILIUM_MOD) + ./api + $(CILIUM_DIR))" + rm -f $@ + cd $(CILIUM_MOD) && GOWORK="$(CURDIR)/$(CILIUM_MOD)/go.work" go work init . ../../api ../../$(CILIUM_DIR) + cd $(CILIUM_MOD) && GOWORK="$(CURDIR)/$(CILIUM_MOD)/go.work" go work edit \ + -replace google.golang.org/genproto=google.golang.org/genproto@v0.0.0-20231016165738-49dd2c1f3d0b + # --- Build ----------------------------------------------------------------- -build: build-shell build-antrea ## Clone sources, create workspaces, build both binaries +build: build-shell build-antrea build-cilium ## Clone sources, create workspaces, build all engine binaries build-shell: $(CALICO_DIR) go.work ## Build the shell binary (Calico in-process) @mkdir -p $(dir $(BIN)) @@ -171,6 +205,12 @@ build-antrea: $(ANTREA_DIR) $(ANTREA_MOD)/go.work ## Build the out-of-process A cd $(ANTREA_MOD) && go build -o $(CURDIR)/$(ANTREA_BIN) . @echo ">> built $(ANTREA_BIN)" +build-cilium: $(CILIUM_DIR) $(CILIUM_MOD)/go.work ## Build the out-of-process Cilium engine binary + @mkdir -p $(dir $(CILIUM_BIN)) + @echo ">> building $(CILIUM_BIN)" + cd $(CILIUM_MOD) && go build -o $(CURDIR)/$(CILIUM_BIN) . + @echo ">> built $(CILIUM_BIN)" + # --- Docker build ---------------------------------------------------------- # Build inside a pinned golang container instead of with the host toolchain, # so the only host requirement is Docker. The repo is bind-mounted at /src and @@ -304,7 +344,7 @@ e2e: build ## Stand up kind+$(PROVIDER) and compare every e2e/testdata case's r @if kind get clusters 2>/dev/null | grep -qx "$(CLUSTER_NAME)-$(PROVIDER)"; then \ echo ">> kind cluster $(CLUSTER_NAME)-$(PROVIDER) exists — skipping provisioning (make e2e-down to recreate)"; \ else \ - CLUSTER_NAME=$(CLUSTER_NAME)-$(PROVIDER) ANTREA_VERSION=$(ANTREA_VERSION) ./hacks/provision/$(PROVIDER)-up.sh; \ + CLUSTER_NAME=$(CLUSTER_NAME)-$(PROVIDER) ANTREA_VERSION=$(ANTREA_VERSION) CILIUM_VERSION=$(CILIUM_VERSION) ./hacks/provision/$(PROVIDER)-up.sh; \ fi @[ "$(PROVIDER)" = calico ] && { lsmod 2>/dev/null | grep -q '^sctp' || echo ">> note: sctp kernel module not loaded; SCTP cases may fail (try: sudo modprobe sctp)"; }; true @if [ "$(E2E_OS)" = windows ]; then \ @@ -326,7 +366,7 @@ e2e-help: ## Show all e2e options (providers, vars, and examples) @echo " make verify-all run the same cases engine-only, no cluster (fast regression)" @echo "" @echo "Variables (VAR=value on the make line):" - @echo " PROVIDER CNI to test: any hacks/provision/-up.sh [default: $(PROVIDER)] (calico|antrea)" + @echo " PROVIDER CNI to test: any hacks/provision/-up.sh [default: $(PROVIDER)] (calico|antrea|cilium)" @echo " CASE run a single case by name (else all) [default: all]" @echo " E2E_OS pod OS: linux|windows (windows = calico only) [default: $(E2E_OS)]" @echo " WINDOWS_ISO path to Windows Server ISO (required E2E_OS=windows)" @@ -335,6 +375,7 @@ e2e-help: ## Show all e2e options (providers, vars, and examples) @echo "Examples:" @echo " make e2e # calico (default)" @echo " make e2e PROVIDER=antrea" + @echo " make e2e PROVIDER=cilium" @echo " make e2e PROVIDER=calico CASE=gnp-icmp-allow" @echo " make e2e E2E_OS=windows WINDOWS_ISO=/path/to/windows-server-2022.iso" @echo " make e2e-down PROVIDER=antrea" @@ -356,4 +397,4 @@ clean: ## Remove build artifacts rm -rf bin distclean: clean ## Also remove the Calico + Antrea checkouts and generated workspaces - rm -rf $(CALICO_DIR) $(ANTREA_DIR) go.work go.work.sum $(ANTREA_MOD)/go.work $(ANTREA_MOD)/go.work.sum + rm -rf $(CALICO_DIR) $(ANTREA_DIR) $(CILIUM_DIR) go.work go.work.sum $(ANTREA_MOD)/go.work $(ANTREA_MOD)/go.work.sum $(CILIUM_MOD)/go.work $(CILIUM_MOD)/go.work.sum diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 0236b3a..6cd9f52 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -147,9 +147,9 @@ func verifyCase(t *testing.T, name, dir string) { t.Skipf("flavor %q not applicable to %s provider", flavor, provider) } policyText := string(readFile(t, filepath.Join(dir, "policy.yaml"))) - if provider == "antrea" { - if kind := unsupportedAntreaKind(policyText); kind != "" { - t.Skipf("antrea engine does not evaluate %s — skipping (would misreport)", kind) + if k8sOnlyProvider(provider) { + if kind := unsupportedK8sOnlyKind(policyText); kind != "" { + t.Skipf("%s engine does not evaluate %s — skipping (would misreport)", provider, kind) } } @@ -206,15 +206,16 @@ func runCase(t *testing.T, c *cluster, name, dir string) { assertBytes := readFile(t, filepath.Join(dir, "assertions.yaml")) policyText := string(readFile(t, filepath.Join(dir, "policy.yaml"))) - // The Antrea engine only predicts upstream Kubernetes NetworkPolicy; it does - // not yet evaluate the NPA admin tier (ClusterNetworkPolicy / Admin- / - // BaselineAdminNetworkPolicy). A k8s-flavored case that leans on those kinds - // would have the dataplane enforce them while the engine ignores them — a - // false DIFF, not a real disagreement. Skip such cases under -provider antrea - // (they light up automatically once the engine grows that support). - if provider == "antrea" { - if kind := unsupportedAntreaKind(policyText); kind != "" { - t.Skipf("antrea engine does not evaluate %s — skipping (would misreport)", kind) + // The Antrea and Cilium engines only predict upstream Kubernetes + // NetworkPolicy; they do not yet evaluate the NPA admin tier + // (ClusterNetworkPolicy / Admin- / BaselineAdminNetworkPolicy) or vendor + // CRDs. A k8s-flavored case that leans on those kinds would have the + // dataplane enforce them while the engine ignores them — a false DIFF, not a + // real disagreement. Skip such cases for those providers (they light up + // automatically once the engine grows that support). + if k8sOnlyProvider(provider) { + if kind := unsupportedK8sOnlyKind(policyText); kind != "" { + t.Skipf("%s engine does not evaluate %s — skipping (would misreport)", provider, kind) } } @@ -1161,11 +1162,21 @@ func readMetaInt(t *testing.T, path, key string) (int, bool) { return 0, false } -// unsupportedAntreaKind returns the first policy kind in policyText that the -// Antrea engine does not evaluate (the NPA admin tier), or "" when every kind is -// supported. It is a deliberately shallow scan of `kind:` lines — enough to gate -// e2e case applicability without parsing the documents. -func unsupportedAntreaKind(policyText string) string { +// k8sOnlyProvider reports whether an engine only predicts upstream Kubernetes +// NetworkPolicy (no Calico/Antrea/Cilium CRDs, no NPA admin tier). Both the +// Antrea and Cilium engines are in this class today, so k8s-flavored cases that +// lean on CRD kinds must be skipped for them (see unsupportedK8sOnlyKind). +func k8sOnlyProvider(provider string) bool { + return provider == "antrea" || provider == "cilium" +} + +// unsupportedK8sOnlyKind returns the first policy kind a k8s-NetworkPolicy-only +// engine (Antrea, Cilium) cannot evaluate, or "" if the manifest is plain k8s +// NetworkPolicy. Such a case would have the dataplane enforce the kind while the +// engine ignores it — a false DIFF, not a real disagreement. It is a +// deliberately shallow scan of `kind:` lines — enough to gate e2e case +// applicability without parsing the documents. +func unsupportedK8sOnlyKind(policyText string) string { unsupported := []string{"ClusterNetworkPolicy", "AdminNetworkPolicy", "BaselineAdminNetworkPolicy"} for _, line := range strings.Split(policyText, "\n") { line = strings.TrimSpace(line) diff --git a/e2e/testdata/k8s-allow-all-to-app/assertions.yaml b/e2e/testdata/k8s-allow-all-to-app/assertions.yaml new file mode 100644 index 0000000..ea7c1e5 --- /dev/null +++ b/e2e/testdata/k8s-allow-all-to-app/assertions.yaml @@ -0,0 +1,9 @@ +assertions: + - name: any source may reach web + from: default/client + to: default/web + expect: allow + - name: any other source may also reach web + from: default/other + to: default/web + expect: allow diff --git a/e2e/testdata/k8s-allow-all-to-app/meta.yaml b/e2e/testdata/k8s-allow-all-to-app/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-allow-all-to-app/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-allow-all-to-app/policy.yaml b/e2e/testdata/k8s-allow-all-to-app/policy.yaml new file mode 100644 index 0000000..8a1f309 --- /dev/null +++ b/e2e/testdata/k8s-allow-all-to-app/policy.yaml @@ -0,0 +1,16 @@ +# Recipe 2a: ALLOW all traffic to an application. +# The empty ingress rule (- {}) whitelists every source. Its purpose is to +# override a namespace-wide default-deny for a specific app; standalone it is +# equivalent to having no ingress policy. Included here so engines are exercised +# on the explicit allow-all rule form. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: web-allow-all + namespace: default +spec: + podSelector: + matchLabels: + app: web + ingress: + - {} diff --git a/e2e/testdata/k8s-allow-all-to-app/topology.yaml b/e2e/testdata/k8s-allow-all-to-app/topology.yaml new file mode 100644 index 0000000..98588ae --- /dev/null +++ b/e2e/testdata/k8s-allow-all-to-app/topology.yaml @@ -0,0 +1,24 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } +endpoints: + - id: default/web + namespace: default + name: web + ip: 10.0.0.1 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/client + namespace: default + name: client + ip: 10.0.0.2 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/other + namespace: default + name: other + ip: 10.0.0.3 + labels: { app: other } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-allow-external-to-port/assertions.yaml b/e2e/testdata/k8s-allow-external-to-port/assertions.yaml new file mode 100644 index 0000000..8d90416 --- /dev/null +++ b/e2e/testdata/k8s-allow-external-to-port/assertions.yaml @@ -0,0 +1,11 @@ +assertions: + - name: any source may reach the exposed port + from: default/client + to: default/web + expect: allow + port: 8080 + - name: other ports stay denied + from: default/client + to: default/web + expect: deny + port: 5000 diff --git a/e2e/testdata/k8s-allow-external-to-port/meta.yaml b/e2e/testdata/k8s-allow-external-to-port/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-allow-external-to-port/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-allow-external-to-port/policy.yaml b/e2e/testdata/k8s-allow-external-to-port/policy.yaml new file mode 100644 index 0000000..a59f5b6 --- /dev/null +++ b/e2e/testdata/k8s-allow-external-to-port/policy.yaml @@ -0,0 +1,17 @@ +# Recipe 8: ALLOW traffic from external clients (port-scoped variant). +# The recipe's use case is exposing web via a LoadBalancer: allow inbound from +# anywhere (no `from`) but restrict it to the served port. This models the +# "restrict to a port" form the recipe documents -- any source may reach 8080, +# but 5000 is denied. (Recipe port 80 -> 8080 to avoid privileged ports.) +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: web-allow-external + namespace: default +spec: + podSelector: + matchLabels: + app: web + ingress: + - ports: + - port: 8080 diff --git a/e2e/testdata/k8s-allow-external-to-port/topology.yaml b/e2e/testdata/k8s-allow-external-to-port/topology.yaml new file mode 100644 index 0000000..80c24f7 --- /dev/null +++ b/e2e/testdata/k8s-allow-external-to-port/topology.yaml @@ -0,0 +1,20 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } +endpoints: + - id: default/web + namespace: default + name: web + ip: 10.0.0.1 + labels: { app: web } + ports: + - { name: http, port: 8080, protocol: tcp } + - { name: metrics, port: 5000, protocol: tcp } + - id: default/client + namespace: default + name: client + ip: 10.0.0.2 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-allow-from-a-namespace/assertions.yaml b/e2e/testdata/k8s-allow-from-a-namespace/assertions.yaml new file mode 100644 index 0000000..b0066c6 --- /dev/null +++ b/e2e/testdata/k8s-allow-from-a-namespace/assertions.yaml @@ -0,0 +1,13 @@ +assertions: + - name: production namespace allowed + from: prod/client + to: default/web + expect: allow + - name: testing namespace denied + from: dev/client + to: default/web + expect: deny + - name: policy's own namespace denied (lacks purpose=production) + from: default/client + to: default/web + expect: deny diff --git a/e2e/testdata/k8s-allow-from-a-namespace/meta.yaml b/e2e/testdata/k8s-allow-from-a-namespace/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-allow-from-a-namespace/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-allow-from-a-namespace/policy.yaml b/e2e/testdata/k8s-allow-from-a-namespace/policy.yaml new file mode 100644 index 0000000..ab3f9ea --- /dev/null +++ b/e2e/testdata/k8s-allow-from-a-namespace/policy.yaml @@ -0,0 +1,18 @@ +# Recipe 6: ALLOW traffic to an application from a specific namespace. +# The namespaceSelector matches only namespaces labelled purpose=production, so +# web accepts ingress from pods in the prod namespace and nowhere else -- not +# even from pods in its own (default) namespace, which lacks that label. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: web-allow-prod + namespace: default +spec: + podSelector: + matchLabels: + app: web + ingress: + - from: + - namespaceSelector: + matchLabels: + purpose: production diff --git a/e2e/testdata/k8s-allow-from-a-namespace/topology.yaml b/e2e/testdata/k8s-allow-from-a-namespace/topology.yaml new file mode 100644 index 0000000..ac77dfc --- /dev/null +++ b/e2e/testdata/k8s-allow-from-a-namespace/topology.yaml @@ -0,0 +1,34 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } + - name: prod + labels: { kubernetes.io/metadata.name: prod, purpose: production } + - name: dev + labels: { kubernetes.io/metadata.name: dev, purpose: testing } +endpoints: + - id: default/web + namespace: default + name: web + ip: 10.0.0.1 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/client + namespace: default + name: client + ip: 10.0.0.2 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: prod/client + namespace: prod + name: client + ip: 10.0.1.1 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: dev/client + namespace: dev + name: client + ip: 10.0.2.1 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-allow-from-all-namespaces/assertions.yaml b/e2e/testdata/k8s-allow-from-all-namespaces/assertions.yaml new file mode 100644 index 0000000..a7a06b7 --- /dev/null +++ b/e2e/testdata/k8s-allow-from-all-namespaces/assertions.yaml @@ -0,0 +1,9 @@ +assertions: + - name: same-namespace source allowed + from: default/client + to: default/web + expect: allow + - name: other-namespace source allowed + from: secondary/client + to: default/web + expect: allow diff --git a/e2e/testdata/k8s-allow-from-all-namespaces/meta.yaml b/e2e/testdata/k8s-allow-from-all-namespaces/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-allow-from-all-namespaces/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-allow-from-all-namespaces/policy.yaml b/e2e/testdata/k8s-allow-from-all-namespaces/policy.yaml new file mode 100644 index 0000000..fa3f837 --- /dev/null +++ b/e2e/testdata/k8s-allow-from-all-namespaces/policy.yaml @@ -0,0 +1,16 @@ +# Recipe 5: ALLOW traffic to an application from all namespaces. +# An empty namespaceSelector matches every namespace, so web accepts ingress +# from any pod cluster-wide. Contrast recipe 4, where a bare podSelector limits +# sources to the same namespace only. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + namespace: default + name: web-allow-all-namespaces +spec: + podSelector: + matchLabels: + app: web + ingress: + - from: + - namespaceSelector: {} diff --git a/e2e/testdata/k8s-allow-from-all-namespaces/topology.yaml b/e2e/testdata/k8s-allow-from-all-namespaces/topology.yaml new file mode 100644 index 0000000..1559430 --- /dev/null +++ b/e2e/testdata/k8s-allow-from-all-namespaces/topology.yaml @@ -0,0 +1,26 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } + - name: secondary + labels: { kubernetes.io/metadata.name: secondary } +endpoints: + - id: default/web + namespace: default + name: web + ip: 10.0.0.1 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/client + namespace: default + name: client + ip: 10.0.0.2 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: secondary/client + namespace: secondary + name: client + ip: 10.0.1.1 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/assertions.yaml b/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/assertions.yaml new file mode 100644 index 0000000..1a9d7d7 --- /dev/null +++ b/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/assertions.yaml @@ -0,0 +1,13 @@ +assertions: + - name: ops namespace + monitoring pod allowed + from: ops/monitoring + to: default/web + expect: allow + - name: ops namespace but non-monitoring pod denied + from: ops/web + to: default/web + expect: deny + - name: monitoring pod but wrong namespace denied + from: default/monitoring + to: default/web + expect: deny diff --git a/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/meta.yaml b/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/policy.yaml b/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/policy.yaml new file mode 100644 index 0000000..13d2a0d --- /dev/null +++ b/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/policy.yaml @@ -0,0 +1,22 @@ +# Recipe 7: ALLOW traffic from some pods in another namespace. +# The namespaceSelector and podSelector are in the SAME `from` element (no `-` +# before podSelector), so they AND together: the source must be in a namespace +# labelled team=operations AND carry type=monitoring. Right namespace + wrong +# pod, or right pod + wrong namespace, are both denied. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: web-allow-all-ns-monitoring + namespace: default +spec: + podSelector: + matchLabels: + app: web + ingress: + - from: + - namespaceSelector: + matchLabels: + team: operations + podSelector: + matchLabels: + type: monitoring diff --git a/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/topology.yaml b/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/topology.yaml new file mode 100644 index 0000000..17aa006 --- /dev/null +++ b/e2e/testdata/k8s-allow-from-some-pods-in-another-namespace/topology.yaml @@ -0,0 +1,32 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } + - name: ops + labels: { kubernetes.io/metadata.name: ops, team: operations } +endpoints: + - id: default/web + namespace: default + name: web + ip: 10.0.0.1 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/monitoring + namespace: default + name: monitoring + ip: 10.0.0.2 + labels: { type: monitoring } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: ops/monitoring + namespace: ops + name: monitoring + ip: 10.0.1.1 + labels: { type: monitoring } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: ops/web + namespace: ops + name: web + ip: 10.0.1.2 + labels: { type: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-allow-only-to-a-port/assertions.yaml b/e2e/testdata/k8s-allow-only-to-a-port/assertions.yaml new file mode 100644 index 0000000..35f50ed --- /dev/null +++ b/e2e/testdata/k8s-allow-only-to-a-port/assertions.yaml @@ -0,0 +1,16 @@ +assertions: + - name: monitoring may reach the metrics port + from: default/monitoring + to: default/apiserver + expect: allow + port: 5000 + - name: monitoring is denied on other ports + from: default/monitoring + to: default/apiserver + expect: deny + port: 8080 + - name: non-monitoring source denied even on the metrics port + from: default/client + to: default/apiserver + expect: deny + port: 5000 diff --git a/e2e/testdata/k8s-allow-only-to-a-port/meta.yaml b/e2e/testdata/k8s-allow-only-to-a-port/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-allow-only-to-a-port/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-allow-only-to-a-port/policy.yaml b/e2e/testdata/k8s-allow-only-to-a-port/policy.yaml new file mode 100644 index 0000000..2e8f145 --- /dev/null +++ b/e2e/testdata/k8s-allow-only-to-a-port/policy.yaml @@ -0,0 +1,20 @@ +# Recipe 9: ALLOW traffic only to a certain port of an application. +# Selects app=apiserver and allows ingress only from role=monitoring pods AND +# only on port 5000. Monitoring reaching 5000 is allowed; monitoring on any +# other port, or any non-monitoring source, is denied. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: api-allow-5000 + namespace: default +spec: + podSelector: + matchLabels: + app: apiserver + ingress: + - ports: + - port: 5000 + from: + - podSelector: + matchLabels: + role: monitoring diff --git a/e2e/testdata/k8s-allow-only-to-a-port/topology.yaml b/e2e/testdata/k8s-allow-only-to-a-port/topology.yaml new file mode 100644 index 0000000..f1418c2 --- /dev/null +++ b/e2e/testdata/k8s-allow-only-to-a-port/topology.yaml @@ -0,0 +1,26 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } +endpoints: + - id: default/apiserver + namespace: default + name: apiserver + ip: 10.0.0.1 + labels: { app: apiserver } + ports: + - { name: http, port: 8080, protocol: tcp } + - { name: metrics, port: 5000, protocol: tcp } + - id: default/monitoring + namespace: default + name: monitoring + ip: 10.0.0.2 + labels: { role: monitoring } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/client + namespace: default + name: client + ip: 10.0.0.3 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-deny-all-egress-in-namespace/assertions.yaml b/e2e/testdata/k8s-deny-all-egress-in-namespace/assertions.yaml new file mode 100644 index 0000000..e4ce649 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-egress-in-namespace/assertions.yaml @@ -0,0 +1,13 @@ +assertions: + - name: intra-namespace egress denied + from: default/client + to: default/web + expect: deny + - name: egress leaving the namespace denied + from: default/web + to: secondary/client + expect: deny + - name: ingress from another namespace still allowed + from: secondary/client + to: default/web + expect: allow diff --git a/e2e/testdata/k8s-deny-all-egress-in-namespace/meta.yaml b/e2e/testdata/k8s-deny-all-egress-in-namespace/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-egress-in-namespace/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-deny-all-egress-in-namespace/policy.yaml b/e2e/testdata/k8s-deny-all-egress-in-namespace/policy.yaml new file mode 100644 index 0000000..1ddc4ae --- /dev/null +++ b/e2e/testdata/k8s-deny-all-egress-in-namespace/policy.yaml @@ -0,0 +1,14 @@ +# Recipe 12: DENY all non-whitelisted egress in a namespace. +# Empty podSelector selects every pod; policyTypes: [Egress] with an empty +# egress list denies all outbound traffic for the whole namespace. Ingress is +# untouched, so pods in other namespaces can still reach in. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: default-deny-all-egress + namespace: default +spec: + policyTypes: + - Egress + podSelector: {} + egress: [] diff --git a/e2e/testdata/k8s-deny-all-egress-in-namespace/topology.yaml b/e2e/testdata/k8s-deny-all-egress-in-namespace/topology.yaml new file mode 100644 index 0000000..1559430 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-egress-in-namespace/topology.yaml @@ -0,0 +1,26 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } + - name: secondary + labels: { kubernetes.io/metadata.name: secondary } +endpoints: + - id: default/web + namespace: default + name: web + ip: 10.0.0.1 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/client + namespace: default + name: client + ip: 10.0.0.2 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: secondary/client + namespace: secondary + name: client + ip: 10.0.1.1 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-deny-all-in-namespace/assertions.yaml b/e2e/testdata/k8s-deny-all-in-namespace/assertions.yaml new file mode 100644 index 0000000..e0a7734 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-in-namespace/assertions.yaml @@ -0,0 +1,13 @@ +assertions: + - name: same-namespace ingress denied + from: default/client + to: default/web + expect: deny + - name: cross-namespace ingress denied + from: secondary/client + to: default/web + expect: deny + - name: egress out of the namespace is unaffected (Ingress-only policy) + from: default/web + to: secondary/client + expect: allow diff --git a/e2e/testdata/k8s-deny-all-in-namespace/meta.yaml b/e2e/testdata/k8s-deny-all-in-namespace/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-in-namespace/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-deny-all-in-namespace/policy.yaml b/e2e/testdata/k8s-deny-all-in-namespace/policy.yaml new file mode 100644 index 0000000..3be96c6 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-in-namespace/policy.yaml @@ -0,0 +1,12 @@ +# Recipe 3: DENY all non-whitelisted ingress in a namespace. +# An empty podSelector selects EVERY pod in the namespace; the empty ingress +# list then denies all inbound traffic to all of them. This is the baseline +# default-deny other policies build allow-carveouts on top of. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: default-deny-all + namespace: default +spec: + podSelector: {} + ingress: [] diff --git a/e2e/testdata/k8s-deny-all-in-namespace/topology.yaml b/e2e/testdata/k8s-deny-all-in-namespace/topology.yaml new file mode 100644 index 0000000..1559430 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-in-namespace/topology.yaml @@ -0,0 +1,26 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } + - name: secondary + labels: { kubernetes.io/metadata.name: secondary } +endpoints: + - id: default/web + namespace: default + name: web + ip: 10.0.0.1 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/client + namespace: default + name: client + ip: 10.0.0.2 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: secondary/client + namespace: secondary + name: client + ip: 10.0.1.1 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-deny-all-to-app/assertions.yaml b/e2e/testdata/k8s-deny-all-to-app/assertions.yaml new file mode 100644 index 0000000..3e398f2 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-to-app/assertions.yaml @@ -0,0 +1,9 @@ +assertions: + - name: client cannot reach web (ingress default-deny) + from: default/client + to: default/web + expect: deny + - name: web has no egress policy, may reach client + from: default/web + to: default/client + expect: allow diff --git a/e2e/testdata/k8s-deny-all-to-app/meta.yaml b/e2e/testdata/k8s-deny-all-to-app/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-to-app/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-deny-all-to-app/policy.yaml b/e2e/testdata/k8s-deny-all-to-app/policy.yaml new file mode 100644 index 0000000..a405c80 --- /dev/null +++ b/e2e/testdata/k8s-deny-all-to-app/policy.yaml @@ -0,0 +1,14 @@ +# Recipe 1: DENY all traffic to an application. +# podSelector picks app=web; policyTypes defaults to [Ingress] because an +# `ingress` field is present, and an empty ingress list means "no source is +# allowed" -> every inbound connection to web is denied. Egress is untouched. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: web-deny-all + namespace: default +spec: + podSelector: + matchLabels: + app: web + ingress: [] diff --git a/e2e/testdata/k8s-deny-all-to-app/topology.yaml b/e2e/testdata/k8s-deny-all-to-app/topology.yaml new file mode 100644 index 0000000..453063b --- /dev/null +++ b/e2e/testdata/k8s-deny-all-to-app/topology.yaml @@ -0,0 +1,18 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } +endpoints: + - id: default/web + namespace: default + name: web + ip: 10.0.0.1 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/client + namespace: default + name: client + ip: 10.0.0.2 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-deny-egress-from-app/assertions.yaml b/e2e/testdata/k8s-deny-egress-from-app/assertions.yaml new file mode 100644 index 0000000..f8cf6b4 --- /dev/null +++ b/e2e/testdata/k8s-deny-egress-from-app/assertions.yaml @@ -0,0 +1,9 @@ +assertions: + - name: foo's egress is denied + from: default/foo + to: default/web + expect: deny + - name: ingress to foo is unaffected (Egress-only policy) + from: default/web + to: default/foo + expect: allow diff --git a/e2e/testdata/k8s-deny-egress-from-app/meta.yaml b/e2e/testdata/k8s-deny-egress-from-app/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-deny-egress-from-app/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-deny-egress-from-app/policy.yaml b/e2e/testdata/k8s-deny-egress-from-app/policy.yaml new file mode 100644 index 0000000..caf5c5d --- /dev/null +++ b/e2e/testdata/k8s-deny-egress-from-app/policy.yaml @@ -0,0 +1,16 @@ +# Recipe 11: DENY egress traffic from an application. +# policyTypes: [Egress] with an empty egress list denies ALL outbound traffic +# from app=foo pods. Ingress to foo is unaffected. (Real workloads also need a +# DNS carve-out -- see recipe 14.) +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: foo-deny-egress + namespace: default +spec: + podSelector: + matchLabels: + app: foo + policyTypes: + - Egress + egress: [] diff --git a/e2e/testdata/k8s-deny-egress-from-app/topology.yaml b/e2e/testdata/k8s-deny-egress-from-app/topology.yaml new file mode 100644 index 0000000..ebf3f20 --- /dev/null +++ b/e2e/testdata/k8s-deny-egress-from-app/topology.yaml @@ -0,0 +1,18 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } +endpoints: + - id: default/foo + namespace: default + name: foo + ip: 10.0.0.1 + labels: { app: foo } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/web + namespace: default + name: web + ip: 10.0.0.2 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-deny-external-egress/assertions.yaml b/e2e/testdata/k8s-deny-external-egress/assertions.yaml new file mode 100644 index 0000000..142b0ef --- /dev/null +++ b/e2e/testdata/k8s-deny-external-egress/assertions.yaml @@ -0,0 +1,12 @@ +assertions: + - name: DNS egress to kube-dns is allowed + from: default/foo + to: dns-system/coredns + expect: allow + protocol: udp + port: 53 + - name: all other egress is denied + from: default/foo + to: default/web + expect: deny + port: 8080 diff --git a/e2e/testdata/k8s-deny-external-egress/meta.yaml b/e2e/testdata/k8s-deny-external-egress/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-deny-external-egress/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-deny-external-egress/policy.yaml b/e2e/testdata/k8s-deny-external-egress/policy.yaml new file mode 100644 index 0000000..b441431 --- /dev/null +++ b/e2e/testdata/k8s-deny-external-egress/policy.yaml @@ -0,0 +1,34 @@ +# Recipe 14: DENY external egress traffic. +# Egress from app=foo is denied except to the cluster DNS pods (k8s-app=kube-dns +# on port 53). In-cluster DNS resolves, but any other outbound connection -- +# in-cluster or external -- is blocked. +# +# Deviation from the upstream recipe: the DNS namespace label is dns-system, not +# kube-system. The live e2e harness owns the namespaces it creates and cannot +# adopt the real kube-system, so DNS is modelled by a coredns stand-in (still +# labelled k8s-app=kube-dns) in a dns-system namespace -- the same substitution +# used by default-deny-all-namespaces-but-dns-calico. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: foo-deny-external-egress + namespace: default +spec: + podSelector: + matchLabels: + app: foo + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: dns-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP diff --git a/e2e/testdata/k8s-deny-external-egress/topology.yaml b/e2e/testdata/k8s-deny-external-egress/topology.yaml new file mode 100644 index 0000000..f7258ea --- /dev/null +++ b/e2e/testdata/k8s-deny-external-egress/topology.yaml @@ -0,0 +1,26 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } + - name: dns-system + labels: { kubernetes.io/metadata.name: dns-system } +endpoints: + - id: default/foo + namespace: default + name: foo + ip: 10.0.0.1 + labels: { app: foo } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/web + namespace: default + name: web + ip: 10.0.0.2 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: dns-system/coredns + namespace: dns-system + name: coredns + ip: 10.0.1.10 + labels: { k8s-app: kube-dns } + ports: [ { name: dns, port: 53, protocol: udp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-deny-from-other-namespaces/assertions.yaml b/e2e/testdata/k8s-deny-from-other-namespaces/assertions.yaml new file mode 100644 index 0000000..ba2fc71 --- /dev/null +++ b/e2e/testdata/k8s-deny-from-other-namespaces/assertions.yaml @@ -0,0 +1,9 @@ +assertions: + - name: same-namespace source allowed + from: default/client + to: default/web + expect: allow + - name: other-namespace source denied + from: secondary/client + to: default/web + expect: deny diff --git a/e2e/testdata/k8s-deny-from-other-namespaces/meta.yaml b/e2e/testdata/k8s-deny-from-other-namespaces/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-deny-from-other-namespaces/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-deny-from-other-namespaces/policy.yaml b/e2e/testdata/k8s-deny-from-other-namespaces/policy.yaml new file mode 100644 index 0000000..e09acf6 --- /dev/null +++ b/e2e/testdata/k8s-deny-from-other-namespaces/policy.yaml @@ -0,0 +1,15 @@ +# Recipe 4: DENY traffic from other namespaces. +# podSelector: {} selects every pod in the namespace; the rule allows ingress +# only from `podSelector: {}` (any pod in the SAME namespace, since a bare +# podSelector never crosses namespaces). Result: same-namespace allowed, +# every other namespace denied. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + namespace: default + name: deny-from-other-namespaces +spec: + podSelector: {} + ingress: + - from: + - podSelector: {} diff --git a/e2e/testdata/k8s-deny-from-other-namespaces/topology.yaml b/e2e/testdata/k8s-deny-from-other-namespaces/topology.yaml new file mode 100644 index 0000000..1559430 --- /dev/null +++ b/e2e/testdata/k8s-deny-from-other-namespaces/topology.yaml @@ -0,0 +1,26 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } + - name: secondary + labels: { kubernetes.io/metadata.name: secondary } +endpoints: + - id: default/web + namespace: default + name: web + ip: 10.0.0.1 + labels: { app: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/client + namespace: default + name: client + ip: 10.0.0.2 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: secondary/client + namespace: secondary + name: client + ip: 10.0.1.1 + labels: { app: client } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-limit-traffic-to-app/assertions.yaml b/e2e/testdata/k8s-limit-traffic-to-app/assertions.yaml new file mode 100644 index 0000000..2c6f46c --- /dev/null +++ b/e2e/testdata/k8s-limit-traffic-to-app/assertions.yaml @@ -0,0 +1,9 @@ +assertions: + - name: a bookstore pod may reach the api + from: default/bookstore-web + to: default/api + expect: allow + - name: a non-bookstore pod is denied + from: default/other + to: default/api + expect: deny diff --git a/e2e/testdata/k8s-limit-traffic-to-app/meta.yaml b/e2e/testdata/k8s-limit-traffic-to-app/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-limit-traffic-to-app/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-limit-traffic-to-app/policy.yaml b/e2e/testdata/k8s-limit-traffic-to-app/policy.yaml new file mode 100644 index 0000000..a20c1fb --- /dev/null +++ b/e2e/testdata/k8s-limit-traffic-to-app/policy.yaml @@ -0,0 +1,19 @@ +# Recipe 2: LIMIT traffic to an application. +# Selects the api pods (app=bookstore AND role=api) and allows ingress only +# from pods carrying app=bookstore (same namespace). Any other source is denied +# by the implicit ingress default-deny this rule creates. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: api-allow + namespace: default +spec: + podSelector: + matchLabels: + app: bookstore + role: api + ingress: + - from: + - podSelector: + matchLabels: + app: bookstore diff --git a/e2e/testdata/k8s-limit-traffic-to-app/topology.yaml b/e2e/testdata/k8s-limit-traffic-to-app/topology.yaml new file mode 100644 index 0000000..3b4b538 --- /dev/null +++ b/e2e/testdata/k8s-limit-traffic-to-app/topology.yaml @@ -0,0 +1,24 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } +endpoints: + - id: default/api + namespace: default + name: api + ip: 10.0.0.1 + labels: { app: bookstore, role: api } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/bookstore-web + namespace: default + name: bookstore-web + ip: 10.0.0.2 + labels: { app: bookstore, role: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/other + namespace: default + name: other + ip: 10.0.0.3 + labels: { app: other } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/e2e/testdata/k8s-multiple-selectors/assertions.yaml b/e2e/testdata/k8s-multiple-selectors/assertions.yaml new file mode 100644 index 0000000..21c3dbe --- /dev/null +++ b/e2e/testdata/k8s-multiple-selectors/assertions.yaml @@ -0,0 +1,17 @@ +assertions: + - name: search may reach db + from: default/search + to: default/db + expect: allow + - name: api may reach db + from: default/api + to: default/db + expect: allow + - name: inventory/web may reach db + from: default/inventory + to: default/db + expect: allow + - name: unlisted app denied + from: default/other + to: default/db + expect: deny diff --git a/e2e/testdata/k8s-multiple-selectors/meta.yaml b/e2e/testdata/k8s-multiple-selectors/meta.yaml new file mode 100644 index 0000000..a1a6540 --- /dev/null +++ b/e2e/testdata/k8s-multiple-selectors/meta.yaml @@ -0,0 +1,3 @@ +# Upstream Kubernetes NetworkPolicy recipe (networking.k8s.io/v1), so every +# engine evaluates it. +flavor: k8s diff --git a/e2e/testdata/k8s-multiple-selectors/policy.yaml b/e2e/testdata/k8s-multiple-selectors/policy.yaml new file mode 100644 index 0000000..0635fde --- /dev/null +++ b/e2e/testdata/k8s-multiple-selectors/policy.yaml @@ -0,0 +1,28 @@ +# Recipe 10: ALLOW traffic from apps using multiple selectors. +# Selects the db pods and lists three podSelectors as separate `from` elements, +# which OR together: any pod matching search, api, OR inventory/web may connect. +# Anything else is denied. +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: redis-allow-services + namespace: default +spec: + podSelector: + matchLabels: + app: bookstore + role: db + ingress: + - from: + - podSelector: + matchLabels: + app: bookstore + role: search + - podSelector: + matchLabels: + app: bookstore + role: api + - podSelector: + matchLabels: + app: inventory + role: web diff --git a/e2e/testdata/k8s-multiple-selectors/topology.yaml b/e2e/testdata/k8s-multiple-selectors/topology.yaml new file mode 100644 index 0000000..a1391b7 --- /dev/null +++ b/e2e/testdata/k8s-multiple-selectors/topology.yaml @@ -0,0 +1,36 @@ +namespaces: + - name: default + labels: { kubernetes.io/metadata.name: default } +endpoints: + - id: default/db + namespace: default + name: db + ip: 10.0.0.1 + labels: { app: bookstore, role: db } + ports: [ { name: redis, port: 8080, protocol: tcp } ] + - id: default/search + namespace: default + name: search + ip: 10.0.0.2 + labels: { app: bookstore, role: search } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/api + namespace: default + name: api + ip: 10.0.0.3 + labels: { app: bookstore, role: api } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/inventory + namespace: default + name: inventory + ip: 10.0.0.4 + labels: { app: inventory, role: web } + ports: [ { name: http, port: 8080, protocol: tcp } ] + - id: default/other + namespace: default + name: other + ip: 10.0.0.5 + labels: { app: other } + ports: [ { name: http, port: 8080, protocol: tcp } ] +port: 8080 +protocol: tcp diff --git a/engines/cilium/eval.go b/engines/cilium/eval.go new file mode 100644 index 0000000..685c16e --- /dev/null +++ b/engines/cilium/eval.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 The Telepathy Authors +// +// This file is part of Telepathy. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + + "github.com/cilium/cilium/pkg/policy" + policytypes "github.com/cilium/cilium/pkg/policy/types" + "github.com/cilium/cilium/pkg/u8proto" + + "github.com/frozenprocess/telepathy/api" +) + +// evaluate computes the pod-to-pod connectivity matrix for req. Selector +// resolution and the verdict are Cilium's own: build() constructs a real +// pkg/policy Repository, and policy.LookupFlow resolves each flow's egress +// (source) and ingress (destination) policy through the SelectorCache and +// EndpointPolicy map — a flow is allowed only if neither side denies it, the +// same two-sided model the Calico and Antrea engines use. +func evaluate(req api.Request) api.Response { + m := build(req) + + resp := api.Response{Matrix: map[string]string{}} + resp.Warnings = m.warnings + resp.Errors = m.errors + + proto := u8proto.TCP + if strings.EqualFold(req.Protocol, "udp") { + proto = u8proto.UDP + } + port := uint16(req.Port) + if port == 0 { + port = 8080 + } + + // One-shot note if any external/non-namespaced endpoints are present: they + // have no security identity yet, so pairs touching them can't be resolved + // through pkg/policy. ponytail: model these as world/CIDR peers next. + if len(m.ids) < len(req.Endpoints) { + resp.Warnings = append(resp.Warnings, + "cilium provider: non-namespaced endpoints have no identity yet; "+ + "flows to/from them are reported deny (world/CIDR peers not modelled)") + } + + for _, src := range req.Endpoints { + for _, dst := range req.Endpoints { + if src.ID == dst.ID { + continue + } + resp.Matrix[src.ID+"->"+dst.ID] = m.verdict(src, dst, proto, port) + } + } + + resp.Actors = make([]api.Actor, 0, len(req.Endpoints)) + for _, ep := range req.Endpoints { + kind := ep.Role + if kind == "" { + kind = "workload" + } + resp.Actors = append(resp.Actors, api.Actor{ID: ep.ID, Kind: kind}) + } + return resp +} + +// verdict resolves one src->dst:port flow through Cilium's policy engine, +// returning "allow" or "deny". Pairs involving an endpoint with no security +// identity (external/non-namespaced) can't be looked up and default to deny. +func (m model) verdict(src, dst api.Endpoint, proto u8proto.U8proto, port uint16) string { + from, ok := m.ids[src.ID] + if !ok { + return "deny" + } + to, ok := m.ids[dst.ID] + if !ok { + return "deny" + } + flow := policytypes.Flow{From: from, To: to, Proto: proto, Dport: port} + verdict, _, _, err := policy.LookupFlow(m.logger, m.repo, m.idMgr, flow) + if err != nil || !verdict.Allowed() { + return "deny" + } + return "allow" +} + +// endpointNamespace resolves an endpoint's namespace, falling back to splitting +// the "/" ID. A non-namespaced endpoint (external destination +// / host) yields "" and gets no security identity. +func endpointNamespace(ep api.Endpoint) string { + if ep.Namespace != "" { + return ep.Namespace + } + if i := strings.IndexByte(ep.ID, '/'); i >= 0 { + return ep.ID[:i] + } + return "" +} diff --git a/engines/cilium/eval_test.go b/engines/cilium/eval_test.go new file mode 100644 index 0000000..675af33 --- /dev/null +++ b/engines/cilium/eval_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 The Telepathy Authors +// +// This file is part of Telepathy. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/frozenprocess/telepathy/api" +) + +// TestEvaluateIngressIsolation drives a real Cilium pkg/policy evaluation: a +// policy selecting role=backend that admits role=frontend on TCP/6379. backend +// becomes ingress-isolated; frontend and other are not selected by any policy, +// so traffic *to* them is unrestricted. Verifies the two-sided verdict and that +// the port matters (6379 opens, 80 does not). +func TestEvaluateIngressIsolation(t *testing.T) { + np := "apiVersion: networking.k8s.io/v1\n" + + "kind: NetworkPolicy\n" + + "metadata:\n name: allow-frontend\n namespace: myns\n" + + "spec:\n podSelector:\n matchLabels:\n role: backend\n" + + " ingress:\n - from:\n - podSelector:\n matchLabels:\n role: frontend\n" + + " ports:\n - protocol: TCP\n port: 6379\n" + + req := api.Request{ + Endpoints: []api.Endpoint{ + {ID: "myns/frontend", Namespace: "myns", Name: "frontend", Labels: map[string]string{"role": "frontend"}}, + {ID: "myns/backend", Namespace: "myns", Name: "backend", Labels: map[string]string{"role": "backend"}}, + {ID: "myns/other", Namespace: "myns", Name: "other", Labels: map[string]string{"role": "other"}}, + }, + Namespaces: []api.NamespaceInput{{Name: "myns"}}, + Policies: []api.PolicyInput{{Flavor: "k8s", YAML: np}}, + Protocol: "tcp", + } + + // Expected verdicts keyed by "src->dst"; anything not listed is not asserted. + cases := []struct { + port int + want map[string]string + }{ + {port: 6379, want: map[string]string{ + "myns/frontend->myns/backend": "allow", // admitted peer on the open port + "myns/other->myns/backend": "deny", // wrong peer + "myns/backend->myns/frontend": "allow", // frontend not isolated + }}, + {port: 80, want: map[string]string{ + "myns/frontend->myns/backend": "deny", // right peer, wrong port + "myns/other->myns/backend": "deny", + "myns/backend->myns/other": "allow", // other not isolated + }}, + } + + for _, tc := range cases { + req.Port = tc.port + resp := evaluate(req) + if len(resp.Errors) != 0 { + t.Fatalf("port %d: unexpected errors: %v", tc.port, resp.Errors) + } + for k, want := range tc.want { + if got := resp.Matrix[k]; got != want { + t.Errorf("port %d: %s = %q, want %q", tc.port, k, got, want) + } + } + } +} diff --git a/engines/cilium/go.mod b/engines/cilium/go.mod new file mode 100644 index 0000000..4571bed --- /dev/null +++ b/engines/cilium/go.mod @@ -0,0 +1,9 @@ +// engines/cilium is a standalone module that builds the Cilium provider as its +// own binary over Cilium's untouched source tree (../../third_party/cilium), +// using Cilium's native dependency versions. Building it separately from the +// Calico-backed shell is what avoids the cross-CNI dependency conflicts (eBPF, +// envoy, controller-runtime); deps resolve via the sibling go.work, mirroring +// the root module's pattern. +module github.com/frozenprocess/telepathy/engines/cilium + +go 1.26.4 diff --git a/engines/cilium/harness.go b/engines/cilium/harness.go new file mode 100644 index 0000000..19f1384 --- /dev/null +++ b/engines/cilium/harness.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 The Telepathy Authors +// +// This file is part of Telepathy. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "io" + "log/slog" + "strings" + + "sigs.k8s.io/yaml" + + cmtypes "github.com/cilium/cilium/pkg/clustermesh/types" + "github.com/cilium/cilium/pkg/identity" + "github.com/cilium/cilium/pkg/identity/identitymanager" + k8s "github.com/cilium/cilium/pkg/k8s" + k8sConst "github.com/cilium/cilium/pkg/k8s/apis/cilium.io" + slim_networkingv1 "github.com/cilium/cilium/pkg/k8s/slim/k8s/api/networking/v1" + "github.com/cilium/cilium/pkg/labels" + "github.com/cilium/cilium/pkg/policy" + testidentity "github.com/cilium/cilium/pkg/testutils/identity" + testpolicy "github.com/cilium/cilium/pkg/testutils/policy" + + "github.com/frozenprocess/telepathy/api" +) + +// firstIdentity is where we start allocating numeric security identities for the +// request's workloads. Anything below identity.MinimalNumericIdentity is +// reserved (host, world, …); the k8s policy tests seed their own pods from 1000, +// so we do the same — the exact values are arbitrary, they only need to be +// distinct and above the reserved range. +const firstIdentity = identity.NumericIdentity(1000) + +// model is the resolved policy state the evaluator drives: Cilium's real policy +// Repository (rules + SelectorCache seeded with every workload's identity) plus +// the per-endpoint identity map the verdict lookup keys off. Selector +// resolution, identity distillation, and the {identity,port,proto}->verdict +// table are all Cilium's own code (pkg/policy); harness.go only feeds it. +type model struct { + logger *slog.Logger + repo *policy.Repository + idMgr identitymanager.IDManager + ids map[string]*identity.Identity // endpoint ID -> its security identity + warnings []string + errors []string +} + +// build turns req's topology into Cilium security identities, constructs a +// policy Repository seeded with them, and loads every parsed Kubernetes +// NetworkPolicy as Cilium rules. Mirrors pkg/k8s's testNewPolicyRepository + +// TestNetworkPolicyExamples wiring, driven from telepathy's Request instead of +// test literals. +func build(req api.Request) model { + // ponytail: discard Cilium's internal logs. The engine's own output is the + // JSON Response on stdout; slog would corrupt it, so send it to stderr-free + // io.Discard. Bump to os.Stderr when debugging a verdict. + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + nsLabels := map[string]map[string]string{} + for _, ns := range req.Namespaces { + nsLabels[ns.Name] = ns.Labels + } + + m := model{logger: logger, ids: map[string]*identity.Identity{}} + + // One security identity per namespaced workload, built from the same label + // set Cilium's agent would derive: the pod namespace, the pod's own labels, + // and the pod namespace's labels (io.cilium.k8s.namespace.labels.* prefix). + idMap := identity.IdentityMap{} + next := firstIdentity + for _, ep := range req.Endpoints { + ns := endpointNamespace(ep) + if ns == "" { + // Non-namespaced endpoints (external destinations / hosts) have no + // pod identity; they are handled as world/CIDR peers, not yet + // modelled here. See eval.go. + continue + } + lbls := podLabels(ns, ep.Labels, nsLabels[ns]) + next++ + id := identity.NewIdentity(next, lbls.Labels()) + m.ids[ep.ID] = id + idMap[id.ID] = id.LabelArray + } + + m.idMgr = identitymanager.NewIDManager(logger) + m.repo = policy.NewPolicyRepository(logger, idMap, nil, nil, m.idMgr, testpolicy.NewPolicyMetricsNoop()) + m.repo.GetSelectorCache().SetLocalIdentityNotifier(testidentity.NewDummyIdentityNotifier()) + + for _, p := range req.Policies { + np, kind, err := parseK8sNetworkPolicy(p) + switch { + case err != nil: + m.errors = append(m.errors, err.Error()) + case np != nil: + entries, err := k8s.ParseNetworkPolicy(logger, cmtypes.PolicyAnyCluster, np) + if err != nil { + m.errors = append(m.errors, fmt.Sprintf("cilium provider: parse %s/%s: %v", np.Namespace, np.Name, err)) + continue + } + m.repo.MustAddPolicyEntries(entries) + default: + m.warnings = append(m.warnings, + fmt.Sprintf("cilium provider: skipping unsupported manifest kind %q "+ + "(only Kubernetes NetworkPolicy is evaluated today)", kind)) + } + } + return m +} + +// podLabels builds the Cilium label set for a workload, matching how the agent +// derives identity labels from a Pod: the namespace, the pod's own labels, and +// the namespace's labels under the io.cilium.k8s.namespace.labels.* prefix (so +// namespaceSelector rules resolve). All in the k8s label source. +func podLabels(namespace string, podLabels, nsLabels map[string]string) labels.LabelArray { + lbls := labels.LabelArray{ + labels.NewLabel(k8sConst.PodNamespaceLabel, namespace, labels.LabelSourceK8s), + } + for k, v := range podLabels { + lbls = append(lbls, labels.NewLabel(k, v, labels.LabelSourceK8s)) + } + for k, v := range nsLabels { + lbls = append(lbls, labels.NewLabel("io.cilium.k8s.namespace.labels."+k, v, labels.LabelSourceK8s)) + } + return lbls.Sort() +} + +// parseK8sNetworkPolicy decodes a manifest into Cilium's slim NetworkPolicy when +// it is a Kubernetes NetworkPolicy. Returns (nil, kind, nil) for any other kind +// so the caller surfaces a skip warning, and (nil, kind, err) only on malformed +// input. Mirrors the Antrea engine's parser, targeting the slim type +// ParseNetworkPolicy expects. +func parseK8sNetworkPolicy(p api.PolicyInput) (*slim_networkingv1.NetworkPolicy, string, error) { + var head struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + } + if err := yaml.Unmarshal([]byte(p.YAML), &head); err != nil { + return nil, "", fmt.Errorf("cilium provider: cannot parse manifest: %v", err) + } + isK8sNP := head.Kind == "NetworkPolicy" && + (p.Flavor == "k8s" || strings.HasPrefix(head.APIVersion, "networking.k8s.io/")) + if !isK8sNP { + return nil, head.Kind, nil + } + var np slim_networkingv1.NetworkPolicy + if err := yaml.Unmarshal([]byte(p.YAML), &np); err != nil { + return nil, head.Kind, fmt.Errorf("cilium provider: cannot decode NetworkPolicy: %v", err) + } + if np.Namespace == "" { + np.Namespace = "default" + } + return &np, head.Kind, nil +} diff --git a/engines/cilium/main.go b/engines/cilium/main.go new file mode 100644 index 0000000..717c728 --- /dev/null +++ b/engines/cilium/main.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 The Telepathy Authors +// +// This file is part of Telepathy. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command telepathy-engine-cilium is the out-of-process Cilium provider. It is +// built as its OWN Go module (engines/cilium) over Cilium's untouched source +// tree (../../third_party/cilium), so it uses Cilium's native dependency +// versions (eBPF, envoy, its own controller-runtime, …) with no reconciliation +// against Calico — which is the whole reason it lives in a separate binary. The +// main `telepathy` shell dispatches `-provider cilium` to it over the +// vendor-neutral JSON contract: an api.Request on stdin, an api.Response on +// stdout. +// +// Unlike Calico's ordered per-endpoint rule chains, Cilium collapses a +// workload's labels into a numeric security identity and renders policy as a +// {direction, identity, port, protocol} -> verdict table. This engine drives +// Cilium's own pkg/policy offline: it builds a Repository from the request's +// policies, distills each endpoint's SelectorPolicy, and resolves every probe +// via EndpointPolicy.Lookup (see eval.go / harness.go). +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/frozenprocess/telepathy/api" +) + +func main() { + caps := flag.Bool("capabilities", false, "print this engine's capabilities as JSON and exit") + flag.Parse() + + if *caps { + if err := json.NewEncoder(os.Stdout).Encode(capabilities()); err != nil { + fail("encode capabilities: %v", err) + } + return + } + + data, err := io.ReadAll(os.Stdin) + if err != nil { + fail("read request: %v", err) + } + req, err := api.DecodeRequest(data) + if err != nil { + fail("%v", err) + } + if err := json.NewEncoder(os.Stdout).Encode(evaluate(req)); err != nil { + fail("encode response: %v", err) + } +} + +func fail(format string, a ...any) { + fmt.Fprintf(os.Stderr, format+"\n", a...) + os.Exit(1) +} + +// capabilities lists what the Cilium engine honors today. Entries with +// Supported=false are recognised but not yet evaluated. Printed as JSON for the +// shell's `version` banner via the -capabilities flag. +// +// k8s NetworkPolicy is driven through Cilium's real pkg/policy; the CRD kinds +// and world/CIDR peers are recognised but not yet evaluated (flip each as its +// path lands in eval.go/harness.go). +func capabilities() []api.Capability { + return []api.Capability{ + {Name: "kind: NetworkPolicy (k8s)", Supported: true, + Notes: "parsed via Cilium's pkg/k8s.ParseNetworkPolicy, resolved through pkg/policy.LookupFlow"}, + {Name: "spec.podSelector", Supported: true}, + {Name: "ingress / egress rules", Supported: true, + Notes: "two-sided: a flow is allowed only if neither src egress nor dst ingress denies it"}, + {Name: "from/to.podSelector + namespaceSelector", Supported: true}, + {Name: "ports (numeric, named, ranges)", Supported: true, + Notes: "resolved by Cilium; named ports keyed off the destination endpoint"}, + {Name: "policyTypes (Ingress/Egress isolation)", Supported: true}, + {Name: "from/to.ipBlock (cidr + except)", Supported: false, + Notes: "needs world/CIDR identities for the peer; non-namespaced endpoints not modelled yet"}, + {Name: "kind: CiliumNetworkPolicy (cilium.io)", Supported: false, + Notes: "label-based identities, L7 (HTTP), entities (world/cluster/host) — after k8s NP"}, + {Name: "kind: CiliumClusterwideNetworkPolicy (cilium.io)", Supported: false}, + {Name: "dataplane render (eBPF policy map)", Supported: false, + Notes: "Cilium's verdict is a map lookup, not a rule chain; render would dump MapState entries"}, + } +} diff --git a/hacks/provision/cilium-down.sh b/hacks/provision/cilium-down.sh new file mode 100755 index 0000000..d3d234f --- /dev/null +++ b/hacks/provision/cilium-down.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Delete the local Cilium kind cluster. +set -euo pipefail +CLUSTER_NAME="${CLUSTER_NAME:-telepathy-e2e-calico-cilium}" +kind delete cluster --name "$CLUSTER_NAME" diff --git a/hacks/provision/cilium-kind.yaml b/hacks/provision/cilium-kind.yaml new file mode 100644 index 0000000..d894804 --- /dev/null +++ b/hacks/provision/cilium-kind.yaml @@ -0,0 +1,14 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: telepathy-cilium +networking: + # We install Cilium ourselves so policy enforcement actually works. + # (kind's default kindnet CNI does NOT enforce NetworkPolicy.) + disableDefaultCNI: true + # Cilium's kubernetes IPAM carves each node's pod CIDR from this range, which + # kube-controller-manager assigns. 10.244.0.0/16 is the conventional kind pod + # subnet (matches the Antrea config). + podSubnet: "10.244.0.0/16" +nodes: + - role: control-plane + - role: worker diff --git a/hacks/provision/cilium-up.sh b/hacks/provision/cilium-up.sh new file mode 100755 index 0000000..29f44ed --- /dev/null +++ b/hacks/provision/cilium-up.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Create a local kind cluster with Cilium installed (idempotent). +# +# Cilium enforces upstream Kubernetes NetworkPolicy (plus its own CRDs), which +# is what the out-of-process Cilium engine (engines/cilium) predicts via its +# real pkg/policy. The e2e harness then compares the engine's prediction against +# this cluster's real connectivity for every k8s-flavored e2e/testdata case +# (see `make e2e PROVIDER=cilium`). +# +# This is a SEPARATE cluster from the Calico/Antrea e2e ones (a node runs only +# one CNI), hence the distinct default CLUSTER_NAME. +# +# Cilium ships no single all-in-one manifest like Antrea, so we install via its +# Helm chart (requires `helm` on PATH). The chart version tracks the app +# version, so CILIUM_VERSION drives both the chart and the image tag. +# +# Env overrides: +# CLUSTER_NAME (default: telepathy-e2e-calico-cilium) +# CILIUM_VERSION (default: 1.19.5) — kept in step with the engine's pinned +# Cilium source tree (Makefile CILIUM_VERSION, minus the "v") +# so the dataplane and the engine implement the same semantics. +set -euo pipefail + +CLUSTER_NAME="${CLUSTER_NAME:-telepathy-e2e-calico-cilium}" +# Accept a leading "v" (Makefile pins vX.Y.Z) but Helm wants the bare version. +CILIUM_VERSION="${CILIUM_VERSION:-1.19.5}" +CILIUM_VERSION="${CILIUM_VERSION#v}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } + +command -v helm >/dev/null || { echo "helm not found on PATH (needed to install Cilium)"; exit 1; } + +if kind get clusters 2>/dev/null | grep -qx "$CLUSTER_NAME"; then + log "kind cluster '$CLUSTER_NAME' already exists — reusing" +else + log "Creating kind cluster '$CLUSTER_NAME' (default CNI disabled)" + kind create cluster --name "$CLUSTER_NAME" --config "$SCRIPT_DIR/cilium-kind.yaml" +fi + +kubectl config use-context "kind-$CLUSTER_NAME" >/dev/null + +log "Installing Cilium ($CILIUM_VERSION) via Helm" +helm repo add cilium https://helm.cilium.io/ >/dev/null 2>&1 || true +helm repo update cilium >/dev/null +# --reuse-values-free upgrade --install keeps this idempotent across re-runs. +# ipam.mode=kubernetes uses the podCIDR kind assigns (matches cilium-kind.yaml); +# the defaults (kube-proxy present, no BPF host-routing) are the simplest setup +# that enforces NetworkPolicy correctly, which is all the e2e harness needs. +helm upgrade --install cilium cilium/cilium \ + --version "$CILIUM_VERSION" \ + --namespace kube-system \ + --set image.pullPolicy=IfNotPresent \ + --set ipam.mode=kubernetes + +log "Waiting for cilium-operator + cilium agent" +kubectl -n kube-system rollout status deploy/cilium-operator --timeout=300s +kubectl -n kube-system rollout status ds/cilium --timeout=300s + +log "Waiting for node(s) Ready (CNI installed)" +kubectl wait --for=condition=Ready node --all --timeout=180s + +log "Cluster '$CLUSTER_NAME' is ready." +kubectl get nodes -o wide +echo +echo "Enforcing upstream Kubernetes NetworkPolicy via Cilium." +echo "Run the e2e suite against it with: make e2e PROVIDER=cilium" diff --git a/proxy.go b/proxy.go index f56511a..616b557 100644 --- a/proxy.go +++ b/proxy.go @@ -33,8 +33,11 @@ import ( // module so their CNI's dependency versions never have to reconcile with the // shell's (Calico's). They speak the vendor-neutral JSON contract: an // api.Request on stdin, an api.Response on stdout; `-capabilities` prints the -// capability list. The Antrea provider is registered this way. -func init() { provider.Register(externalProvider{name: "antrea", binary: "telepathy-engine-antrea"}) } +// capability list. The Antrea and Cilium providers are registered this way. +func init() { + provider.Register(externalProvider{name: "antrea", binary: "telepathy-engine-antrea"}) + provider.Register(externalProvider{name: "cilium", binary: "telepathy-engine-cilium"}) +} // externalProvider is a provider.Provider backed by an external engine binary. type externalProvider struct {