From 5dd28f760caf6e6178a742dbeae7a06b5f5c72bb Mon Sep 17 00:00:00 2001 From: Shaanveer Singh Date: Wed, 18 Feb 2026 12:47:23 +0100 Subject: [PATCH] netpol: specify explicit per-test pod topologies Signed-off-by: Shaanveer Singh --- test/e2e/network/netpol/model.go | 15 +- test/e2e/network/netpol/network_policy.go | 374 +++++++++++++--------- 2 files changed, 242 insertions(+), 147 deletions(-) diff --git a/test/e2e/network/netpol/model.go b/test/e2e/network/netpol/model.go index 4fc29f25a15..4eaf446845c 100644 --- a/test/e2e/network/netpol/model.go +++ b/test/e2e/network/netpol/model.go @@ -22,6 +22,7 @@ import ( v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/kubernetes/test/e2e/framework" imageutils "k8s.io/kubernetes/test/utils/image" ) @@ -44,17 +45,28 @@ type Model struct { // The number of containers per pod is the number of ports x the number of protocols. // The *total* number of containers is namespaces x pods x ports x protocols. func NewModel(namespaceNames []string, podNames []string, ports []int32, protocols []v1.Protocol) *Model { + podNamesByNamespace := make(map[string]sets.Set[string], len(namespaceNames)) + for _, ns := range namespaceNames { + podNamesByNamespace[ns] = sets.New(podNames...) + } + return newModelWithPerNamespacePodNames(namespaceNames, podNamesByNamespace, ports, protocols) +} + +// newModelWithPerNamespacePodNames instantiates a model where each namespace can define a different pod set. +func newModelWithPerNamespacePodNames(namespaceNames []string, podNamesByNamespace map[string]sets.Set[string], ports []int32, protocols []v1.Protocol) *Model { model := &Model{ - PodNames: podNames, Ports: ports, Protocols: protocols, } + allPodNames := sets.New[string]() // build the entire "model" for the overall test, which means, building // namespaces, pods, containers for each protocol. for _, ns := range namespaceNames { var pods []*Pod + podNames := sets.List(podNamesByNamespace[ns]) for _, podName := range podNames { + allPodNames.Insert(podName) pods = append(pods, &Pod{ Name: podName, Ports: ports, @@ -66,6 +78,7 @@ func NewModel(namespaceNames []string, podNames []string, ports []int32, protoco Pods: pods, }) } + model.PodNames = sets.List(allPodNames) return model } diff --git a/test/e2e/network/netpol/network_policy.go b/test/e2e/network/netpol/network_policy.go index afd485ee2b5..595bfe33298 100644 --- a/test/e2e/network/netpol/network_policy.go +++ b/test/e2e/network/netpol/network_policy.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "net" + "strings" "time" "k8s.io/apimachinery/pkg/util/intstr" @@ -30,6 +31,7 @@ import ( "github.com/onsi/ginkgo/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/kubernetes/test/e2e/feature" "k8s.io/kubernetes/test/e2e/framework" e2eskipper "k8s.io/kubernetes/test/e2e/framework/skipper" @@ -62,52 +64,9 @@ You might be wondering, why are there multiple namespaces used for each test cas These tests are based on "truth tables" that compare the expected and actual connectivity of each pair of pods. Since network policies live in namespaces, and peers can be selected by namespace, -howing the connectivity of pods in other namespaces is key information to show whether a network policy is working as intended or not. +showing the connectivity of pods in other namespaces is key information to show whether a network policy is working as intended or not. -We use 3 namespaces each with 3 pods, and probe all combinations ( 9 pods x 9 pods = 81 data points ) -- including cross-namespace calls. - -Here's an example of a test run, showing the expected and actual connectivity, along with the differences. Note how the -visual representation as a truth table greatly aids in understanding what a network policy is intended to do in theory -and what is happening in practice: - - Oct 19 10:34:16.907: INFO: expected: - - - x/a x/b x/c y/a y/b y/c z/a z/b z/c - x/a X . . . . . . . . - x/b X . . . . . . . . - x/c X . . . . . . . . - y/a . . . . . . . . . - y/b . . . . . . . . . - y/c . . . . . . . . . - z/a X . . . . . . . . - z/b X . . . . . . . . - z/c X . . . . . . . . - - Oct 19 10:34:16.907: INFO: observed: - - - x/a x/b x/c y/a y/b y/c z/a z/b z/c - x/a X . . . . . . . . - x/b X . . . . . . . . - x/c X . . . . . . . . - y/a . . . . . . . . . - y/b . . . . . . . . . - y/c . . . . . . . . . - z/a X . . . . . . . . - z/b X . . . . . . . . - z/c X . . . . . . . . - - Oct 19 10:34:16.907: INFO: comparison: - - - x/a x/b x/c y/a y/b y/c z/a z/b z/c - x/a . . . . . . . . . - x/b . . . . . . . . . - x/c . . . . . . . . . - y/a . . . . . . . . . - y/b . . . . . . . . . - y/c . . . . . . . . . - z/a . . . . . . . . . - z/b . . . . . . . . . - z/c . . . . . . . . . +Each test specifies the exact pod topology it needs (for example: x/a, x/b, y/a, z/a). */ var _ = common.SIGDescribe("Netpol", func() { @@ -126,8 +85,11 @@ var _ = common.SIGDescribe("Netpol", func() { // Only testing port 80 ports := []int32{80} - // Create pods and namespaces for this test - k8s = initializeResources(ctx, f, protocols, ports) + // Namespace X has a default-deny-ingress policy, so we need 2 pods in X to + // verify X-to-X ingress is blocked, and 2 pods in Y to verify Y-to-X is + // also blocked while X-to-Y and Y-to-Y traffic remain allowed. + // (In later tests, we just assume that a policy in X will not affect Y-to-Y traffic.) + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "y/b") // Only going to make a policy in namespace X nsX, _, _ := getK8sNamespaces(k8s) @@ -148,7 +110,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should support a 'default-deny-all' policy", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Namespace X has a default-deny for both ingress and egress, so we need 2 + // pods in X to verify X-to-X traffic is blocked, and a pod in Y to verify + // X<->Y traffic is blocked in both directions. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, _, _ := getK8sNamespaces(k8s) policy := GenNetworkPolicyWithNameAndPodSelector("deny-all", metav1.LabelSelector{}, SetSpecIngressRules(), SetSpecEgressRules()) @@ -164,7 +129,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy to allow traffic from pods within server namespace based on PodSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy isolates x/a and only allows ingress from x/b, so we need x/b as the + // allowed same-namespace peer, x/c as a same-namespace non-matching pod, and + // y/a as a cross-namespace peer that must not be able to reach x/a. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "x/c", "y/a") nsX, _, _ := getK8sNamespaces(k8s) allowedPods := metav1.LabelSelector{ @@ -187,7 +155,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy to allow ingress traffic for a target", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Namespace X is default-deny-ingress, but we then allow ingress to x/a. + // We need both x/a and x/b to show the target-specific behavior, and y/a to + // exercise cross-namespace ingress. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ginkgo.By("having a deny all ingress policy") @@ -204,7 +175,6 @@ var _ = common.SIGDescribe("Netpol", func() { reachability := NewReachability(k8s.AllPodStrings(), true) reachability.ExpectAllIngress(NewPodString(nsX, "a"), true) reachability.ExpectAllIngress(NewPodString(nsX, "b"), false) - reachability.ExpectAllIngress(NewPodString(nsX, "c"), false) ValidateOrFail(k8s, &TestCase{ToPort: 80, Protocol: v1.ProtocolTCP, Reachability: reachability}) }) @@ -212,7 +182,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy to allow ingress traffic from pods in all namespaces", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy on x/a should allow ingress from any namespace, including its own. + // We include x/b as a same-namespace client, plus one pod in Y and Z as + // cross-namespace clients. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "z/a") nsX, _, _ := getK8sNamespaces(k8s) ingressRule := networkingv1.NetworkPolicyIngressRule{} @@ -227,7 +200,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy to allow traffic only from a different namespace, based on NamespaceSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy on x/a allows ingress only from namespace Y. We need x/b to show + // same-namespace ingress is denied, y/a as the allowed source, and z/a as an + // unrelated namespace that must be denied. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "z/a") nsX, nsY, nsZ := getK8sNamespaces(k8s) ingressRule := networkingv1.NetworkPolicyIngressRule{} @@ -246,7 +222,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy based on PodSelector with MatchExpressions", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Same as the basic PodSelector test, but using MatchExpressions: x/b must be + // able to reach x/a, while y/a (other namespace) must not be able to reach x/a. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, _, _ := getK8sNamespaces(k8s) allowedPods := metav1.LabelSelector{ @@ -271,7 +249,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy based on NamespaceSelector with MatchExpressions", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Same as the basic NamespaceSelector test, but using MatchExpressions: y/a is + // the allowed source namespace, while x/b (same namespace) and z/a must be + // denied when connecting to x/a. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "z/a") nsX, nsY, nsZ := getK8sNamespaces(k8s) allowedNamespaces := &metav1.LabelSelector{ @@ -297,7 +278,11 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy based on PodSelector or NamespaceSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy on x/a allows ingress from either (a) any namespace other than X, or + // (b) pod x/b. We include x/b as the PodSelector-allowed source, x/c as a + // non-matching pod in X that must be denied, and y/a as the NamespaceSelector- + // allowed source. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "x/c", "y/a") nsX, _, _ := getK8sNamespaces(k8s) allowedNamespaces := &metav1.LabelSelector{ @@ -327,7 +312,11 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy based on PodSelector and NamespaceSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy on x/a requires BOTH a namespace match and a pod match. We use y/b + // and z/b as the sources that match both selectors, x/b as the negative case + // (pod matches but namespace does not), and y/a as the negative case (namespace + // matches but pod does not). + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "y/b", "z/b") nsX, nsY, nsZ := getK8sNamespaces(k8s) allowedNamespaces := &metav1.LabelSelector{ @@ -358,8 +347,11 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy based on Multiple PodSelectors and NamespaceSelectors", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) - nsX, nsY, nsZ := getK8sNamespaces(k8s) + // Policy on x/a allows ingress only from pods {b,c} in namespaces other than X. + // We need x/b to prove same-namespace traffic is denied, y/b and y/c as allowed + // sources, and y/a as a non-matching pod in an allowed namespace. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "y/b", "y/c") + nsX, nsY, _ := getK8sNamespaces(k8s) allowedNamespaces := &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{{ @@ -383,7 +375,6 @@ var _ = common.SIGDescribe("Netpol", func() { reachability := NewReachability(k8s.AllPodStrings(), true) reachability.ExpectPeer(&Peer{Namespace: nsX}, &Peer{Namespace: nsX, Pod: "a"}, false) reachability.Expect(NewPodString(nsY, "a"), NewPodString(nsX, "a"), false) - reachability.Expect(NewPodString(nsZ, "a"), NewPodString(nsX, "a"), false) ValidateOrFail(k8s, &TestCase{ToPort: 80, Protocol: v1.ProtocolTCP, Reachability: reachability}) }) @@ -391,7 +382,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy based on any PodSelectors", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy on x/a uses multiple "from" entries (pod=b OR pod=c). We need both + // x/b and x/c as allowed sources and x/a as the isolated target pod. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "x/c") nsX, _, _ := getK8sNamespaces(k8s) ingressRule := networkingv1.NetworkPolicyIngressRule{} @@ -414,7 +407,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy to allow traffic only from a pod in a different namespace based on PodSelector and NamespaceSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy on x/a allows ingress only from y/a (namespace+pod selectors). We keep + // x/b as a same-namespace/non-matching pod, plus y/b and z/a as non-matching + // cross-namespace pods, to ensure they cannot reach x/a. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "y/b", "z/a") nsX, nsY, _ := getK8sNamespaces(k8s) allowedNamespaces := &metav1.LabelSelector{ @@ -442,7 +438,11 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy based on Ports", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{81} - k8s = initializeResources(ctx, f, protocols, ports) + // This test is port-specific: namespace X should allow ingress to x/a on + // port 81 from namespace Y only. We include x/b as a same-namespace source + // (denied), y/a,y/b as namespace-Y sources (allowed), and z/a as a denied + // third-namespace source. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "y/b", "z/a") nsX, nsY, nsZ := getK8sNamespaces(k8s) ginkgo.By("Creating a network allowPort81Policy which only allows allow listed namespaces (y) to connect on exactly one port (81)") @@ -468,7 +468,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce multiple, stacked policies with overlapping podSelectors", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80, 81} - k8s = initializeResources(ctx, f, protocols, ports) + // We stack multiple policies to verify per-port behavior and policy precedence. + // We need x/a as the target, x/b as a same-namespace source, y/a,y/b as the + // allowed namespace, and z/a as a namespace that must remain denied. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "y/b", "z/a") nsX, nsY, nsZ := getK8sNamespaces(k8s) ginkgo.By("Creating a network allowPort81Policy which only allows allow listed namespaces (y) to connect on exactly one port (81)") @@ -510,7 +513,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should support allow-all policy", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80, 81} - k8s = initializeResources(ctx, f, protocols, ports) + // Allow-all should not restrict anything. We include two pods in X and one in Y + // so we cover both intra-namespace (x/a<->x/b) and cross-namespace traffic. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ginkgo.By("Creating a network policy which allows all traffic.") @@ -526,7 +531,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should allow ingress access on one named port", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80, 81} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy applies to all pods in X and only allows the named port serve-81-tcp. + // We need two pods in X to validate X-to-X traffic is blocked on port 80, and a + // pod in Y to validate cross-namespace ingress is also restricted as expected. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ginkgo.By("Blocking all ports other then 81 in the entire namespace") @@ -547,7 +555,11 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should allow ingress access from namespace on one named port", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80, 81} - k8s = initializeResources(ctx, f, protocols, ports) + // Only namespace Y should be able to reach x/a on the named port serve-80-tcp, + // and port 81 should be blocked for everyone. We include x/b as a + // same-namespace peer (denied), y/a as the allowed namespace, and z/a as a + // denied namespace. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "z/a") nsX, nsY, nsZ := getK8sNamespaces(k8s) allowedLabels := &metav1.LabelSelector{ @@ -578,7 +590,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should allow egress access on one named port", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80, 81} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy restricts egress from x/a by named port: port 80 allowed, port 81 + // denied. One pod in X and one pod in Y is sufficient to validate egress + // behavior. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) egressRule := networkingv1.NetworkPolicyEgressRule{} @@ -598,7 +613,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce updated policy", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{81} - k8s = initializeResources(ctx, f, protocols, ports) + // This test mutates a policy in namespace X and validates connectivity changes + // from allowed -> denied. One pod in X and one in Y is enough to observe the + // before/after behavior. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ginkgo.By("Using the simplest possible mutation: start with allow all, then switch to deny all") @@ -621,7 +639,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should allow ingress access from updated namespace", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Namespace label changes are what we are testing here. We need x/a as the + // target and y/a as the external client whose namespace labels are updated. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, nsY, _ := getK8sNamespaces(k8s) ginkgo.DeferCleanup(DeleteNamespaceLabel, k8s, nsY, "ns2") @@ -652,7 +672,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should allow ingress access from updated pod", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Pod label changes are what we are testing here. We need x/a as the target and + // x/b as the client whose labels are updated to match the PodSelector. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b") nsX, _, _ := getK8sNamespaces(k8s) ginkgo.DeferCleanup(ResetPodLabels, k8s, nsX, "b") @@ -681,7 +703,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should deny ingress from pods on other namespaces", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Ingress policy in X should only allow same-namespace pods. We need two pods + // in X to verify X-to-X is allowed, plus one pod in each of Y and Z to verify + // that cross-namespace ingress into X is denied. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "z/a") nsX, nsY, nsZ := getK8sNamespaces(k8s) IngressRules := networkingv1.NetworkPolicyIngressRule{} @@ -699,7 +724,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should deny ingress access to updated pod", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // This test verifies that x/a becomes isolated when its labels are updated to + // match a deny-ingress policy. We need x/a as the target and y/a as an external + // source. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ginkgo.DeferCleanup(ResetPodLabels, k8s, nsX, "a") @@ -721,7 +749,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should deny egress from pods based on PodSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Egress policy selects x/a and denies all egress. We include x/b to verify + // non-selected pods are unaffected, plus y/a as a cross-namespace destination. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, _, _ := getK8sNamespaces(k8s) policy := GenNetworkPolicyWithNameAndPodSelector("deny-egress-pod-a", metav1.LabelSelector{MatchLabels: map[string]string{"pod": "a"}}, SetSpecEgressRules()) @@ -736,7 +766,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should deny egress from all pods in a namespace", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Namespace-wide egress deny in X: we include x/b to verify x/a->x/b is denied, + // and y/a to verify cross-namespace egress from X is denied as well. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, _, _ := getK8sNamespaces(k8s) policy := GenNetworkPolicyWithNameAndPodSelector("deny-egress-ns-x", metav1.LabelSelector{}, SetSpecEgressRules()) @@ -751,7 +783,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should work with Ingress, Egress specified together", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80, 81} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy on x/a allows ingress only from x/a and x/b, and allows egress only to + // port 80. We include x/c as a same-namespace disallowed ingress source and y/a + // as a cross-namespace destination for egress checks. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "x/c", "y/a") nsX, _, _ := getK8sNamespaces(k8s) allowedPodLabels := &metav1.LabelSelector{MatchLabels: map[string]string{"pod": "b"}} @@ -795,7 +830,10 @@ var _ = common.SIGDescribe("Netpol", func() { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // We need x/a as the client selected by the egress policy, and two pods in Y so + // we can show y/a is the allowed destination while y/b is denied by egress even + // though ingress on the server side allows both. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a", "y/b") nsX, nsY, _ := getK8sNamespaces(k8s) // Building egress policy for x/a to y/a only @@ -833,48 +871,7 @@ var _ = common.SIGDescribe("Netpol", func() { CreatePolicy(ctx, k8s, allowIngressPolicyPodA, nsY) CreatePolicy(ctx, k8s, allowIngressPolicyPodB, nsY) - // While applying the policies, traffic needs to be allowed by both egress and ingress rules. - // Egress rules only - // xa xb xc ya yb yc za zb zc - // xa X X X . *X* X X X X - // xb . . . . . . . . . - // xc . . . . . . . . . - // ya . . . . . . . . . - // yb . . . . . . . . . - // yc . . . . . . . . . - // za . . . . . . . . . - // zb . . . . . . . . . - // zc . . . . . . . . . - // Ingress rules only - // xa xb xc ya yb yc za zb zc - // xa . . . *.* . . . . . - // xb . . X X . . . . . - // xc . . X X . . . . . - // ya . . X X . . . . . - // yb . . X X . . . . . - // yc . . X X . . . . . - // za . . X X . . . . . - // zb . . X X . . . . . - // zc . . X X . . . . . - // In the resulting truth table, connections from x/a should only be allowed to y/a. x/a to y/b should be blocked by the egress on x/a. - // Expected results - // xa xb xc ya yb yc za zb zc - // xa X X X . *X* X X X X - // xb . . . X X . . . . - // xc . . . X X . . . . - // ya . . . X X . . . . - // yb . . . X X . . . . - // yc . . . X X . . . . - // za . . . X X . . . . - // zb . . . X X . . . . - // zc . . . X X . . . . - reachability := NewReachability(k8s.AllPodStrings(), true) - // Default all traffic flows. - // Exception: x/a can only egress to y/a, others are false - // Exception: y/a can only allow ingress from x/a, others are false - // Exception: y/b has no allowed traffic (due to limit on x/a egress) - reachability.ExpectPeer(&Peer{Namespace: nsX, Pod: "a"}, &Peer{}, false) reachability.ExpectPeer(&Peer{}, &Peer{Namespace: nsY, Pod: "a"}, false) reachability.ExpectPeer(&Peer{Namespace: nsX, Pod: "a"}, &Peer{Namespace: nsY, Pod: "a"}, true) @@ -886,7 +883,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce egress policy allowing traffic to a server in a different namespace based on PodSelector and NamespaceSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Egress policy on x/a only allows traffic to y/a (namespace+pod selectors). + // We include y/b as a non-matching destination to ensure it is denied. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a", "y/b") nsX, nsY, _ := getK8sNamespaces(k8s) allowedNamespaces := &metav1.LabelSelector{ @@ -913,7 +912,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce ingress policy allowing any port traffic to a server on a specific protocol", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP, protocolUDP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Protocol-only ingress allowlist: one server (x/a) and one client (y/a) is + // enough to validate TCP is allowed while UDP is denied. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ingressRule := networkingv1.NetworkPolicyIngressRule{} @@ -932,7 +933,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce multiple ingress policies with ingress allow-all policy taking precedence", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{81} - k8s = initializeResources(ctx, f, protocols, ports) + // We only need x/a as the policy target and y/a as the client to observe that + // an allow-all ingress policy overrides a more restrictive ingress policy. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) IngressRules := networkingv1.NetworkPolicyIngressRule{} @@ -958,7 +961,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce multiple egress policies with egress allow-all policy taking precedence", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{81} - k8s = initializeResources(ctx, f, protocols, ports) + // We only need x/a as the policy target and y/a as the destination to observe + // that an allow-all egress policy overrides a more restrictive egress policy. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) egressRule := networkingv1.NetworkPolicyEgressRule{} @@ -984,7 +989,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should stop enforcing policies after they are deleted", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // A single client/server pair is sufficient to validate connectivity is denied + // while the policy exists and returns to allowed after the policy is deleted. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ginkgo.By("Creating a network policy for the server which denies all traffic.") @@ -1013,7 +1020,10 @@ var _ = common.SIGDescribe("Netpol", func() { // Getting podServer's status to get podServer's IP, to create the CIDR protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // This test needs the IP of a real pod to build the IPBlock CIDR. We create y/b + // as the server (to source the IP), y/a as a non-matching destination, and x/a + // as the client whose egress is restricted. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a", "y/b") nsX, nsY, _ := getK8sNamespaces(k8s) podList, err := f.ClientSet.CoreV1().Pods(nsY).List(ctx, metav1.ListOptions{LabelSelector: "pod=b"}) @@ -1040,7 +1050,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce except clause while egress access to server in CIDR block", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // We need x/b to supply the IP used in the IPBlock (and as the destination), + // x/a as the client, and x/c to verify non-excepted traffic still works. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "x/c") nsX, _, _ := getK8sNamespaces(k8s) // Getting podServer's status to get podServer's IP, to create the CIDR with except clause @@ -1073,7 +1085,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should ensure an IP overlapping both IPBlock.CIDR and IPBlock.Except is allowed", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // We need x/b to supply the IP used in the IPBlock (and as the destination), + // x/a as the client, and x/c to verify non-excepted traffic still works for + // CIDR/Except overlap behavior. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "x/c") nsX, _, _ := getK8sNamespaces(k8s) // Getting podServer's status to get podServer's IP, to create the CIDR with except clause @@ -1120,7 +1135,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policies to check ingress and egress policies can be controlled independently based on PodSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Policies target x/a. We need x/b to verify a->b allowed while b->a can be + // denied, and y/a as an external pod so the initial "all traffic allowed" step + // covers cross-namespace connectivity as well. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, _, _ := getK8sNamespaces(k8s) /* @@ -1158,7 +1176,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should not mistakenly treat 'protocol: SCTP' as 'protocol: TCP', even if the plugin doesn't support SCTP", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{81} - k8s = initializeResources(ctx, f, protocols, ports) + // One server (x/a) and one client (y/a) is sufficient: we create an SCTP-only + // allow policy and verify that TCP to the same port remains blocked. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ginkgo.By("Creating a default-deny ingress policy.") @@ -1184,7 +1204,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should properly isolate pods that are selected by a policy allowing SCTP, even if the plugin doesn't support SCTP", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{81} - k8s = initializeResources(ctx, f, protocols, ports) + // One server (x/a) and one client (y/a) is sufficient: an SCTP-only allow rule + // should still isolate the pod for TCP traffic on other ports. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ginkgo.By("Creating a network policy for the server which allows traffic only via SCTP on port 80.") @@ -1202,7 +1224,9 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should not allow access by TCP when a policy specifies only UDP", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{81} - k8s = initializeResources(ctx, f, protocols, ports) + // One server (x/a) and one client (y/a) is sufficient: a UDP-only allow rule + // must not accidentally permit TCP connectivity. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "y/a") nsX, _, _ := getK8sNamespaces(k8s) ingressRule := networkingv1.NetworkPolicyIngressRule{} @@ -1222,7 +1246,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy to allow traffic based on NamespaceSelector with MatchLabels using default ns label", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // Policy on x/a allows ingress only from namespace Y (using the default ns name + // label). We need x/b (same namespace) and z/a (other namespace) as negative + // cases. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "z/a") nsX, nsY, nsZ := getK8sNamespaces(k8s) allowedLabels := &metav1.LabelSelector{ @@ -1246,7 +1273,10 @@ var _ = common.SIGDescribe("Netpol", func() { f.It("should enforce policy based on NamespaceSelector with MatchExpressions using default ns label", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolTCP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // This uses MatchExpressions on the default namespace-name label for an egress + // policy selecting x/a. The selector is NotIn{Y}, so x/a -> Y must be denied + // while x/a -> X and x/a -> Z remain allowed. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "z/a") nsX, nsY, _ := getK8sNamespaces(k8s) allowedNamespaces := &metav1.LabelSelector{ @@ -1283,7 +1313,10 @@ var _ = common.SIGDescribe("Netpol [LinuxOnly]", func() { f.It("should support a 'default-deny-ingress' policy", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolUDP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // UDP: namespace X has a default-deny-ingress policy, so we need 2 pods in X to + // verify X-to-X ingress is blocked, and 2 pods in Y to verify Y-to-X is also + // blocked while X-to-Y and Y-to-Y traffic remain allowed. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "y/b") nsX, _, _ := getK8sNamespaces(k8s) policy := GenNetworkPolicyWithNameAndPodSelector("deny-all", metav1.LabelSelector{}, SetSpecIngressRules()) @@ -1298,7 +1331,11 @@ var _ = common.SIGDescribe("Netpol [LinuxOnly]", func() { f.It("should enforce policy based on Ports", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolUDP} ports := []int32{81} - k8s = initializeResources(ctx, f, protocols, ports) + // UDP port test: namespace X should allow ingress to x/a on port 81 from + // namespace Y only. We include x/b as a same-namespace source (denied) and z/a + // as an unrelated namespace (denied). One pod in Y is enough because the rule + // is namespace-based. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "z/a") nsX, nsY, nsZ := getK8sNamespaces(k8s) ginkgo.By("Creating a network policy allowPort81Policy which only allows allow listed namespaces (y) to connect on exactly one port (81)") @@ -1323,7 +1360,10 @@ var _ = common.SIGDescribe("Netpol [LinuxOnly]", func() { f.It("should enforce policy to allow traffic only from a pod in a different namespace based on PodSelector and NamespaceSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolUDP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // UDP: policy on x/a allows ingress only from y/a (namespace+pod selectors). + // We keep x/b as a same-namespace/non-matching pod to ensure it cannot reach + // x/a. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, nsY, _ := getK8sNamespaces(k8s) allowedNamespaces := &metav1.LabelSelector{ @@ -1365,7 +1405,10 @@ var _ = common.SIGDescribe("Netpol", feature.SCTPConnectivity, "[LinuxOnly]", fu f.It("should support a 'default-deny-ingress' policy", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolSCTP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // SCTP: namespace X has a default-deny-ingress policy, so we need 2 pods in X + // to verify X-to-X ingress is blocked, and 2 pods in Y to verify Y-to-X is also + // blocked while X-to-Y and Y-to-Y traffic remain allowed. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "y/b") nsX, _, _ := getK8sNamespaces(k8s) policy := GenNetworkPolicyWithNameAndPodSelector("deny-all", metav1.LabelSelector{}, SetSpecIngressRules()) @@ -1380,7 +1423,11 @@ var _ = common.SIGDescribe("Netpol", feature.SCTPConnectivity, "[LinuxOnly]", fu f.It("should enforce policy based on Ports", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolSCTP} ports := []int32{81} - k8s = initializeResources(ctx, f, protocols, ports) + // SCTP port test: namespace X should allow ingress to x/a on port 81 from + // namespace Y only. We include x/b as a same-namespace source (denied) and z/a + // as an unrelated namespace (denied). One pod in Y is enough because the rule + // is namespace-based. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a", "z/a") nsX, nsY, nsZ := getK8sNamespaces(k8s) ginkgo.By("Creating a network allowPort81Policy which only allows allow listed namespaces (y) to connect on exactly one port (81)") @@ -1405,7 +1452,10 @@ var _ = common.SIGDescribe("Netpol", feature.SCTPConnectivity, "[LinuxOnly]", fu f.It("should enforce policy to allow traffic only from a pod in a different namespace based on PodSelector and NamespaceSelector", feature.NetworkPolicy, func(ctx context.Context) { protocols := []v1.Protocol{protocolSCTP} ports := []int32{80} - k8s = initializeResources(ctx, f, protocols, ports) + // SCTP: policy on x/a allows ingress only from y/a (namespace+pod selectors). + // We keep x/b as a same-namespace/non-matching pod to ensure it cannot reach + // x/a. + k8s = initializeResources(ctx, f, protocols, ports, "x/a", "x/b", "y/a") nsX, nsY, _ := getK8sNamespaces(k8s) allowedNamespaces := &metav1.LabelSelector{ @@ -1445,10 +1495,39 @@ func getNamespaceNames(rootNs string) []string { return []string{nsX, nsY, nsZ} } -// defaultModel creates a new "model" pod system under namespaces (x,y,z) which has pods a, b, and c. Thus resulting in the -// truth table matrix that is identical for all tests, comprising 81 total connections between 9 pods (x/a, x/b, x/c, ..., z/c). -func defaultModel(namespaces []string, protocols []v1.Protocol, ports []int32) *Model { - return NewModel(namespaces, []string{"a", "b", "c"}, ports, protocols) +// modelFromPodStrings converts pod references like "x/a" into a model that uses the +// actual generated namespace names. +func modelFromPodStrings(namespaces []string, modelPods []string, protocols []v1.Protocol, ports []int32) (*Model, error) { + if len(modelPods) == 0 { + return nil, fmt.Errorf("model pod list must not be empty") + } + + namespaceBySuffix := map[string]string{ + "x": namespaces[0], + "y": namespaces[1], + "z": namespaces[2], + } + podNamesByNamespace := map[string]sets.Set[string]{ + namespaces[0]: sets.New[string](), + namespaces[1]: sets.New[string](), + namespaces[2]: sets.New[string](), + } + + for _, modelPod := range modelPods { + namespaceSuffix, podName, found := strings.Cut(modelPod, "/") + if !found || namespaceSuffix == "" || podName == "" { + return nil, fmt.Errorf("invalid model pod %q; expected / like x/a", modelPod) + } + + namespaceName, found := namespaceBySuffix[namespaceSuffix] + if !found { + return nil, fmt.Errorf("invalid model pod %q; namespace suffix %q must be one of x, y, z", modelPod, namespaceSuffix) + } + + podNamesByNamespace[namespaceName].Insert(podName) + } + + return newModelWithPerNamespacePodNames(namespaces, podNamesByNamespace, ports, protocols), nil } // getK8sNamespaces returns the 3 actual namespace names. @@ -1457,7 +1536,7 @@ func getK8sNamespaces(k8s *kubeManager) (string, string, string) { return ns[0], ns[1], ns[2] } -func initializeCluster(ctx context.Context, f *framework.Framework, protocols []v1.Protocol, ports []int32) (*kubeManager, error) { +func initializeCluster(ctx context.Context, f *framework.Framework, protocols []v1.Protocol, ports []int32, modelPods ...string) (*kubeManager, error) { dnsDomain := framework.TestContext.ClusterDNSDomain framework.Logf("dns domain: %s", dnsDomain) @@ -1465,7 +1544,10 @@ func initializeCluster(ctx context.Context, f *framework.Framework, protocols [] rootNs := f.BaseName namespaceNames := getNamespaceNames(rootNs) - model := defaultModel(namespaceNames, protocols, ports) + model, err := modelFromPodStrings(namespaceNames, modelPods, protocols, ports) + if err != nil { + return nil, err + } framework.Logf("initializing cluster: ensuring namespaces, pods and services exist and are ready") @@ -1485,8 +1567,8 @@ func initializeCluster(ctx context.Context, f *framework.Framework, protocols [] // initializeResources uses the e2e framework to create all necessary namespace resources, based on the network policy // model derived from the framework. It then waits for the resources described by the model to be up and running // (i.e. all pods are ready and running in their namespaces). -func initializeResources(ctx context.Context, f *framework.Framework, protocols []v1.Protocol, ports []int32) *kubeManager { - k8s, err := initializeCluster(ctx, f, protocols, ports) +func initializeResources(ctx context.Context, f *framework.Framework, protocols []v1.Protocol, ports []int32, modelPods ...string) *kubeManager { + k8s, err := initializeCluster(ctx, f, protocols, ports, modelPods...) framework.ExpectNoError(err, "unable to initialize resources") return k8s }