This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
The following commit(s) were added to refs/heads/master by this push:
new 4d0015c0 fix: set the Gateway status reasons Gateway API 1.6 requires
and make the conformance report honest (#2843)
4d0015c0 is described below
commit 4d0015c00cdd6f2d0ec315f44f5acc6dffe283e5
Author: AlinsRan <[email protected]>
AuthorDate: Thu Aug 13 07:27:23 2026 +0800
fix: set the Gateway status reasons Gateway API 1.6 requires and make the
conformance report honest (#2843)
---
.github/workflows/apisix-conformance-test.yml | 92 +++++++++++--
.gitignore | 2 +-
Makefile | 73 +++++++++-
internal/controller/gateway_controller.go | 26 +++-
internal/controller/utils.go | 59 +++++++-
internal/controller/utils_gatewaystatus_test.go | 171 ++++++++++++++++++++++++
test/conformance/README.md | 116 ++++++++++++++++
test/conformance/conformance_test.go | 10 +-
test/e2e/framework/apisix_consts.go | 4 +
test/e2e/framework/ingress.go | 5 +
test/e2e/framework/manifests/apisix.yaml | 4 +-
test/e2e/framework/manifests/ingress.yaml | 4 +-
test/e2e/scaffold/apisix_deployer.go | 3 +
13 files changed, 538 insertions(+), 31 deletions(-)
diff --git a/.github/workflows/apisix-conformance-test.yml
b/.github/workflows/apisix-conformance-test.yml
index 2bf273fe..898b99ce 100644
--- a/.github/workflows/apisix-conformance-test.yml
+++ b/.github/workflows/apisix-conformance-test.yml
@@ -24,6 +24,16 @@ on:
pull_request:
branches:
- master
+ # The report submitted to the Gateway API conformance reports repository is
+ # produced here, with image_tag naming the release to test. It cannot key off
+ # the tag push itself: push-docker.yaml publishes the images on that same
+ # event, so they do not exist yet when this would start pulling them.
+ workflow_dispatch:
+ inputs:
+ image_tag:
+ description: "Released tag to test, for example 2.2.0. Run it from
that same tag, since the skip list and the manifests come from the checked-out
ref. Leave empty to build and test the checked-out commit."
+ required: false
+ default: ""
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number ||
github.ref }}
@@ -35,13 +45,18 @@ permissions:
jobs:
conformance-test:
env:
- CONFORMANCE_TEST_REPORT_OUTPUT:
/tmp/apisix-ingress-controller-conformance-report.yaml
+ # The report name encodes the mode, so each provider writes its own file
+ # and the two can be submitted side by side. apisix is the controller's
+ # default provider, the standalone run declares a named mode.
+ CONFORMANCE_MODE: ${{ matrix.conformance_mode }}
timeout-minutes: 60
strategy:
matrix:
- provider_type:
- - apisix-standalone
- - apisix
+ include:
+ - provider_type: apisix-standalone
+ conformance_mode: apisix-standalone
+ - provider_type: apisix
+ conformance_mode: default
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -58,7 +73,11 @@ jobs:
run: |
go install sigs.k8s.io/[email protected]
+ # A release run must exercise the published images for that release,
+ # otherwise the report would describe a local build while claiming the
+ # release version. Every other run tests what was just built.
- name: Build images
+ if: ${{ !inputs.image_tag }}
env:
TAG: dev
ARCH: amd64
@@ -74,38 +93,91 @@ jobs:
- name: Install And Run Cloud Provider KIND
run: |
- go install sigs.k8s.io/[email protected]
- nohup cloud-provider-kind > /tmp/kind-loadbalancer.log 2>&1 &
+ make kind-lb
- name: Install Gateway API And CRDs
run: |
make install
- name: Loading Docker Image to Kind Cluster
+ if: ${{ !inputs.image_tag }}
run: |
make kind-load-adc-image
make kind-load-ingress-image
+ # A release run pulls the published images instead, and the report names
+ # the release rather than the commit.
+ - name: Resolve Released Images
+ if: ${{ inputs.image_tag }}
+ shell: bash
+ run: |
+ # The report names image_tag, but the skip list, the supported
feature
+ # list and the manifests come from the ref this runs on, so only a
run
+ # from that same tag is submittable. It stays a warning rather than
an
+ # error because running from another ref is how this path gets
+ # rehearsed against an already published release, and the workflow a
+ # tag run executes is the one inside that tag, which cannot be fixed
+ # afterwards without a new tag.
+ if [ "${{ github.ref_name }}" != "${{ inputs.image_tag }}" ]; then
+ echo "::warning::running from ${{ github.ref_name }} while
reporting ${{ inputs.image_tag }}, this report is a rehearsal and must not be
submitted"
+ fi
+ echo "CONFORMANCE_IMAGE_TAG=${{ inputs.image_tag }}" >> $GITHUB_ENV
+
- name: Run Conformance Test
+ id: conformance
shell: bash
continue-on-error: true
env:
PROVIDER_TYPE: ${{ matrix.provider_type }}
run: |
make conformance-test
-
+
- name: Show Conformance Report
+ if: always()
shell: bash
run: |
- cat ${CONFORMANCE_TEST_REPORT_OUTPUT}
+ cat ./*-report.yaml || echo "no report was produced"
+
+ # The suite runs most tests with t.Parallel, and those report their
result
+ # after the report has been written, so a parallel failure leaves the
+ # report at Failed: 0 while the suite exits non-zero. The step outcome is
+ # the only place such a failure shows, which is why the run above is
+ # continue-on-error and the verdict is reached here instead.
+ - name: Check Conformance Result
+ if: always()
+ shell: bash
+ env:
+ STRICT: ${{ inputs.image_tag != '' }}
+ RUN_OUTCOME: ${{ steps.conformance.outcome }}
+ run: |
+ problem=""
+ if [ "${RUN_OUTCOME}" != "success" ]; then
+ problem="the suite exited non-zero, see the failures above"
+ elif ! ls ./*-report.yaml >/dev/null 2>&1; then
+ problem="no report was produced"
+ elif grep -qE '^[[:space:]]+result: failure' ./*-report.yaml; then
+ problem="the report contains a failing profile"
+ fi
+ if [ -n "${problem}" ]; then
+ echo "::error::${problem}"
+ grep -nE '^[[:space:]]+result:' ./*-report.yaml 2>/dev/null || true
+ if [ "${STRICT}" = "true" ]; then
+ exit 1
+ fi
+ echo "::warning::not a release run, not failing the job"
+ fi
- name: Get Logs from apisix-ingress-controller
+ if: always()
shell: bash
run: |
kubectl logs -n apisix-conformance-test -l
app=apisix-ingress-controller
- name: Upload Gateway API Conformance Report
+ if: always()
uses: actions/upload-artifact@v4
with:
- name: apisix-ingress-controller-conformance-report-${{
matrix.provider_type }}.yaml
- path: ${{ env.CONFORMANCE_TEST_REPORT_OUTPUT }}
+ # The artifact keeps the upstream report file name, so a release run
+ # can be submitted straight from the download without renaming it.
+ name: conformance-report-${{ matrix.provider_type }}
+ path: ./*-report.yaml
diff --git a/.gitignore b/.gitignore
index 81f2edfe..67bc1f92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,4 +47,4 @@ go.work
dist
.tmp
apisix-ingress-controller
-apisix-ingress-controller-conformance-report.yaml
+/*-report.yaml
diff --git a/Makefile b/Makefile
index d5bd3637..d06e65a6 100644
--- a/Makefile
+++ b/Makefile
@@ -26,6 +26,8 @@ IMG ?= apache/apisix-ingress-controller:$(IMAGE_TAG)
# ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be
downloaded by envtest binary.
ENVTEST_K8S_VERSION = 1.30.0
KIND_NAME ?= apisix-ingress-cluster
+CLOUD_PROVIDER_KIND_VERSION ?= v0.6.0
+CLOUD_PROVIDER_KIND_PID ?= /tmp/cloud-provider-kind.pid
ADC_VERSION ?= 0.27.1
@@ -52,12 +54,43 @@ MIN_K8S_VERSION ?= 1.31.0
GO_LDFLAGS ?= "-X=$(VERSYM)=$(VERSION) -X=$(GITSHASYM)=$(GITSHA)
-X=$(BUILDOSSYM)=$(OSNAME)/$(OSARCH) -X=$(MINK8SVERSYM)=$(MIN_K8S_VERSION)"
# gateway-api
-GATEAY_API_VERSION ?= v1.6.0
+GATEWAY_API_VERSION ?= v1.6.0
##
https://github.com/kubernetes-sigs/gateway-api/blob/v1.6.0/pkg/features/httproute.go
SUPPORTED_EXTENDED_FEATURES =
"HTTPRouteDestinationPortMatching,HTTPRouteMethodMatching,HTTPRoutePortRedirect,HTTPRouteRequestMirror,HTTPRouteSchemeRedirect,GatewayAddressEmpty,HTTPRouteResponseHeaderModification,GatewayPort8080,HTTPRouteHostRewrite,HTTPRouteQueryParamMatching,HTTPRoutePathRewrite,HTTPRouteBackendProtocolWebSocket,TLSRouteModeTerminate"
-CONFORMANCE_TEST_REPORT_OUTPUT ?=
$(DIR)/apisix-ingress-controller-conformance-report.yaml
##
https://github.com/kubernetes-sigs/gateway-api/blob/v1.6.0/conformance/utils/suite/profiles.go
CONFORMANCE_PROFILES ?= GATEWAY-HTTP,GATEWAY-GRPC,GATEWAY-TLS
+# Report metadata, filled into the report's implementation block by the suite.
+#
https://github.com/kubernetes-sigs/gateway-api/blob/main/conformance/reports/README.md
+CONFORMANCE_ORGANIZATION ?= apache
+CONFORMANCE_PROJECT ?= apisix-ingress-controller
+CONFORMANCE_URL ?= https://github.com/apache/apisix-ingress-controller
+CONFORMANCE_CONTACT ?=
https://github.com/apache/apisix-ingress-controller/issues
+# The channel install-gateway-api installs from.
+CONFORMANCE_CHANNEL ?= experimental
+# A non-default mode must map to a specific setup and be documented in the
+# report's Reproduce section.
+CONFORMANCE_MODE ?= default
+# The data plane a release report is produced against. apisix:dev is a floating
+# tag, so a report meant to be reproducible has to name a released one.
+CONFORMANCE_DATAPLANE_VERSION ?= 3.17.0-debian
+# What the run deploys and what the report declares, following the checked-out
+# state: a release tag pulls the published images for that release, anything
+# else uses the dev images. Upstream rejects a floating name as the version, so
+# a dev run declares the commit instead.
+CONFORMANCE_IMAGE_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null
|| echo dev)
+override CONFORMANCE_IMAGE_TAG := $(or $(strip $(CONFORMANCE_IMAGE_TAG)),dev)
+ifeq ($(CONFORMANCE_IMAGE_TAG),dev)
+CONFORMANCE_VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo
unknown)
+CONFORMANCE_INGRESS_IMAGE ?= apache/apisix-ingress-controller:dev
+CONFORMANCE_ADC_IMAGE ?= ghcr.io/api7/adc:dev
+CONFORMANCE_DATAPLANE_IMAGE ?= apache/apisix:dev
+else
+CONFORMANCE_VERSION ?= $(CONFORMANCE_IMAGE_TAG)
+CONFORMANCE_INGRESS_IMAGE ?=
apache/apisix-ingress-controller:$(CONFORMANCE_IMAGE_TAG)
+CONFORMANCE_ADC_IMAGE ?= ghcr.io/api7/adc:$(ADC_VERSION)
+CONFORMANCE_DATAPLANE_IMAGE ?= apache/apisix:$(CONFORMANCE_DATAPLANE_VERSION)
+endif
+CONFORMANCE_TEST_REPORT_OUTPUT ?=
$(DIR)/$(CONFORMANCE_CHANNEL)-$(CONFORMANCE_VERSION)-$(CONFORMANCE_MODE)-report.yaml
TEST_EXCLUDES ?= /e2e /conformance /benchmark
TEST_PACKAGES = $(shell go list ./... $(foreach p,$(TEST_EXCLUDES),| grep -v
$(p)))
@@ -151,13 +184,32 @@ ginkgo-e2e-test: adc
install-ginkgo:
@go install github.com/onsi/ginkgo/v2/ginkgo@v$(GINKGO_VERSION)
+.PHONY: conformance-report-path
+conformance-report-path: ## Print the path conformance-test writes the report
to.
+ @echo $(CONFORMANCE_TEST_REPORT_OUTPUT)
+
.PHONY: conformance-test
+conformance-test: export INGRESS_IMAGE=$(CONFORMANCE_INGRESS_IMAGE)
+conformance-test: export ADC_IMAGE=$(CONFORMANCE_ADC_IMAGE)
+conformance-test: export DATAPLANE_IMAGE=$(CONFORMANCE_DATAPLANE_IMAGE)
conformance-test:
go test -v ./test/conformance -tags conformance,experimental -timeout
60m \
--supported-features=$(SUPPORTED_EXTENDED_FEATURES) \
--conformance-profiles=$(CONFORMANCE_PROFILES) \
+ --organization="$(CONFORMANCE_ORGANIZATION)" \
+ --project="$(CONFORMANCE_PROJECT)" \
+ --url="$(CONFORMANCE_URL)" \
+ --version="$(CONFORMANCE_VERSION)" \
+ --contact="$(CONFORMANCE_CONTACT)" \
+ --mode="$(CONFORMANCE_MODE)" \
--report-output=$(CONFORMANCE_TEST_REPORT_OUTPUT)
+.PHONY: conformance-images
+conformance-images: ## Print the images the conformance run deploys.
+ @echo $(CONFORMANCE_INGRESS_IMAGE)
+ @echo $(CONFORMANCE_ADC_IMAGE)
+ @echo $(CONFORMANCE_DATAPLANE_IMAGE)
+
.PHONY: benchmark-test
benchmark-test:
go test -v ./test/benchmark -test.timeout=$(TEST_TIMEOUT) -v -ginkgo.v
@@ -177,6 +229,17 @@ kind-up:
|| echo "kind cluster already exists"
kubectl wait --for=condition=Ready nodes --all
+.PHONY: kind-lb
+kind-lb: ## Run cloud-provider-kind so LoadBalancer Services in kind get an
address.
+ @if [ -f $(CLOUD_PROVIDER_KIND_PID) ] && kill -0 "$$(cat
$(CLOUD_PROVIDER_KIND_PID))" 2>/dev/null; then \
+ echo "cloud-provider-kind already running"; \
+ else \
+ go install
sigs.k8s.io/cloud-provider-kind@$(CLOUD_PROVIDER_KIND_VERSION); \
+ echo "starting cloud-provider-kind, logs in
/tmp/cloud-provider-kind.log"; \
+ nohup $(GOBIN)/cloud-provider-kind >
/tmp/cloud-provider-kind.log 2>&1 & \
+ echo $$! > $(CLOUD_PROVIDER_KIND_PID); \
+ fi
+
.PHONY: kind-down
kind-down:
@kind get clusters 2>&1 | grep $(KIND_NAME) \
@@ -296,11 +359,11 @@ endif
install-gateway-api: ## Install Gateway API CRDs into the K8s cluster
specified in ~/.kube/config.
# Server-side apply: the v1.6 CRDs exceed the 262144-byte annotation
limit of
# client-side apply (last-applied-configuration).
- kubectl apply --server-side --force-conflicts -f
https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEAY_API_VERSION)/experimental-install.yaml
+ kubectl apply --server-side --force-conflicts -f
https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEWAY_API_VERSION)/experimental-install.yaml
.PHONY: uninstall-gateway-api
uninstall-gateway-api: ## Uninstall Gateway API CRDs from the K8s cluster
specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource
not found errors during deletion.
- kubectl delete -f
https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEAY_API_VERSION)/experimental-install.yaml
+ kubectl delete -f
https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEWAY_API_VERSION)/experimental-install.yaml
.PHONY: install
install: manifests kustomize install-gateway-api install-crds ## Install CRDs
and Gateway API into the K8s cluster specified in ~/.kube/config.
@@ -410,7 +473,7 @@ endef
helm-build-crds:
@echo "build gateway-api standard crds"
- $(KUSTOMIZE) build
github.com/kubernetes-sigs/gateway-api/config/crd\?ref=${GATEAY_API_VERSION} >
charts/crds/gwapi-crds.yaml
+ $(KUSTOMIZE) build
github.com/kubernetes-sigs/gateway-api/config/crd\?ref=${GATEWAY_API_VERSION} >
charts/crds/gwapi-crds.yaml
@echo "build apisix ic crds"
$(KUSTOMIZE) build config/crd > charts/crds/apisixic-crds.yaml
diff --git a/internal/controller/gateway_controller.go
b/internal/controller/gateway_controller.go
index bdde4d3d..7430e1ca 100644
--- a/internal/controller/gateway_controller.go
+++ b/internal/controller/gateway_controller.go
@@ -165,10 +165,12 @@ func (r *GatewayReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (ct
r.Log.Info("gateway has been accepted", "gateway", gateway.GetName())
type conditionStatus struct {
status bool
+ reason gatewayv1.GatewayConditionReason
msg string
}
acceptStatus := conditionStatus{
status: true,
+ reason: gatewayv1.GatewayReasonAccepted,
msg: acceptedMessage("gateway"),
}
@@ -179,6 +181,7 @@ func (r *GatewayReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (ct
if err := r.processInfrastructure(tctx, gateway); err != nil {
acceptStatus = conditionStatus{
status: false,
+ reason: gatewayv1.GatewayReasonInvalidParameters,
msg: err.Error(),
}
}
@@ -189,8 +192,16 @@ func (r *GatewayReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (ct
gatewayProxy, ok := tctx.GatewayProxies[rk]
if !ok {
+ // InvalidParameters is only the right answer when the Gateway
actually
+ // names a parametersRef that cannot be resolved. A Gateway
that names
+ // none is simply not configured yet.
+ reason := gatewayv1.GatewayReasonPending
+ if gateway.Spec.Infrastructure != nil &&
gateway.Spec.Infrastructure.ParametersRef != nil {
+ reason = gatewayv1.GatewayReasonInvalidParameters
+ }
acceptStatus = conditionStatus{
status: false,
+ reason: reason,
msg: "gateway proxy not found",
}
} else {
@@ -223,11 +234,24 @@ func (r *GatewayReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (ct
if err := r.Provider.Update(ctx, tctx, gateway); err != nil {
acceptStatus = conditionStatus{
status: false,
+ reason: gatewayv1.GatewayReasonAccepted,
msg: err.Error(),
}
}
- accepted := SetGatewayConditionAccepted(gateway, acceptStatus.status,
acceptStatus.msg)
+ // A listener the Gateway cannot serve is the most specific thing to
report,
+ // so it wins over whatever else was found: the Gateway says
+ // ListenersNotValid, and the status separates a Gateway that still
serves
+ // some listeners from one that serves none.
+ if status, invalid := gatewayAcceptanceFromListeners(listenerStatuses);
invalid {
+ acceptStatus = conditionStatus{
+ status: status,
+ reason: gatewayv1.GatewayReasonListenersNotValid,
+ msg: "one or more listeners are not accepted",
+ }
+ }
+
+ accepted := SetGatewayConditionAccepted(gateway, acceptStatus.status,
acceptStatus.reason, acceptStatus.msg)
programmed := SetGatewayConditionProgrammed(gateway,
conditionProgrammedStatus, conditionProgrammedMsg)
addressesChanged := !reflect.DeepEqual(gateway.Status.Addresses, addrs)
if accepted || programmed || addressesChanged || len(listenerStatuses)
> 0 {
diff --git a/internal/controller/utils.go b/internal/controller/utils.go
index 87ad338a..85108fa7 100644
--- a/internal/controller/utils.go
+++ b/internal/controller/utils.go
@@ -158,11 +158,11 @@ func IsConditionPresentAndEqual(conditions
[]metav1.Condition, condition metav1.
return false
}
-func SetGatewayConditionAccepted(gw *gatewayv1.Gateway, status bool, message
string) (ok bool) {
+func SetGatewayConditionAccepted(gw *gatewayv1.Gateway, status bool, reason
gatewayv1.GatewayConditionReason, message string) (ok bool) {
condition := metav1.Condition{
Type: string(gatewayv1.GatewayConditionAccepted),
Status: ConditionStatus(status),
- Reason: string(gatewayv1.GatewayReasonAccepted),
+ Reason: string(reason),
ObservedGeneration: gw.GetGeneration(),
Message: message,
LastTransitionTime: metav1.Now(),
@@ -589,6 +589,38 @@ func routeKindsForProtocol(protocol
gatewayv1.ProtocolType) []gatewayv1.RouteGro
return []gatewayv1.RouteGroupKind{}
}
+// isSupportedProtocol reports whether a listener protocol is one this
+// implementation serves. routeKindsForProtocol returning nothing is the same
+// question asked the other way round.
+func isSupportedProtocol(protocol gatewayv1.ProtocolType) bool {
+ return len(routeKindsForProtocol(protocol)) > 0
+}
+
+// gatewayAcceptanceFromListeners derives the Gateway's Accepted condition from
+// its listeners. Gateway API asks for ListenersNotValid as soon as one
listener
+// is not accepted, with the status separating "some listeners work" from "none
+// do". invalid is false when every listener is accepted, leaving the caller's
+// own verdict alone.
+func gatewayAcceptanceFromListeners(listeners []gatewayv1.ListenerStatus)
(status bool, invalid bool) {
+ var accepted, rejected int
+ for _, listener := range listeners {
+ for _, condition := range listener.Conditions {
+ if condition.Type !=
string(gatewayv1.ListenerConditionAccepted) {
+ continue
+ }
+ if condition.Status == metav1.ConditionTrue {
+ accepted++
+ } else {
+ rejected++
+ }
+ }
+ }
+ if rejected == 0 {
+ return true, false
+ }
+ return accepted > 0, true
+}
+
func SetApisixCRDConditionAccepted(status *apiv2.ApisixStatus, generation
int64, err error) {
var condition = metav1.Condition{
Type: string(apiv2.ConditionTypeAccepted),
@@ -976,6 +1008,29 @@ func getListenerStatus(
supportedKinds = []gatewayv1.RouteGroupKind{}
)
+ // A protocol this implementation does not serve is rejected
outright:
+ // accepting it would advertise a listener that can never carry
traffic.
+ if !isSupportedProtocol(listener.Protocol) {
+ conditionAccepted.Status = metav1.ConditionFalse
+ conditionAccepted.Reason =
string(gatewayv1.ListenerReasonUnsupportedProtocol)
+ conditionAccepted.Message = fmt.Sprintf("protocol %q is
not supported", listener.Protocol)
+ conditionProgrammed.Status = metav1.ConditionFalse
+ conditionProgrammed.Reason =
string(gatewayv1.ListenerReasonInvalid)
+
+ statusArray = append(statusArray,
reuseUnchangedListenerStatus(gateway, i, gatewayv1.ListenerStatus{
+ Name: listener.Name,
+ Conditions: []metav1.Condition{
+ conditionProgrammed,
+ conditionAccepted,
+ conditionConflicted,
+ conditionResolvedRefs,
+ },
+ SupportedKinds: supportedKinds,
+ AttachedRoutes: attachedRoutes,
+ }))
+ continue
+ }
+
// A port serving more than one TLS mode cannot be programmed,
so the
// listener is rejected rather than accepted with undefined
behaviour.
if listener.Protocol == gatewayv1.TLSProtocolType &&
tlsModeConflictPorts[listener.Port] {
diff --git a/internal/controller/utils_gatewaystatus_test.go
b/internal/controller/utils_gatewaystatus_test.go
new file mode 100644
index 00000000..18775957
--- /dev/null
+++ b/internal/controller/utils_gatewaystatus_test.go
@@ -0,0 +1,171 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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 controller
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+
"github.com/apache/apisix-ingress-controller/internal/controller/indexer"
+)
+
+// A listener whose protocol the implementation does not serve must say so with
+// UnsupportedProtocol and advertise no route kinds, rather than be accepted
and
+// then quietly serve nothing.
+func TestGetListenerStatus_UnsupportedProtocol(t *testing.T) {
+ scheme := parentRefTestScheme(t)
+
+ for _, tc := range []struct {
+ name string
+ listener gatewayv1.Listener
+ wantStatus metav1.ConditionStatus
+ wantReason gatewayv1.ListenerConditionReason
+ wantKinds int
+ }{
+ {
+ name: "unknown protocol",
+ listener: gatewayv1.Listener{
+ Name: "invalid", Port: 1111, Protocol:
gatewayv1.ProtocolType("INVALID"),
+ },
+ wantStatus: metav1.ConditionFalse,
+ wantReason: gatewayv1.ListenerReasonUnsupportedProtocol,
+ wantKinds: 0,
+ },
+ {
+ name: "known protocol",
+ listener: gatewayv1.Listener{
+ Name: "http", Port: 80, Protocol:
gatewayv1.HTTPProtocolType,
+ },
+ wantStatus: metav1.ConditionTrue,
+ wantReason: gatewayv1.ListenerReasonAccepted,
+ wantKinds: 2,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ gw := &gatewayv1.Gateway{
+ ObjectMeta: metav1.ObjectMeta{Namespace:
"default", Name: "gw"},
+ Spec: gatewayv1.GatewaySpec{
+ GatewayClassName: "apisix",
+ Listeners:
[]gatewayv1.Listener{tc.listener},
+ },
+ }
+ cli := fake.NewClientBuilder().WithScheme(scheme).
+ WithObjects(newParentRefGatewayClass(), gw).
+ // An HTTP listener counts both route kinds it
can serve, so both
+ // indexes have to exist even though no route
does.
+ WithIndex(&gatewayv1.HTTPRoute{},
indexer.ParentRefs,
+ func(client.Object) []string { return
nil }).
+ WithIndex(&gatewayv1.GRPCRoute{},
indexer.ParentRefs,
+ func(client.Object) []string { return
nil }).
+ Build()
+
+ statuses, err :=
getListenerStatus(context.Background(), cli, gw)
+ require.NoError(t, err)
+ require.Len(t, statuses, 1)
+
+ var accepted *metav1.Condition
+ for i := range statuses[0].Conditions {
+ if statuses[0].Conditions[i].Type ==
string(gatewayv1.ListenerConditionAccepted) {
+ accepted = &statuses[0].Conditions[i]
+ }
+ }
+ require.NotNil(t, accepted, "listener must report an
Accepted condition")
+ assert.Equal(t, tc.wantStatus, accepted.Status)
+ assert.Equal(t, string(tc.wantReason), accepted.Reason)
+ assert.Len(t, statuses[0].SupportedKinds, tc.wantKinds)
+ assert.Zero(t, statuses[0].AttachedRoutes)
+ })
+ }
+}
+
+// The Gateway's own Accepted condition follows its listeners: one bad listener
+// is enough for ListenersNotValid, and the status separates "some listeners
+// work" from "none do".
+func TestGatewayAcceptanceFromListeners(t *testing.T) {
+ listener := func(name string, accepted bool) gatewayv1.ListenerStatus {
+ status := metav1.ConditionTrue
+ if !accepted {
+ status = metav1.ConditionFalse
+ }
+ return gatewayv1.ListenerStatus{
+ Name: gatewayv1.SectionName(name),
+ Conditions: []metav1.Condition{{
+ Type:
string(gatewayv1.ListenerConditionAccepted),
+ Status: status,
+ }},
+ }
+ }
+
+ for _, tc := range []struct {
+ name string
+ listeners []gatewayv1.ListenerStatus
+ wantInvalid bool
+ wantStatus bool
+ }{
+ {
+ name: "all listeners accepted",
+ listeners:
[]gatewayv1.ListenerStatus{listener("http", true), listener("https", true)},
+ wantInvalid: false,
+ },
+ {
+ name: "one of two listeners rejected",
+ listeners:
[]gatewayv1.ListenerStatus{listener("http", true), listener("invalid", false)},
+ wantInvalid: true,
+ wantStatus: true,
+ },
+ {
+ name: "every listener rejected",
+ listeners:
[]gatewayv1.ListenerStatus{listener("invalid", false)},
+ wantInvalid: true,
+ wantStatus: false,
+ },
+ {
+ name: "no listeners",
+ listeners: nil,
+ wantInvalid: false,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ status, invalid :=
gatewayAcceptanceFromListeners(tc.listeners)
+ assert.Equal(t, tc.wantInvalid, invalid)
+ if invalid {
+ assert.Equal(t, tc.wantStatus, status)
+ }
+ })
+ }
+}
+
+// Accepted=False used to report Reason=Accepted, which says nothing about what
+// went wrong.
+func TestSetGatewayConditionAccepted_Reason(t *testing.T) {
+ gw := &gatewayv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace:
"default", Name: "gw"}}
+
+ SetGatewayConditionAccepted(gw, false,
gatewayv1.GatewayReasonInvalidParameters, "parametersRef is not resolvable")
+
+ require.Len(t, gw.Status.Conditions, 1)
+ assert.Equal(t, string(gatewayv1.GatewayConditionAccepted),
gw.Status.Conditions[0].Type)
+ assert.Equal(t, metav1.ConditionFalse, gw.Status.Conditions[0].Status)
+ assert.Equal(t, string(gatewayv1.GatewayReasonInvalidParameters),
gw.Status.Conditions[0].Reason)
+}
diff --git a/test/conformance/README.md b/test/conformance/README.md
new file mode 100644
index 00000000..c4ffbdcf
--- /dev/null
+++ b/test/conformance/README.md
@@ -0,0 +1,116 @@
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+-->
+
+# Gateway API conformance
+
+This directory holds the [Gateway API
conformance](https://gateway-api.sigs.k8s.io/docs/concepts/conformance/)
+suite configuration. Running it produces a report that can be submitted to the
+[Gateway API conformance
reports](https://github.com/kubernetes-sigs/gateway-api/tree/main/conformance/reports)
+repository, which requires the run to be reproducible by a third party.
+
+## Prerequisites
+
+The following binaries are assumed to be installed:
+
+- [docker](https://docs.docker.com/get-started/get-docker/)
+- [kubectl](https://kubernetes.io/docs/tasks/tools/)
+- [kind](https://github.com/kubernetes-sigs/kind)
+- [go](https://go.dev/learn/)
+
+Tested on Linux. Any cluster works as long as it supports LoadBalancer
+Services: the suite reaches the gateway through the data plane Service's
+external address, and the controller publishes that address in every Gateway's
+`status.addresses`. Steps 2 and 3 below only exist to give a local kind cluster
+that capability.
+
+## Reproduce
+
+1. Clone the repository and check out the release to reproduce
+
+ ```shell
+ git clone https://github.com/apache/apisix-ingress-controller.git && cd
apisix-ingress-controller
+ git checkout <release>
+ ```
+
+ Checking out the release tag is what selects the published images for it and
+ makes the report name that release, so the steps below are available from
the
+ first release that carries them. `make conformance-images` prints the three
+ images the run will deploy; do not run `make build-image` here, it would
+ replace a published image locally with a build of your own.
+
+2. Create the cluster
+
+ ```shell
+ make kind-up
+ ```
+
+3. Run a local LoadBalancer provider
+
+ ```shell
+ make kind-lb
+ ```
+
+ This runs
[cloud-provider-kind](https://kind.sigs.k8s.io/docs/user/loadbalancer)
+ in the background, logging to `/tmp/cloud-provider-kind.log`. It needs
access
+ to the Docker socket. Skip this step on a cluster that already has
+ LoadBalancer support.
+
+4. Install the Gateway API and the controller's CRDs
+
+ ```shell
+ make install
+ ```
+
+ On a commit that is not a release, the run uses the `dev` images and the
+ report names the commit. Build and load them into the cluster first:
+
+ ```shell
+ make build-image && make kind-load-images
+ ```
+
+5. Run the suite
+
+ ```shell
+ make conformance-test
+ ```
+
+ For the standalone data plane mode, run it with the provider type and the
+ matching report mode:
+
+ ```shell
+ PROVIDER_TYPE=apisix-standalone make conformance-test
CONFORMANCE_MODE=apisix-standalone
+ ```
+
+6. Read the report
+
+ ```shell
+ cat "$(make -s conformance-report-path)"
+ ```
+
+ Pass the same `CONFORMANCE_MODE` to get the path of a standalone run.
+
+ The file is named `<channel>-<version>-<mode>-report.yaml`, which is the
name
+ the upstream repository expects, so it can be submitted as produced. Reports
+ must be uploaded unmodified.
+
+ A run that fails before writing the report leaves the previous one in place,
+ so delete `*-report.yaml` before rerunning. Note also that most of the suite
+ runs in parallel and those tests report after the report is written, so a
+ report can read `Failed: 0` while `make conformance-test` exited non-zero;
+ the exit status is what to trust.
diff --git a/test/conformance/conformance_test.go
b/test/conformance/conformance_test.go
index ecdca90d..12f483b6 100644
--- a/test/conformance/conformance_test.go
+++ b/test/conformance/conformance_test.go
@@ -21,7 +21,6 @@ import (
"testing"
"sigs.k8s.io/gateway-api/conformance"
- conformancev1 "sigs.k8s.io/gateway-api/conformance/apis/v1"
"sigs.k8s.io/gateway-api/conformance/tests"
)
@@ -73,13 +72,8 @@ func TestGatewayAPIConformance(t *testing.T) {
opts.SkipTests = append(opts.SkipTests, skippedTestsForSSL...)
opts.SkipTests = append(opts.SkipTests,
skippedTestsForTLSPassthrough...)
opts.SkipTests = append(opts.SkipTests, skippedTestsForKnownGaps...)
- opts.Implementation = conformancev1.Implementation{
- Organization: "APISIX",
- Project: "apisix-ingress-controller",
- URL:
"https://github.com/apache/apisix-ingress-controller.git",
- Version: "v2.1.0",
- Contact:
[]string{"https://github.com/apache/apisix-ingress-controller/issues"},
- }
+ // Implementation is left to the flags DefaultOptions already applied.
+ // Assigning it here would override them and pin the report to a stale
version.
conformance.RunConformanceWithOptions(t, opts)
}
diff --git a/test/e2e/framework/apisix_consts.go
b/test/e2e/framework/apisix_consts.go
index 0d1d18c3..1005cb40 100644
--- a/test/e2e/framework/apisix_consts.go
+++ b/test/e2e/framework/apisix_consts.go
@@ -29,6 +29,10 @@ import (
var (
ProviderType = cmp.Or(os.Getenv("PROVIDER_TYPE"), "apisix")
DisableGatewayAPI = os.Getenv("DISABLE_GATEWAY_API") == "true"
+
+ IngressImage = cmp.Or(os.Getenv("INGRESS_IMAGE"),
"apache/apisix-ingress-controller:dev")
+ ADCImage = cmp.Or(os.Getenv("ADC_IMAGE"), "ghcr.io/api7/adc:dev")
+ DataplaneImage = cmp.Or(os.Getenv("DATAPLANE_IMAGE"),
"apache/apisix:dev")
)
const (
diff --git a/test/e2e/framework/ingress.go b/test/e2e/framework/ingress.go
index 3faaa48d..79ffb578 100644
--- a/test/e2e/framework/ingress.go
+++ b/test/e2e/framework/ingress.go
@@ -58,6 +58,11 @@ type IngressDeployOpts struct {
ListenerPortMatchMode string
}
+// Methods rather than fields, so a caller that executes the template directly
+// cannot end up rendering an empty image.
+func (IngressDeployOpts) ControllerImage() string { return IngressImage }
+func (IngressDeployOpts) ADCImage() string { return ADCImage }
+
func (f *Framework) DeployIngress(opts IngressDeployOpts) {
buf := bytes.NewBuffer(nil)
diff --git a/test/e2e/framework/manifests/apisix.yaml
b/test/e2e/framework/manifests/apisix.yaml
index 90f8845a..ad8be66f 100644
--- a/test/e2e/framework/manifests/apisix.yaml
+++ b/test/e2e/framework/manifests/apisix.yaml
@@ -84,7 +84,7 @@ spec:
spec:
initContainers:
- name: config-setup
- image: apache/apisix:dev
+ image: {{ .Image }}
command:
- sh
- -c
@@ -102,7 +102,7 @@ spec:
mountPath: /tmp/apisix-conf
containers:
- name: apisix
- image: apache/apisix:dev
+ image: {{ .Image }}
ports:
- name: http
containerPort: 9080
diff --git a/test/e2e/framework/manifests/ingress.yaml
b/test/e2e/framework/manifests/ingress.yaml
index a24fcfd3..022bb159 100644
--- a/test/e2e/framework/manifests/ingress.yaml
+++ b/test/e2e/framework/manifests/ingress.yaml
@@ -357,7 +357,7 @@ spec:
securityContext:
fsGroup: 2000
containers:
- - image: apache/apisix-ingress-controller:dev
+ - image: {{ .ControllerImage }}
env:
- name: POD_NAMESPACE
valueFrom:
@@ -400,7 +400,7 @@ spec:
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
- - image: ghcr.io/api7/adc:dev
+ - image: {{ .ADCImage }}
env:
- name: ADC_RUNNING_MODE
value: ingress
diff --git a/test/e2e/scaffold/apisix_deployer.go
b/test/e2e/scaffold/apisix_deployer.go
index 19f30f36..58f57074 100644
--- a/test/e2e/scaffold/apisix_deployer.go
+++ b/test/e2e/scaffold/apisix_deployer.go
@@ -48,6 +48,9 @@ type APISIXDeployOptions struct {
Replicas *int
}
+// See IngressDeployOpts.ControllerImage.
+func (APISIXDeployOptions) Image() string { return framework.DataplaneImage }
+
type APISIXDeployer struct {
*Scaffold
adminTunnel *k8s.Tunnel