Skip to content

Commit d8c44b9

Browse files
committed
test(triggers): add e2e tests for NetworkPolicy support
Add TestTektonTriggerNetworkPolicy covering: - Default NetworkPolicies created when TektonTrigger is Ready - Triggers functional end-to-end with policies in place: - CEL interceptor matching event (action=push) → PipelineRun created, exercising interceptors ingress from all namespaces on port 8443 and interceptors → API server egress - CEL interceptor non-matching event (action=open) → blocked by interceptor, no new PipelineRun created - spec.networkPolicy.disabled=true removes all policies - Re-enabling restores all policies Add helper functions in test/resources/networkpolicies.go: AssertNetworkPoliciesExist, AssertNetworkPoliciesAbsent, AssertEventListenerReady, AssertPipelineRunCreated, AssertPipelineRunCountUnchanged. Add testdata/triggers/ with Pipeline, TriggerBinding, TriggerTemplate, and EventListener (with CEL interceptor) YAML fixtures. Signed-off-by: Khurram Baig <khurram.baig@gmail.com> Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2b6f6d9 commit d8c44b9

9 files changed

Lines changed: 402 additions & 38 deletions

File tree

pkg/reconciler/common/networkpolicy/networkpolicy.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,7 @@ func Generate(
8484
}
8585

8686
// DefaultDenyPolicy returns a default-deny NetworkPolicy scoped to podSelector.
87-
// Pass an empty metav1.LabelSelector{} for a namespace-wide deny (once all
88-
// components implement NetworkPolicy support). Until then, each component passes
89-
// its own selector (e.g. app.kubernetes.io/part-of: tekton-triggers) so only
90-
// its pods are affected.
87+
// Use an empty LabelSelector{} for a namespace-wide deny once all components support NetworkPolicy.
9188
func DefaultDenyPolicy(name string, podSelector metav1.LabelSelector) networkingv1.NetworkPolicy {
9289
return networkingv1.NetworkPolicy{
9390
ObjectMeta: metav1.ObjectMeta{Name: name},
@@ -126,11 +123,8 @@ func DNSEgressRule(p PlatformParams) networkingv1.NetworkPolicyEgressRule {
126123
}
127124
}
128125

129-
// APIServerEgressRule allows egress to the Kubernetes API server on the platform-specific port.
130-
// Vanilla Kubernetes exposes the API server via kubernetes.default.svc on port 443 (targetPort 6443).
131-
// OpenShift exposes it directly on port 6443.
132-
// No To restriction is set because the API server is typically host-networked or behind a
133-
// ClusterIP service that does not match a pod/namespace selector.
126+
// APIServerEgressRule allows egress to the API server on the platform-specific port (443 on
127+
// Kubernetes, 6443 on OpenShift). No To restriction — API server is behind a ClusterIP service.
134128
func APIServerEgressRule(p PlatformParams) networkingv1.NetworkPolicyEgressRule {
135129
tcp := corev1.ProtocolTCP
136130
apiPort := intstr.FromInt32(p.APIServerPort)

pkg/reconciler/common/networkpolicy/platform.go

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,10 @@ type PlatformParams struct {
2222
DNSResolverNamespace string
2323
DNSResolverPodLabel map[string]string
2424
PrometheusNamespaceLabel map[string]string
25-
// DNSPort is the port the platform's DNS resolver pods listen on.
26-
// Vanilla Kubernetes CoreDNS pods listen on 53.
27-
// OpenShift DNS pods listen on 5353 (host-networked daemonset); OVN-Kubernetes
28-
// enforces NetworkPolicy after DNAT, so the policy must match the pod port (5353),
29-
// not the dns-default service port (53).
25+
// DNSPort is the DNS resolver pod port. Kubernetes CoreDNS uses 53; OpenShift DNS
26+
// uses 5353 (OVN-K8s enforces NetworkPolicy after DNAT, so pod port applies).
3027
DNSPort int32
31-
// APIServerPort is the port the Kubernetes API server service exposes to pods.
32-
// Vanilla Kubernetes exposes the api-server via the kubernetes.default.svc service on
33-
// port 443 (targetPort 6443). OpenShift exposes it on port 6443 directly.
28+
// APIServerPort is the kubernetes.default.svc port: 443 on Kubernetes, 6443 on OpenShift.
3429
APIServerPort int32
3530
}
3631

pkg/reconciler/kubernetes/tektontrigger/networkpolicies.go

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ func triggersDefaultPolicies(params networkpolicy.PlatformParams) []networkingv1
6060
},
6161
PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress},
6262
Ingress: []networkingv1.NetworkPolicyIngressRule{
63-
// cidr="" → allow from any source; operator defaults to permissive.
64-
// Users can restrict to a specific control-plane CIDR via spec.networkPolicy.policies.
63+
// cidr="" → permissive; restrict via spec.networkPolicy.policies if needed.
6564
networkpolicy.WebhookIngressRule("", webhookPort),
6665
networkpolicy.PrometheusIngressRule(params, metricsPort),
6766
},
@@ -93,33 +92,20 @@ func triggersDefaultPolicies(params networkpolicy.PlatformParams) []networkingv1
9392
Egress: []networkingv1.NetworkPolicyEgressRule{
9493
networkpolicy.DNSEgressRule(params),
9594
networkpolicy.APIServerEgressRule(params),
96-
// Core interceptors may call external APIs (e.g. GitHub) to fetch files,
97-
// verify ownership, or perform other outbound operations.
95+
// Allow egress to external APIs (e.g. GitHub) for file fetching and validation.
9896
networkpolicy.InternetEgressRule(),
9997
},
10098
},
10199
},
102100
}
103101
}
104102

105-
// defaultDenyPolicy returns the default-deny NetworkPolicy for the Tekton namespace.
103+
// defaultDenyPolicy returns the scoped default-deny for Triggers pods.
106104
//
107-
// Naming: the policy is intentionally named "tekton-default-deny" (not
108-
// "tekton-triggers-default-deny"). The long-term design is for a single
109-
// namespace-wide deny to be owned by one component (TektonPipeline), so all
110-
// components converge on the same name. Do NOT rename this to a component-specific
111-
// name — doing so would leave orphaned per-component deny policies once the
112-
// namespace-wide policy takes over, and would break the migration path.
113-
//
114-
// Temporary placement: this lives here because TektonTrigger is the first component
115-
// to implement NetworkPolicy support. TektonConfig cannot own it (no InstallerSetClient),
116-
// so the intended long-term owner is TektonPipeline, which is the foundational component
117-
// that all others depend on and already has an InstallerSetClient.
118-
//
119-
// Migration path: once all components (Pipeline, Chains, Results, Dashboard…) implement
120-
// NetworkPolicy support, this function should move to the TektonPipeline reconciler,
121-
// the pod selector should be widened to an empty metav1.LabelSelector{} (namespace-wide
122-
// deny), and this copy removed.
105+
// Name is "tekton-default-deny" (not component-specific) because the long-term
106+
// owner is TektonPipeline, which will replace this with a namespace-wide deny
107+
// (empty podSelector) once all components implement NetworkPolicy support.
108+
// Do NOT rename — orphaned policies would result on migration.
123109
func defaultDenyPolicy() networkingv1.NetworkPolicy {
124110
return networkpolicy.DefaultDenyPolicy(
125111
"tekton-default-deny",
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//go:build e2e
2+
// +build e2e
3+
4+
/*
5+
Copyright 2026 The Tekton Authors
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.
18+
*/
19+
20+
package common
21+
22+
import (
23+
"bytes"
24+
"context"
25+
"fmt"
26+
"os/exec"
27+
"path/filepath"
28+
"runtime"
29+
"testing"
30+
31+
"github.com/tektoncd/operator/test/client"
32+
"github.com/tektoncd/operator/test/resources"
33+
"github.com/tektoncd/operator/test/utils"
34+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
35+
)
36+
37+
const (
38+
npTestNamespace = "tekton-np-e2e"
39+
npEventListenerName = "np-test-listener"
40+
)
41+
42+
// TestTektonTriggerNetworkPolicy verifies NetworkPolicies are created by default,
43+
// that Triggers resources work correctly under those policies (EventListener
44+
// receives events and creates PipelineRuns), and that toggling
45+
// spec.networkPolicy.disabled correctly adds and removes the policies.
46+
func TestTektonTriggerNetworkPolicy(t *testing.T) {
47+
crNames := utils.GetResourceNames()
48+
clients := client.Setup(t, crNames.TargetNamespace)
49+
50+
utils.CleanupOnInterrupt(func() { utils.TearDownPipeline(clients, crNames.TektonPipeline) })
51+
utils.CleanupOnInterrupt(func() { utils.TearDownTrigger(clients, crNames.TektonTrigger) })
52+
utils.CleanupOnInterrupt(func() { utils.TearDownNamespace(clients, npTestNamespace) })
53+
utils.CleanupOnInterrupt(func() { deleteNPTestClusterRoleBinding(t) })
54+
defer utils.TearDownNamespace(clients, npTestNamespace)
55+
defer deleteNPTestClusterRoleBinding(t)
56+
defer utils.TearDownPipeline(clients, crNames.TektonPipeline)
57+
defer utils.TearDownTrigger(clients, crNames.TektonTrigger)
58+
59+
resources.EnsureNoTektonConfigInstance(t, clients, crNames)
60+
61+
if _, err := resources.EnsureTektonPipelineExists(clients.TektonPipeline(), crNames); err != nil {
62+
t.Fatalf("TektonPipeline %q failed to create: %v", crNames.TektonPipeline, err)
63+
}
64+
resources.AssertTektonPipelineCRReadyStatus(t, clients, crNames)
65+
66+
if _, err := resources.EnsureTektonTriggerExists(clients.TektonTrigger(), crNames); err != nil {
67+
t.Fatalf("TektonTrigger %q failed to create: %v", crNames.TektonTrigger, err)
68+
}
69+
resources.AssertTektonTriggerCRReadyStatus(t, clients, crNames)
70+
71+
expectedPolicies := []string{
72+
"tekton-default-deny",
73+
"triggers-controller",
74+
"triggers-webhook",
75+
"triggers-core-interceptors",
76+
}
77+
78+
t.Run("default-policies-created", func(t *testing.T) {
79+
resources.AssertNetworkPoliciesExist(t, clients, crNames.TargetNamespace, expectedPolicies)
80+
})
81+
82+
t.Run("triggers-functional-with-networkpolicies", func(t *testing.T) {
83+
if err := resources.CreateNamespace(clients.KubeClient, npTestNamespace); err != nil {
84+
t.Fatalf("failed to create test namespace %q: %v", npTestNamespace, err)
85+
}
86+
if err := applyTriggersTestdata(t, npTestNamespace); err != nil {
87+
t.Fatalf("failed to apply Triggers testdata: %v", err)
88+
}
89+
90+
// EventListener Ready proves controller reached the API server.
91+
resources.AssertEventListenerReady(t, clients, npTestNamespace, npEventListenerName)
92+
93+
// action=push matches the CEL filter; exercises interceptors ingress + API server egress.
94+
t.Run("cel-interceptor-matching-event-creates-pipelinerun", func(t *testing.T) {
95+
if err := sendEventToListener(t, npTestNamespace, npEventListenerName, `{"action":"push"}`); err != nil {
96+
t.Fatalf("matching event failed: %v", err)
97+
}
98+
resources.AssertPipelineRunCreated(t, clients, npTestNamespace)
99+
})
100+
101+
// action=open does not match; interceptor blocks it, no new PipelineRun.
102+
t.Run("cel-interceptor-non-matching-event-blocked", func(t *testing.T) {
103+
prsBefore, err := clients.TektonClient.PipelineRuns(npTestNamespace).List(context.TODO(), metav1.ListOptions{})
104+
if err != nil {
105+
t.Fatalf("failed to list PipelineRuns: %v", err)
106+
}
107+
_ = sendEventToListener(t, npTestNamespace, npEventListenerName, `{"action":"open"}`)
108+
resources.AssertPipelineRunCountUnchanged(t, clients, npTestNamespace, len(prsBefore.Items))
109+
})
110+
})
111+
112+
t.Run("disable-removes-policies", func(t *testing.T) {
113+
tt, err := clients.TektonTrigger().Get(context.TODO(), crNames.TektonTrigger, metav1.GetOptions{})
114+
if err != nil {
115+
t.Fatalf("failed to get TektonTrigger: %v", err)
116+
}
117+
tt.Spec.NetworkPolicy.Disabled = true
118+
if _, err := clients.TektonTrigger().Update(context.TODO(), tt, metav1.UpdateOptions{}); err != nil {
119+
t.Fatalf("failed to disable NetworkPolicy on TektonTrigger: %v", err)
120+
}
121+
resources.AssertTektonTriggerCRReadyStatus(t, clients, crNames)
122+
resources.AssertNetworkPoliciesAbsent(t, clients, crNames.TargetNamespace, expectedPolicies)
123+
})
124+
125+
t.Run("reenable-restores-policies", func(t *testing.T) {
126+
tt, err := clients.TektonTrigger().Get(context.TODO(), crNames.TektonTrigger, metav1.GetOptions{})
127+
if err != nil {
128+
t.Fatalf("failed to get TektonTrigger: %v", err)
129+
}
130+
tt.Spec.NetworkPolicy.Disabled = false
131+
if _, err := clients.TektonTrigger().Update(context.TODO(), tt, metav1.UpdateOptions{}); err != nil {
132+
t.Fatalf("failed to re-enable NetworkPolicy on TektonTrigger: %v", err)
133+
}
134+
resources.AssertTektonTriggerCRReadyStatus(t, clients, crNames)
135+
resources.AssertNetworkPoliciesExist(t, clients, crNames.TargetNamespace, expectedPolicies)
136+
})
137+
}
138+
139+
// deleteNPTestClusterRoleBinding removes the cluster-scoped binding created by rbac.yaml.
140+
func deleteNPTestClusterRoleBinding(t *testing.T) {
141+
t.Helper()
142+
cmd := exec.Command("kubectl", "delete", "clusterrolebinding", "np-test-el-clusterrolebinding", "--ignore-not-found")
143+
_ = cmd.Run()
144+
}
145+
146+
// applyTriggersTestdata applies testdata/triggers/ YAML into namespace.
147+
func applyTriggersTestdata(t *testing.T, namespace string) error {
148+
t.Helper()
149+
_, file, _, ok := runtime.Caller(0)
150+
if !ok {
151+
return fmt.Errorf("failed to get caller information")
152+
}
153+
testdataDir := filepath.Join(filepath.Dir(file), "testdata", "triggers")
154+
155+
var stderr bytes.Buffer
156+
cmd := exec.Command("kubectl", "apply", "-f", testdataDir, "-n", namespace)
157+
cmd.Stderr = &stderr
158+
if err := cmd.Run(); err != nil {
159+
return fmt.Errorf("kubectl apply failed: %v\n%s", err, stderr.String())
160+
}
161+
return nil
162+
}
163+
164+
// sendEventToListener POSTs payload to the EventListener from a temporary in-cluster pod.
165+
func sendEventToListener(t *testing.T, namespace, listenerName, payload string) error {
166+
t.Helper()
167+
svcURL := fmt.Sprintf("http://el-%s.%s.svc.cluster.local:8080", listenerName, namespace)
168+
169+
var stderr bytes.Buffer
170+
cmd := exec.Command(
171+
"kubectl", "run", "np-e2e-curl", "--restart=Never", "--rm", "-i",
172+
"--image=curlimages/curl:latest",
173+
"-n", namespace,
174+
"--",
175+
"curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
176+
"-X", "POST", svcURL,
177+
"-H", "Content-Type: application/json",
178+
"-d", payload,
179+
)
180+
cmd.Stderr = &stderr
181+
out, err := cmd.Output()
182+
if err != nil {
183+
return fmt.Errorf("curl pod failed: %v\nstderr: %s", err, stderr.String())
184+
}
185+
// kubectl --rm appends the pod deletion message to stdout; take only the first
186+
// 3 bytes which are the curl %{http_code} output.
187+
raw := string(bytes.TrimSpace(out))
188+
if len(raw) < 3 || (raw[:3] != "200" && raw[:3] != "202") {
189+
return fmt.Errorf("EventListener returned unexpected HTTP status %q (want 200/202)", raw)
190+
}
191+
return nil
192+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
apiVersion: triggers.tekton.dev/v1beta1
2+
kind: EventListener
3+
metadata:
4+
name: np-test-listener
5+
spec:
6+
serviceAccountName: np-test-el-sa
7+
triggers:
8+
- name: np-test-trigger
9+
interceptors:
10+
- ref:
11+
name: cel
12+
params:
13+
- name: filter
14+
value: "body.action == 'push'"
15+
template:
16+
ref: np-test-template
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
apiVersion: tekton.dev/v1
2+
kind: Pipeline
3+
metadata:
4+
name: np-test-pipeline
5+
spec:
6+
tasks:
7+
- name: echo
8+
taskSpec:
9+
steps:
10+
- name: echo
11+
image: alpine
12+
script: echo "NetworkPolicy e2e test passed"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
apiVersion: v1
2+
kind: ServiceAccount
3+
metadata:
4+
name: np-test-el-sa
5+
---
6+
# Binds the EventListener SA to the namespaced Triggers ClusterRole so it can
7+
# read TriggerTemplates and create PipelineRuns in the test namespace.
8+
apiVersion: rbac.authorization.k8s.io/v1
9+
kind: RoleBinding
10+
metadata:
11+
name: np-test-el-rolebinding
12+
roleRef:
13+
apiGroup: rbac.authorization.k8s.io
14+
kind: ClusterRole
15+
name: tekton-triggers-eventlistener-roles
16+
subjects:
17+
- kind: ServiceAccount
18+
name: np-test-el-sa
19+
---
20+
# Binds the EventListener SA to the cluster-scoped Triggers ClusterRole so it
21+
# can read ClusterInterceptors (e.g. cel). Named uniquely to avoid conflicts.
22+
apiVersion: rbac.authorization.k8s.io/v1
23+
kind: ClusterRoleBinding
24+
metadata:
25+
name: np-test-el-clusterrolebinding
26+
roleRef:
27+
apiGroup: rbac.authorization.k8s.io
28+
kind: ClusterRole
29+
name: tekton-triggers-eventlistener-clusterroles
30+
subjects:
31+
- kind: ServiceAccount
32+
name: np-test-el-sa
33+
namespace: tekton-np-e2e
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
apiVersion: triggers.tekton.dev/v1beta1
2+
kind: TriggerTemplate
3+
metadata:
4+
name: np-test-template
5+
spec:
6+
resourcetemplates:
7+
- apiVersion: tekton.dev/v1
8+
kind: PipelineRun
9+
metadata:
10+
generateName: np-test-run-
11+
spec:
12+
pipelineRef:
13+
name: np-test-pipeline

0 commit comments

Comments
 (0)