test(sdn): port the securitygroup and serviceexposure suites to Chainsaw

Main added securitygroup.bats (#2922 membership model) and
serviceexposure.bats (#3081 ExposureClass/ServiceExposure) after the
Chainsaw migration branch was cut.

- securitygroup: two Tests — the aggregated-API projection round-trip
  (backing CNP marker label, empty-value membership endpointSelector,
  lineage fromApp + membership fromSG peers, 1:1 rule translation, the
  view hiding storage internals, unmarked-CNP invisibility via an error
  op, delete propagation) and the controller membership-label
  stamp/clear lifecycle. Only the attachments annotation stays a jq
  script (it is a JSON string). Chainsaw auto-cleanup covers every
  fixture even on mid-test failure — the bats had no teardown hook and
  leaked its fixtures.
- serviceexposure: the externalIPs backend resolve/Ready/assignedIPs
  path with the no-MetalLB-pool negative as an error op (ordered after
  resolvedBackend so it cannot false-pass), and the finalizer-removal
  test collapsed onto the delete op itself, which waits for full
  disappearance.
- selection: kubernetes-application now also maps to the two OIDC
  render-side suites (they exercise the same app chart — a gap main's
  bats selection had too); the new dirs enter all_apps discovery
  automatically. Selector unit tests extended and green (13/13 under
  cozytest).
- README: suites list updated.

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
This commit is contained in:
Myasnikov DaniilandClaude Fable 5 committed 2026-07-08 18:24:24 +05:00
1 parent cac07db8fc
commit dd2f89d208
10 files changed
+433 -3

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ hack/e2e-chainsaw/
└── ...
```
Suites: `postgres`, `bucket`, `mariadb`, `mongodb`, `redis`, `qdrant`, `clickhouse`, `kafka`, `etcd`, `openbao`, `harbor`, `foundationdb`, `external-dns`, `kuberture`, `vminstance`, `gateway`, `kubernetes-latest`, `kubernetes-previous`.
Suites: `postgres`, `bucket`, `mariadb`, `mongodb`, `redis`, `qdrant`, `clickhouse`, `kafka`, `etcd`, `openbao`, `harbor`, `foundationdb`, `external-dns`, `kuberture`, `vminstance`, `gateway`, `kubernetes-latest`, `kubernetes-previous`, `kubernetes-oidc-system`, `kubernetes-oidc-customconfig`, `securitygroup`, `serviceexposure`.
## What Chainsaw buys over the BATS suite
@@ -0,0 +1,195 @@
# Chainsaw port of hack/e2e-apps/securitygroup.bats.
#
# Exercises the SecurityGroup membership model against a live cluster. Two
# things unit tests cannot prove: that the in-tree CiliumNetworkPolicy mirror
# (membership endpointSelector, app/SG peer selectors, attachments annotation)
# round-trips against the real cilium.io/v2 CRD, and that the
# securitygroup-controller actually stamps the membership label onto an
# attached application's pods.
#
# Everything both Tests create is chainsaw-applied, so auto-cleanup reclaims
# it even on mid-test failure — strictly better than the bats, which had no
# teardown hook and leaked its fixtures on failure. The one operator-created
# object (the backing CNP) is the same storage object as the tracked
# SecurityGroup, so no finally block is needed anywhere.
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: securitygroup-projection
spec:
catch:
- events: {}
- describe:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
name: sg-e2e
steps:
- name: create-securitygroup
try:
- apply:
file: securitygroup.yaml
- name: assert-backing-cnp
try:
# The backing CNP must materialise with the marker label, the group's own
# membership label as endpointSelector (the EMPTY value must round-trip
# against the real cilium.io/v2 CRD), fromApp projected to the three
# lineage labels, fromSG to the peer group's membership label (index
# order fromApp-then-fromSG is code-guaranteed by projectIngress), and
# the rule list translated 1:1 (port is a string in the CRD).
- assert:
timeout: 1m
resource:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sg-e2e
labels:
sdn.cozystack.io/securitygroup: "true"
spec:
endpointSelector:
matchLabels:
securitygroup.sdn.cozystack.io/sg-e2e: ""
(ingress[0].fromEndpoints[0].matchLabels):
apps.cozystack.io/application.group: apps.cozystack.io
apps.cozystack.io/application.kind: Kubernetes
apps.cozystack.io/application.name: web
(ingress[0].fromEndpoints[1].matchLabels):
securitygroup.sdn.cozystack.io/sg-frontend-e2e: ""
(ingress[0].toPorts[0].ports[0].port): "5432"
(ingress[0].toPorts[0].ports[0].protocol): TCP
# The attachments annotation is a JSON *string* on the backing policy —
# parsed with jq, exactly like the bats (no reliance on JMESPath JSON
# parsing in chainsaw v0.2.15).
- script:
timeout: 1m
content: |
set -eu
att=$(kubectl -n "$NAMESPACE" get ciliumnetworkpolicy sg-e2e \
-o jsonpath='{.metadata.annotations.sdn\.cozystack\.io/attachments}')
echo "$att" | jq -e '.[0].kind == "Postgres" and .[0].name == "db"' >/dev/null
check:
($error == null): true
- name: assert-securitygroup-view
try:
# The SecurityGroup view reconstructs attachments and peers, and HIDES the
# internal marker label + storage annotation.
- assert:
timeout: 1m
resource:
apiVersion: sdn.cozystack.io/v1alpha1
kind: SecurityGroup
metadata:
name: sg-e2e
spec:
(attachments[0].kind): Postgres
(attachments[0].name): db
(ingress[0].fromApp[0].name): web
(ingress[0].fromSG[0]): sg-frontend-e2e
(metadata.labels."sdn.cozystack.io/securitygroup" == null): true
(metadata.annotations."sdn.cozystack.io/attachments" == null): true
- name: unmarked-cnp-invisible
try:
- apply:
file: plain-cnp.yaml
# The aggregated API filters by the marker label synchronously, so
# non-existence of the view is deterministic.
- error:
timeout: 30s
resource:
apiVersion: sdn.cozystack.io/v1alpha1
kind: SecurityGroup
metadata:
name: plain-cnp-e2e
- name: delete-propagates
try:
# Deleting the SecurityGroup removes its backing policy (the delete op
# waits for full disappearance of the view); the unmarked CNP survives.
- delete:
timeout: 1m
ref:
apiVersion: sdn.cozystack.io/v1alpha1
kind: SecurityGroup
name: sg-e2e
- error:
timeout: 1m
resource:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sg-e2e
- assert:
timeout: 30s
resource:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: plain-cnp-e2e
---
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: securitygroup-membership
spec:
catch:
- events: {}
- describe:
apiVersion: v1
kind: Pod
name: sg-member-pod-e2e
- podLogs:
namespace: cozy-securitygroup-controller
selector: app=securitygroup-controller
tail: 100
steps:
- name: create-member-pod-and-securitygroup
try:
- apply:
file: member-pod.yaml
- apply:
file: securitygroup-member.yaml
- name: membership-label-stamped
try:
# The controller must stamp the membership label onto the member pod;
# the value is the empty string (reconciler sets pod.Labels[key] = "").
- assert:
timeout: 2m
resource:
apiVersion: v1
kind: Pod
metadata:
name: sg-member-pod-e2e
labels:
securitygroup.sdn.cozystack.io/sg-member-e2e: ""
- name: delete-clears-membership
try:
# Deleting the SecurityGroup while a pod is still a member must strip the
# membership label off that pod (the controller's finalizer runs before
# the backing policy is garbage-collected), leaving no orphaned label.
# This is the delete-with-members path, distinct from detach.
- delete:
timeout: 2m
ref:
apiVersion: sdn.cozystack.io/v1alpha1
kind: SecurityGroup
name: sg-member-e2e
- error:
timeout: 1m
resource:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sg-member-e2e
- assert:
timeout: 1m
resource:
apiVersion: v1
kind: Pod
metadata:
name: sg-member-pod-e2e
(metadata.labels."securitygroup.sdn.cozystack.io/sg-member-e2e" == null): true
@@ -0,0 +1,27 @@
# A pod carrying the lineage labels of a managed application. The lineage
# webhook skips it (managed-by is already set), so these labels are exactly
# what the securitygroup-controller resolves the attachment to. The full
# restricted-profile securityContext is required by tenant-test PSA.
apiVersion: v1
kind: Pod
metadata:
name: sg-member-pod-e2e
labels:
internal.cozystack.io/managed-by-cozystack: "true"
apps.cozystack.io/application.group: apps.cozystack.io
apps.cozystack.io/application.kind: Postgres
apps.cozystack.io/application.name: sg-member-app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: pause
image: registry.k8s.io/pause:3.9
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
@@ -0,0 +1,17 @@
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: plain-cnp-e2e
spec:
endpointSelector:
matchLabels:
app: untouched
# A rule is mandatory: the CiliumNetworkPolicy CRD requires at least one of
# ingress/ingressDeny/egress/egressDeny (spec anyOf), so a selector-only
# policy is rejected at apply time. This keeps the fixture a valid, applyable
# CNP while leaving it unmarked (no securitygroup label) — so it must stay
# invisible to the SecurityGroup API.
ingress:
- fromEndpoints:
- matchLabels:
app: peer
@@ -0,0 +1,8 @@
apiVersion: sdn.cozystack.io/v1alpha1
kind: SecurityGroup
metadata:
name: sg-member-e2e
spec:
attachments:
- kind: Postgres
name: sg-member-app
@@ -0,0 +1,22 @@
# SecurityGroup created through the aggregated API (storage-backed by the
# CiliumNetworkPolicy itself). It attaches to a managed application; the
# backing endpointSelector is the group's own membership label, and peers are
# expressed as fromApp/fromSG.
apiVersion: sdn.cozystack.io/v1alpha1
kind: SecurityGroup
metadata:
name: sg-e2e
spec:
attachments:
- kind: Postgres
name: db
ingress:
- fromApp:
- kind: Kubernetes
name: web
fromSG:
- sg-frontend-e2e
toPorts:
- ports:
- port: "5432"
protocol: TCP
@@ -0,0 +1,120 @@
# Chainsaw port of hack/e2e-apps/serviceexposure.bats.
#
# Smoke test for the ServiceExposure controller (network.cozystack.io): the
# CRDs are installed, the controller resolves a ServiceExposure to its
# ExposureClass, renders NO foreign backend CRs for the externalIPs backend,
# and reports status. See serviceexposure.yaml for why externalIPs.
#
# Both Tests reuse the same fixture names — safe under the global
# execution.parallel: 1 (the first Test's auto-cleanup completes before the
# second starts).
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: serviceexposure-externalips
spec:
catch:
- events: {}
- describe:
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
name: se-e2e
- get:
apiVersion: network.cozystack.io/v1alpha1
kind: ExposureClass
name: se-e2e-externalips
- podLogs:
namespace: cozy-system
selector: app=cozystack-controller
tail: 100
steps:
- name: create-fixtures
try:
- apply:
file: serviceexposure.yaml
- name: resolved-backend
try:
# The controller stamps status.resolvedBackend once it has reconciled.
- assert:
timeout: 2m
resource:
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
metadata:
name: se-e2e
status:
resolvedBackend: externalIPs
- name: no-backend-pool
try:
# externalIPs backend must not create a MetalLB pool
# (backend.PoolName = cozystack-<class>). resolvedBackend above already
# proved the reconcile ran, so a short non-existence poll is race-free —
# same ordering as the bats.
- error:
timeout: 30s
resource:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: cozystack-se-e2e-externalips
namespace: cozy-metallb
- name: ready-and-assigned-ips
try:
# The pinned externalIPs make the exposure Ready.
- assert:
timeout: 1m
resource:
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
metadata:
name: se-e2e
status:
(conditions[?type == 'Ready']):
- status: "True"
(assignedIPs[0]): 203.0.113.10
---
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: serviceexposure-delete
spec:
catch:
- events: {}
- describe:
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
name: se-e2e
- podLogs:
namespace: cozy-system
selector: app=cozystack-controller
tail: 100
steps:
- name: create-fixtures
try:
- apply:
file: serviceexposure.yaml
- assert:
timeout: 2m
resource:
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
metadata:
name: se-e2e
status:
resolvedBackend: externalIPs
- name: delete-completes-despite-finalizer
try:
# Deletion must complete despite the cleanup finalizer (nothing to GC for
# the externalIPs backend, so the finalizer is removed promptly). The
# chainsaw delete op waits for the object to be fully gone — the delete
# IS the test. 3m covers the bats' 2m delete + 1m wait-for-delete.
- delete:
timeout: 3m
ref:
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
name: se-e2e
@@ -0,0 +1,37 @@
# The externalIPs path is chosen deliberately so the test creates no
# MetalLB/Cilium pool — it cannot collide with the cluster's existing
# LoadBalancer address pools or depend on real L2 reachability
# (203.0.113.10 is TEST-NET-3), keeping it deterministic. The metallb and
# cilium rendering paths (pool + announcer CRs, garbage collection, take-over
# guard) are covered exhaustively by the Go unit tests in
# internal/controller/serviceexposure.
#
# The ExposureClass is cluster-scoped; Chainsaw tracks cluster-scoped applies
# too, so auto-cleanup reclaims all three objects in reverse apply order.
apiVersion: network.cozystack.io/v1alpha1
kind: ExposureClass
metadata:
name: se-e2e-externalips
spec:
backend: externalIPs
---
apiVersion: v1
kind: Service
metadata:
name: se-e2e-svc
spec:
type: ClusterIP
externalIPs:
- 203.0.113.10
ports:
- port: 80
targetPort: 80
---
apiVersion: network.cozystack.io/v1alpha1
kind: ServiceExposure
metadata:
name: se-e2e
spec:
serviceRef:
name: se-e2e-svc
exposureClassName: se-e2e-externalips
+1 -1
View File
@@ -33,7 +33,7 @@ src_to_suites() {
case "$1" in
postgres-application) echo postgres ;;
vm-instance-application) echo vminstance ;;
kubernetes-application) echo "kubernetes-latest kubernetes-previous" ;;
kubernetes-application) echo "kubernetes-latest kubernetes-previous kubernetes-oidc-system kubernetes-oidc-customconfig" ;;
external-dns) echo external-dns ;;
*-application) echo "${1%-application}" ;;
*) echo "$1" ;;
+5 -1
View File
@@ -85,7 +85,7 @@
[ -z "$output" ]
}
@test "kubernetes-application maps to two suites" {
@test "kubernetes-application maps to the four kubernetes suites" {
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cp -r packages/core/platform/sources "$tmp/sources"
@@ -93,6 +93,10 @@
output=$(hack/select-e2e.sh "$tmp/diff" "$tmp/sources")
echo "$output" | grep -q "kubernetes-latest"
echo "$output" | grep -q "kubernetes-previous"
# The OIDC render-side suites exercise the same kubernetes app chart, so a
# chart-only change must select them too.
echo "$output" | grep -q "kubernetes-oidc-system"
echo "$output" | grep -q "kubernetes-oidc-customconfig"
}
@test "dashboards-only diff selects nothing (path is plural)" {