diff --git a/.github/workflows/check-gitleaks.yml b/.github/workflows/check-gitleaks.yml index f5e89efa..53aa89b9 100644 --- a/.github/workflows/check-gitleaks.yml +++ b/.github/workflows/check-gitleaks.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 + - uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE}} # Only required for Organizations, not personal accounts. diff --git a/.github/workflows/check-govulncheck.yml b/.github/workflows/check-govulncheck.yml index dd381851..3f68ca81 100644 --- a/.github/workflows/check-govulncheck.yml +++ b/.github/workflows/check-govulncheck.yml @@ -9,11 +9,18 @@ jobs: if: contains(github.event.head_commit.message, '[build]') steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Scan for Vulnerabilities in Code - # Pinned to master HEAD: latest v1.0.4 tag transitively pulls actions/checkout@v4.1.1 - # and actions/setup-go@v5.0.0 (Node 20). master HEAD pins them to v6.0.2 / v6.2.0 (Node 24). - # Switch back to a tagged release once upstream cuts v1.0.5+. - uses: golang/govulncheck-action@31f7c5463448f83528bd771c2d978d940080c9fd # master @ 2026-02-26 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version-input: 1.25.10 - go-package: ./... + go-version: 1.25.11 + check-latest: true + - name: Scan for Vulnerabilities in Code + # NB: govulncheck is pinned to v1.3.0 (NOT @latest). v1.4.0 links + # golang.org/x/tools@v0.46.0, whose SSA call-graph builder panics on + # generic code under Go 1.25 ("ForEachElement called on type containing + # *types.TypeParam"), crashing the default -scan symbol mode. v1.3.0 + # links x/tools@v0.44.0 and scans cleanly. The golang/govulncheck-action + # only installs @latest, so we invoke govulncheck directly instead. + # Revisit once upstream ships a fixed x/tools (then bump or drop the pin). + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.3.0 + govulncheck -C . -format text ./... diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 96bd7f05..8077e477 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -20,14 +20,14 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Log in to the GHCR - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to the vCR - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: vcr.vngcloud.vn username: ${{ secrets.VCR_USER }} @@ -45,7 +45,7 @@ jobs: echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV - name: Build and push Docker image - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . push: true diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 2fa406af..d08b9f3d 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -23,21 +23,21 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Log in to the GHCR - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to the vCR HCM - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: vcr.vngcloud.vn username: ${{ secrets.VCR_USER_PRO }} password: ${{ secrets.VCR_PASSWORD_PRO }} - name: Log in to the vCR HAN - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: vcr-han.vngcloud.vn username: ${{ secrets.VCR_USER_HAN_PRO }} @@ -53,7 +53,7 @@ jobs: run: echo ${{ github.ref_name }} - name: Build and push Docker image - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . push: true diff --git a/.golangci.yml b/.golangci.yml index e3135660..a10f3d09 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -33,6 +33,9 @@ linters: linters: - dupl - lll + - path: "_test\\.go" + linters: + - unparam settings: revive: rules: diff --git a/Makefile b/Makefile index c6b74a80..9430346e 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,7 @@ manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and Cust .PHONY: sync-embedded-crds sync-embedded-crds: ## Copy generated vks.vngcloud.vn CRDs into the binary-embedded location (pkg/k8s/apis/.../crds). cp config/crd/bases/vks.vngcloud.vn_*.yaml pkg/k8s/apis/vks.vngcloud.vn/crds/ + cp config/crd/bases/gateway.vks.vngcloud.vn_*.yaml pkg/k8s/apis/vks.vngcloud.vn/crds/ 2>/dev/null || true .PHONY: generate generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. @@ -76,6 +77,12 @@ test: manifests generate fmt vet envtest ## Run tests. test-e2e: go test ./test/e2e/ -v -ginkgo.v +.PHONY: test-e2e-gateway # Run Gateway-API e2e against a REAL vngcloud cluster (opt-in). +# Requires: a real cluster reachable via KUBECONFIG, with the controller already +# deployed and --enable-gateway-api-alb set. Provisions and tears down real ALBs. +test-e2e-gateway: + RUN_GATEWAY_E2E=true go test ./test/e2e/gateway/ -v -ginkgo.v -timeout 20m + .PHONY: lint lint: golangci-lint ## Run golangci-lint linter $(GOLANGCI_LINT) run @@ -112,7 +119,8 @@ build-pro: .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. go run ./cmd/main.go --log-level=debug \ - --metrics-bind-address=:8080 --metrics-secure=false + --metrics-bind-address=:8080 --metrics-secure=false \ + --disable-alb-gateway-controller=false --disable-nlb-gateway-controller=false # --disable-service-controller \ # --disable-load-balancer-config-controller \ # --disable-ingress-controller \ diff --git a/README.md b/README.md index cb71ba27..3e6e2cc8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Application Load Balancers automatically. - **L4 Load Balancing** — Network Load Balancers for `Service` resources of type `LoadBalancer`. - **L7 Load Balancing** — Application Load Balancers driven by Kubernetes `Ingress` resources. +- **Gateway API** — Application Load Balancers driven by `Gateway`/`HTTPRoute`, and Network Load Balancers driven by `TCPRoute`/`UDPRoute`, customised via GEP-713 policy CRDs. - **`LoadBalancerConfig` CRD** — Fine-grained control over listeners, pools, policies, and TLS certificates. - **`NodeSecurityGroup` CRD** — Declarative management of node security-group rules. - **`GlobalLoadBalancerConfig` / `VngcloudGlobalLoadBalancer` CRDs** — Multi-region traffic distribution. @@ -143,7 +144,7 @@ spec: More guides: -- [Service (L4)](docs/guide/service.md) · [Ingress (L7)](docs/guide/ingress.md) +- [Service (L4)](docs/guide/service.md) · [Ingress (L7)](docs/guide/ingress.md) · [Gateway API (ALB)](docs/guide/gateway-api.md) · [Gateway API (NLB)](docs/guide/gateway-nlb.md) - [`LoadBalancerConfig` CRD](docs/guide/load-balancer-config.md) - [`NodeSecurityGroup` CRD](docs/guide/node-security-group.md) - [Global Load Balancer](docs/guide/global-load-balancer.md) @@ -157,7 +158,9 @@ sources live under [`docs/`](docs/). ## Roadmap -- [ ] Gateway API support (design phase) +- [x] Gateway API support — Phase 1: ALB (`Gateway` + `HTTPRoute` + policy CRDs) +- [x] Gateway API Phase 2: NLB (`TCPRoute`/`UDPRoute`) +- [ ] Gateway API Phase 3+: `TLSRoute`, `GRPCRoute`, conformance - [ ] End-to-end test suite - [ ] Validating / mutating webhooks - [ ] Out-of-band drift reconciliation (detect external load-balancer changes) diff --git a/api/gateway/v1alpha1/doc.go b/api/gateway/v1alpha1/doc.go new file mode 100644 index 00000000..b338f056 --- /dev/null +++ b/api/gateway/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// Package v1alpha1 contains API Schema definitions for the gateway.vks.vngcloud.vn v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=gateway.vks.vngcloud.vn +package v1alpha1 diff --git a/api/gateway/v1alpha1/groupversion_info.go b/api/gateway/v1alpha1/groupversion_info.go new file mode 100644 index 00000000..2465dd65 --- /dev/null +++ b/api/gateway/v1alpha1/groupversion_info.go @@ -0,0 +1,12 @@ +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + GroupVersion = schema.GroupVersion{Group: "gateway.vks.vngcloud.vn", Version: "v1alpha1"} + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/gateway/v1alpha1/shared_types.go b/api/gateway/v1alpha1/shared_types.go new file mode 100644 index 00000000..fa235642 --- /dev/null +++ b/api/gateway/v1alpha1/shared_types.go @@ -0,0 +1,46 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +// LocalPolicyTargetReference is re-exported for convenience. +// +kubebuilder:object:generate=false +type LocalPolicyTargetReference = gwv1alpha2.LocalPolicyTargetReference + +// LocalPolicyTargetReferenceWithSectionName is re-exported for convenience. +// +kubebuilder:object:generate=false +type LocalPolicyTargetReferenceWithSectionName = gwv1alpha2.LocalPolicyTargetReferenceWithSectionName + +// PolicyAncestorStatus is re-exported for convenience. +// +kubebuilder:object:generate=false +type PolicyAncestorStatus = gwv1alpha2.PolicyAncestorStatus + +// CommonPolicyStatus is the embedded ancestor-status block carried by every VKS policy CRD. +type CommonPolicyStatus struct { + // Ancestors records reconcile status per controller acting on this policy. + // +listType=atomic + // +kubebuilder:validation:MaxItems=16 + Ancestors []PolicyAncestorStatus `json:"ancestors,omitempty"` +} + +// PolicyConditionType collects the standard reasons used across all four VKS policy CRDs. +const ( + PolicyConditionAccepted = "Accepted" + PolicyConditionProgrammed = "Programmed" + + PolicyReasonAccepted = "Accepted" + PolicyReasonConflicted = "Conflicted" + PolicyReasonInvalid = "Invalid" + PolicyReasonTargetNotFound = "TargetNotFound" + PolicyReasonNoReadyController = "NoReadyController" + PolicyReasonProgrammed = "Programmed" + PolicyReasonPending = "Pending" +) + +// CommonStatus is the common subset embedded into each policy's Status. +type CommonStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} diff --git a/api/gateway/v1alpha1/status_accessors.go b/api/gateway/v1alpha1/status_accessors.go new file mode 100644 index 00000000..6be768dd --- /dev/null +++ b/api/gateway/v1alpha1/status_accessors.go @@ -0,0 +1,25 @@ +package v1alpha1 + +// GetCommonStatus / GetCommonPolicyStatus expose the embedded status blocks so +// a generic Direct-policy validator can read and write Conditions + Ancestors +// uniformly across the four policy CRDs. + +func (p *VKSGatewayPolicy) GetCommonStatus() *CommonStatus { return &p.Status.CommonStatus } +func (p *VKSGatewayPolicy) GetCommonPolicyStatus() *CommonPolicyStatus { + return &p.Status.CommonPolicyStatus +} + +func (p *VKSBackendPolicy) GetCommonStatus() *CommonStatus { return &p.Status.CommonStatus } +func (p *VKSBackendPolicy) GetCommonPolicyStatus() *CommonPolicyStatus { + return &p.Status.CommonPolicyStatus +} + +func (p *VKSHealthCheckPolicy) GetCommonStatus() *CommonStatus { return &p.Status.CommonStatus } +func (p *VKSHealthCheckPolicy) GetCommonPolicyStatus() *CommonPolicyStatus { + return &p.Status.CommonPolicyStatus +} + +func (p *VKSRoutePolicy) GetCommonStatus() *CommonStatus { return &p.Status.CommonStatus } +func (p *VKSRoutePolicy) GetCommonPolicyStatus() *CommonPolicyStatus { + return &p.Status.CommonPolicyStatus +} diff --git a/api/gateway/v1alpha1/vksbackendpolicy_types.go b/api/gateway/v1alpha1/vksbackendpolicy_types.go new file mode 100644 index 00000000..fbd4e04a --- /dev/null +++ b/api/gateway/v1alpha1/vksbackendpolicy_types.go @@ -0,0 +1,88 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=vksbpolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.spec.targetRefs[*].name` +// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=="Accepted")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type VKSBackendPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VKSBackendPolicySpec `json:"spec,omitempty"` + Status VKSBackendPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type VKSBackendPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VKSBackendPolicy `json:"items"` +} + +type VKSBackendPolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []LocalPolicyTargetReference `json:"targetRefs"` + + // TargetType selects which addresses the controller resolves into pool + // members. "ip" puts pod IPs in the pool (works on flat networks / + // Cilium native routing). "instance" puts node IPs + nodePort in the + // pool (works on overlay networks where pod IPs aren't routable from + // the cloud LB). Default is auto-detected from the cluster CNI — same + // rule the Ingress controller uses today (Cilium overlay → instance, + // native routing → ip). + // + // This is a controller-side translation toggle, not a vngcloud LB API + // field; the resulting member IPs are what gets stored on the cloud + // pool. See design spec §1.6. + // + // +kubebuilder:validation:Enum=instance;ip + TargetType *string `json:"targetType,omitempty"` + + // +kubebuilder:validation:Enum=ROUND_ROBIN;LEAST_CONNECTIONS;SOURCE_IP + PoolAlgorithm *string `json:"poolAlgorithm,omitempty"` + + // Stickiness enables sticky sessions on the pool. The vngcloud LB API + // exposes only an on/off flag; cookie name and TTL are not configurable. + Stickiness *bool `json:"stickiness,omitempty"` + + EnableTLSEncryption *bool `json:"enableTLSEncryption,omitempty"` + TargetNodeLabels map[string]string `json:"targetNodeLabels,omitempty"` + ManageDFPMembers *bool `json:"manageDFPMembers,omitempty"` + + // ProxyProtocol makes the cloud LB pool speak HAProxy PROXY protocol to its + // members, so an L4 (NLB) backend such as an HAProxy/nginx ingress + // controller can recover the real client IP behind the load balancer. + // Honored only on the NLB (L4) path and only for TCP pools (UDP is + // ignored). Mirrors the Service controller's + // `vks.vngcloud.vn/enable-proxy-protocol` annotation. + ProxyProtocol *bool `json:"proxyProtocol,omitempty"` +} + +type VKSBackendPolicyStatus struct { + CommonStatus `json:",inline"` + CommonPolicyStatus `json:",inline"` +} + +// Matches reports whether this policy targets the given PolicyTarget. +func (p *VKSBackendPolicy) Matches(t pkggw.PolicyTarget) bool { + for _, ref := range p.Spec.TargetRefs { + if pkggw.TargetRefMatches(ref, p.Namespace, t) { + return true + } + } + return false +} + +func init() { + SchemeBuilder.Register(&VKSBackendPolicy{}, &VKSBackendPolicyList{}) +} diff --git a/api/gateway/v1alpha1/vksgatewaypolicy_types.go b/api/gateway/v1alpha1/vksgatewaypolicy_types.go new file mode 100644 index 00000000..ee394e65 --- /dev/null +++ b/api/gateway/v1alpha1/vksgatewaypolicy_types.go @@ -0,0 +1,107 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=vksgwpolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.spec.targetRefs[*].name` +// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=="Accepted")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type VKSGatewayPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VKSGatewayPolicySpec `json:"spec,omitempty"` + Status VKSGatewayPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type VKSGatewayPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VKSGatewayPolicy `json:"items"` +} + +type VKSGatewayPolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []LocalPolicyTargetReferenceWithSectionName `json:"targetRefs"` + + AllowedCIDRs []string `json:"allowedCidrs,omitempty"` + InsertHeaders map[string]string `json:"insertHeaders,omitempty"` + TimeoutClient *metav1.Duration `json:"timeoutClient,omitempty"` + TimeoutMember *metav1.Duration `json:"timeoutMember,omitempty"` + TimeoutConnection *metav1.Duration `json:"timeoutConnection,omitempty"` + + CertificateIDs []string `json:"certificateIds,omitempty"` + ClientCertificateID *string `json:"clientCertificateId,omitempty"` + + LoadBalancerSpec *VKSLoadBalancerSpec `json:"loadBalancerSpec,omitempty"` +} + +type VKSLoadBalancerSpec struct { + // +kubebuilder:validation:Enum=Internet;Internal;InterVPC + Scheme *string `json:"scheme,omitempty"` + PackageID *string `json:"packageId,omitempty"` + SubnetID *string `json:"subnetId,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + LoadBalancerID *string `json:"loadBalancerId,omitempty"` + + // LoadBalancerName sets a custom name for the cloud LB at creation time. + // Maps to LBC.Spec.LoadBalancerName — same as the Ingress + // "load-balancer-name" annotation. Written only when the LBC is first + // created (the cloud name is then mirrored back into Status); unset = a + // controller-generated default name. + LoadBalancerName *string `json:"loadBalancerName,omitempty"` + + // PreferZoneID pins the LB to a specific availability zone. The controller + // resolves a subnet in that zone from a cluster node. Maps to the same + // zone/subnet selection the Ingress "prefer-zone-id" annotation drives. + // Lower precedence than LoadBalancerID (adoption) and SubnetID. + PreferZoneID *string `json:"preferZoneId,omitempty"` + + // PrivateSubnetID overrides the LB's private subnet (used by the + // InterVPC scheme). Maps to LBC.Spec.PrivateSubnetId — same field the + // Ingress controller's "private-subnet-id" annotation writes. + PrivateSubnetID *string `json:"privateSubnetId,omitempty"` + + // PrivateZoneID is the zone of the client subnet, required by the + // InterVPC scheme since the client subnet lives in a different VPC. + // Maps to LBC.Spec.PrivateZoneId — same as the Service controller's + // "private-zone-id" annotation. + PrivateZoneID *string `json:"privateZoneId,omitempty"` + + // EnableAutoscale toggles cloud-side autoscaling on the LB. Maps to + // LBC.Spec.EnableAutoscale — same as the Ingress "enable-autoscale" + // annotation. Unset = cloud default (off). + EnableAutoscale *bool `json:"enableAutoscale,omitempty"` + + // IsPOC marks the LB as a POC tier. Maps to LBC.Spec.IsPoc — same as + // the Ingress "is-poc" annotation. Unset = production tier. + IsPOC *bool `json:"isPOC,omitempty"` +} + +type VKSGatewayPolicyStatus struct { + CommonStatus `json:",inline"` + CommonPolicyStatus `json:",inline"` +} + +// Matches reports whether this policy targets the given PolicyTarget. +func (p *VKSGatewayPolicy) Matches(t pkggw.PolicyTarget) bool { + for _, ref := range p.Spec.TargetRefs { + if pkggw.TargetRefMatchesWithSection(ref, p.Namespace, t) { + return true + } + } + return false +} + +func init() { + SchemeBuilder.Register(&VKSGatewayPolicy{}, &VKSGatewayPolicyList{}) +} diff --git a/api/gateway/v1alpha1/vkshealthcheckpolicy_types.go b/api/gateway/v1alpha1/vkshealthcheckpolicy_types.go new file mode 100644 index 00000000..6a7d13b4 --- /dev/null +++ b/api/gateway/v1alpha1/vkshealthcheckpolicy_types.go @@ -0,0 +1,90 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=vkshcpolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +// +kubebuilder:printcolumn:name="Protocol",type=string,JSONPath=`.spec.protocol` +// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.spec.targetRefs[*].name` +// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=="Accepted")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type VKSHealthCheckPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VKSHealthCheckPolicySpec `json:"spec,omitempty"` + Status VKSHealthCheckPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type VKSHealthCheckPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VKSHealthCheckPolicy `json:"items"` +} + +type VKSHealthCheckPolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []LocalPolicyTargetReference `json:"targetRefs"` + + // +kubebuilder:validation:Enum=HTTP;HTTPS;TCP + Protocol string `json:"protocol"` + Interval *metav1.Duration `json:"interval,omitempty"` + Timeout *metav1.Duration `json:"timeout,omitempty"` + HealthyThreshold *int32 `json:"healthyThreshold,omitempty"` + UnhealthyThreshold *int32 `json:"unhealthyThreshold,omitempty"` + + // Port overrides the health-check (monitor) port on every pool member fed + // by the targeted Service. When unset, each member is probed on its own + // traffic port. Mirrors the Ingress `vks.vngcloud.vn/healthcheck-port` + // annotation; applies to all protocols. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + Port *int32 `json:"port,omitempty"` + + HTTPHealthCheck *VKSHTTPHealthCheck `json:"httpHealthCheck,omitempty"` +} + +type VKSHTTPHealthCheck struct { + Path *string `json:"path,omitempty"` + Host *string `json:"host,omitempty"` + ExpectedCodes []string `json:"expectedCodes,omitempty"` + + // Method is the HTTP method used for the probe. HTTP/HTTPS protocol only; + // ignored for TCP. Defaults to GET. Mirrors the Ingress + // `vks.vngcloud.vn/healthcheck-http-method` annotation. + // +kubebuilder:validation:Enum=GET;PUT;POST + Method *string `json:"method,omitempty"` + + // HTTPVersion is the HTTP version used for the probe. HTTP/HTTPS protocol + // only; ignored for TCP. Defaults to "1.1". Mirrors the Ingress + // `vks.vngcloud.vn/healthcheck-http-version` annotation. + // +kubebuilder:validation:Enum="1.0";"1.1" + HTTPVersion *string `json:"httpVersion,omitempty"` +} + +type VKSHealthCheckPolicyStatus struct { + CommonStatus `json:",inline"` + CommonPolicyStatus `json:",inline"` +} + +// Matches reports whether this policy targets the given PolicyTarget. +func (p *VKSHealthCheckPolicy) Matches(t pkggw.PolicyTarget) bool { + for _, ref := range p.Spec.TargetRefs { + if pkggw.TargetRefMatches(ref, p.Namespace, t) { + return true + } + } + return false +} + +func init() { + SchemeBuilder.Register(&VKSHealthCheckPolicy{}, &VKSHealthCheckPolicyList{}) +} diff --git a/api/gateway/v1alpha1/vksroutepolicy_types.go b/api/gateway/v1alpha1/vksroutepolicy_types.go new file mode 100644 index 00000000..74e3e1bd --- /dev/null +++ b/api/gateway/v1alpha1/vksroutepolicy_types.go @@ -0,0 +1,69 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=vksroutepolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.spec.targetRefs[*].name` +// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=="Accepted")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type VKSRoutePolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VKSRoutePolicySpec `json:"spec,omitempty"` + Status VKSRoutePolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type VKSRoutePolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VKSRoutePolicy `json:"items"` +} + +type VKSRoutePolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []LocalPolicyTargetReferenceWithSectionName `json:"targetRefs"` + + Actions []VKSRuleAction `json:"actions,omitempty"` + Position *int32 `json:"position,omitempty"` +} + +type VKSRuleAction struct { + // +kubebuilder:validation:Enum=Reject;Redirect + Type string `json:"type"` + Redirect *VKSRedirectAction `json:"redirect,omitempty"` +} + +type VKSRedirectAction struct { + URL string `json:"url"` + HTTPCode *int32 `json:"httpCode,omitempty"` + KeepQueryString *bool `json:"keepQueryString,omitempty"` +} + +type VKSRoutePolicyStatus struct { + CommonStatus `json:",inline"` + CommonPolicyStatus `json:",inline"` +} + +// Matches reports whether this policy targets the given PolicyTarget. +func (p *VKSRoutePolicy) Matches(t pkggw.PolicyTarget) bool { + for _, ref := range p.Spec.TargetRefs { + if pkggw.TargetRefMatchesWithSection(ref, p.Namespace, t) { + return true + } + } + return false +} + +func init() { + SchemeBuilder.Register(&VKSRoutePolicy{}, &VKSRoutePolicyList{}) +} diff --git a/api/gateway/v1alpha1/zz_generated.deepcopy.go b/api/gateway/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..a52bbcf1 --- /dev/null +++ b/api/gateway/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,737 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CommonPolicyStatus) DeepCopyInto(out *CommonPolicyStatus) { + *out = *in + if in.Ancestors != nil { + in, out := &in.Ancestors, &out.Ancestors + *out = make([]v1alpha2.PolicyAncestorStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CommonPolicyStatus. +func (in *CommonPolicyStatus) DeepCopy() *CommonPolicyStatus { + if in == nil { + return nil + } + out := new(CommonPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CommonStatus) DeepCopyInto(out *CommonStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CommonStatus. +func (in *CommonStatus) DeepCopy() *CommonStatus { + if in == nil { + return nil + } + out := new(CommonStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSBackendPolicy) DeepCopyInto(out *VKSBackendPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSBackendPolicy. +func (in *VKSBackendPolicy) DeepCopy() *VKSBackendPolicy { + if in == nil { + return nil + } + out := new(VKSBackendPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VKSBackendPolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSBackendPolicyList) DeepCopyInto(out *VKSBackendPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VKSBackendPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSBackendPolicyList. +func (in *VKSBackendPolicyList) DeepCopy() *VKSBackendPolicyList { + if in == nil { + return nil + } + out := new(VKSBackendPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VKSBackendPolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSBackendPolicySpec) DeepCopyInto(out *VKSBackendPolicySpec) { + *out = *in + if in.TargetRefs != nil { + in, out := &in.TargetRefs, &out.TargetRefs + *out = make([]v1alpha2.LocalPolicyTargetReference, len(*in)) + copy(*out, *in) + } + if in.TargetType != nil { + in, out := &in.TargetType, &out.TargetType + *out = new(string) + **out = **in + } + if in.PoolAlgorithm != nil { + in, out := &in.PoolAlgorithm, &out.PoolAlgorithm + *out = new(string) + **out = **in + } + if in.Stickiness != nil { + in, out := &in.Stickiness, &out.Stickiness + *out = new(bool) + **out = **in + } + if in.EnableTLSEncryption != nil { + in, out := &in.EnableTLSEncryption, &out.EnableTLSEncryption + *out = new(bool) + **out = **in + } + if in.TargetNodeLabels != nil { + in, out := &in.TargetNodeLabels, &out.TargetNodeLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.ManageDFPMembers != nil { + in, out := &in.ManageDFPMembers, &out.ManageDFPMembers + *out = new(bool) + **out = **in + } + if in.ProxyProtocol != nil { + in, out := &in.ProxyProtocol, &out.ProxyProtocol + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSBackendPolicySpec. +func (in *VKSBackendPolicySpec) DeepCopy() *VKSBackendPolicySpec { + if in == nil { + return nil + } + out := new(VKSBackendPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSBackendPolicyStatus) DeepCopyInto(out *VKSBackendPolicyStatus) { + *out = *in + in.CommonStatus.DeepCopyInto(&out.CommonStatus) + in.CommonPolicyStatus.DeepCopyInto(&out.CommonPolicyStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSBackendPolicyStatus. +func (in *VKSBackendPolicyStatus) DeepCopy() *VKSBackendPolicyStatus { + if in == nil { + return nil + } + out := new(VKSBackendPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSGatewayPolicy) DeepCopyInto(out *VKSGatewayPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSGatewayPolicy. +func (in *VKSGatewayPolicy) DeepCopy() *VKSGatewayPolicy { + if in == nil { + return nil + } + out := new(VKSGatewayPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VKSGatewayPolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSGatewayPolicyList) DeepCopyInto(out *VKSGatewayPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VKSGatewayPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSGatewayPolicyList. +func (in *VKSGatewayPolicyList) DeepCopy() *VKSGatewayPolicyList { + if in == nil { + return nil + } + out := new(VKSGatewayPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VKSGatewayPolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSGatewayPolicySpec) DeepCopyInto(out *VKSGatewayPolicySpec) { + *out = *in + if in.TargetRefs != nil { + in, out := &in.TargetRefs, &out.TargetRefs + *out = make([]v1alpha2.LocalPolicyTargetReferenceWithSectionName, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AllowedCIDRs != nil { + in, out := &in.AllowedCIDRs, &out.AllowedCIDRs + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.InsertHeaders != nil { + in, out := &in.InsertHeaders, &out.InsertHeaders + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.TimeoutClient != nil { + in, out := &in.TimeoutClient, &out.TimeoutClient + *out = new(v1.Duration) + **out = **in + } + if in.TimeoutMember != nil { + in, out := &in.TimeoutMember, &out.TimeoutMember + *out = new(v1.Duration) + **out = **in + } + if in.TimeoutConnection != nil { + in, out := &in.TimeoutConnection, &out.TimeoutConnection + *out = new(v1.Duration) + **out = **in + } + if in.CertificateIDs != nil { + in, out := &in.CertificateIDs, &out.CertificateIDs + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ClientCertificateID != nil { + in, out := &in.ClientCertificateID, &out.ClientCertificateID + *out = new(string) + **out = **in + } + if in.LoadBalancerSpec != nil { + in, out := &in.LoadBalancerSpec, &out.LoadBalancerSpec + *out = new(VKSLoadBalancerSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSGatewayPolicySpec. +func (in *VKSGatewayPolicySpec) DeepCopy() *VKSGatewayPolicySpec { + if in == nil { + return nil + } + out := new(VKSGatewayPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSGatewayPolicyStatus) DeepCopyInto(out *VKSGatewayPolicyStatus) { + *out = *in + in.CommonStatus.DeepCopyInto(&out.CommonStatus) + in.CommonPolicyStatus.DeepCopyInto(&out.CommonPolicyStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSGatewayPolicyStatus. +func (in *VKSGatewayPolicyStatus) DeepCopy() *VKSGatewayPolicyStatus { + if in == nil { + return nil + } + out := new(VKSGatewayPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSHTTPHealthCheck) DeepCopyInto(out *VKSHTTPHealthCheck) { + *out = *in + if in.Path != nil { + in, out := &in.Path, &out.Path + *out = new(string) + **out = **in + } + if in.Host != nil { + in, out := &in.Host, &out.Host + *out = new(string) + **out = **in + } + if in.ExpectedCodes != nil { + in, out := &in.ExpectedCodes, &out.ExpectedCodes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Method != nil { + in, out := &in.Method, &out.Method + *out = new(string) + **out = **in + } + if in.HTTPVersion != nil { + in, out := &in.HTTPVersion, &out.HTTPVersion + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSHTTPHealthCheck. +func (in *VKSHTTPHealthCheck) DeepCopy() *VKSHTTPHealthCheck { + if in == nil { + return nil + } + out := new(VKSHTTPHealthCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSHealthCheckPolicy) DeepCopyInto(out *VKSHealthCheckPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSHealthCheckPolicy. +func (in *VKSHealthCheckPolicy) DeepCopy() *VKSHealthCheckPolicy { + if in == nil { + return nil + } + out := new(VKSHealthCheckPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VKSHealthCheckPolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSHealthCheckPolicyList) DeepCopyInto(out *VKSHealthCheckPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VKSHealthCheckPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSHealthCheckPolicyList. +func (in *VKSHealthCheckPolicyList) DeepCopy() *VKSHealthCheckPolicyList { + if in == nil { + return nil + } + out := new(VKSHealthCheckPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VKSHealthCheckPolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSHealthCheckPolicySpec) DeepCopyInto(out *VKSHealthCheckPolicySpec) { + *out = *in + if in.TargetRefs != nil { + in, out := &in.TargetRefs, &out.TargetRefs + *out = make([]v1alpha2.LocalPolicyTargetReference, len(*in)) + copy(*out, *in) + } + if in.Interval != nil { + in, out := &in.Interval, &out.Interval + *out = new(v1.Duration) + **out = **in + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(v1.Duration) + **out = **in + } + if in.HealthyThreshold != nil { + in, out := &in.HealthyThreshold, &out.HealthyThreshold + *out = new(int32) + **out = **in + } + if in.UnhealthyThreshold != nil { + in, out := &in.UnhealthyThreshold, &out.UnhealthyThreshold + *out = new(int32) + **out = **in + } + if in.Port != nil { + in, out := &in.Port, &out.Port + *out = new(int32) + **out = **in + } + if in.HTTPHealthCheck != nil { + in, out := &in.HTTPHealthCheck, &out.HTTPHealthCheck + *out = new(VKSHTTPHealthCheck) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSHealthCheckPolicySpec. +func (in *VKSHealthCheckPolicySpec) DeepCopy() *VKSHealthCheckPolicySpec { + if in == nil { + return nil + } + out := new(VKSHealthCheckPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSHealthCheckPolicyStatus) DeepCopyInto(out *VKSHealthCheckPolicyStatus) { + *out = *in + in.CommonStatus.DeepCopyInto(&out.CommonStatus) + in.CommonPolicyStatus.DeepCopyInto(&out.CommonPolicyStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSHealthCheckPolicyStatus. +func (in *VKSHealthCheckPolicyStatus) DeepCopy() *VKSHealthCheckPolicyStatus { + if in == nil { + return nil + } + out := new(VKSHealthCheckPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSLoadBalancerSpec) DeepCopyInto(out *VKSLoadBalancerSpec) { + *out = *in + if in.Scheme != nil { + in, out := &in.Scheme, &out.Scheme + *out = new(string) + **out = **in + } + if in.PackageID != nil { + in, out := &in.PackageID, &out.PackageID + *out = new(string) + **out = **in + } + if in.SubnetID != nil { + in, out := &in.SubnetID, &out.SubnetID + *out = new(string) + **out = **in + } + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.LoadBalancerID != nil { + in, out := &in.LoadBalancerID, &out.LoadBalancerID + *out = new(string) + **out = **in + } + if in.LoadBalancerName != nil { + in, out := &in.LoadBalancerName, &out.LoadBalancerName + *out = new(string) + **out = **in + } + if in.PreferZoneID != nil { + in, out := &in.PreferZoneID, &out.PreferZoneID + *out = new(string) + **out = **in + } + if in.PrivateSubnetID != nil { + in, out := &in.PrivateSubnetID, &out.PrivateSubnetID + *out = new(string) + **out = **in + } + if in.PrivateZoneID != nil { + in, out := &in.PrivateZoneID, &out.PrivateZoneID + *out = new(string) + **out = **in + } + if in.EnableAutoscale != nil { + in, out := &in.EnableAutoscale, &out.EnableAutoscale + *out = new(bool) + **out = **in + } + if in.IsPOC != nil { + in, out := &in.IsPOC, &out.IsPOC + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSLoadBalancerSpec. +func (in *VKSLoadBalancerSpec) DeepCopy() *VKSLoadBalancerSpec { + if in == nil { + return nil + } + out := new(VKSLoadBalancerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSRedirectAction) DeepCopyInto(out *VKSRedirectAction) { + *out = *in + if in.HTTPCode != nil { + in, out := &in.HTTPCode, &out.HTTPCode + *out = new(int32) + **out = **in + } + if in.KeepQueryString != nil { + in, out := &in.KeepQueryString, &out.KeepQueryString + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSRedirectAction. +func (in *VKSRedirectAction) DeepCopy() *VKSRedirectAction { + if in == nil { + return nil + } + out := new(VKSRedirectAction) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSRoutePolicy) DeepCopyInto(out *VKSRoutePolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSRoutePolicy. +func (in *VKSRoutePolicy) DeepCopy() *VKSRoutePolicy { + if in == nil { + return nil + } + out := new(VKSRoutePolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VKSRoutePolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSRoutePolicyList) DeepCopyInto(out *VKSRoutePolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VKSRoutePolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSRoutePolicyList. +func (in *VKSRoutePolicyList) DeepCopy() *VKSRoutePolicyList { + if in == nil { + return nil + } + out := new(VKSRoutePolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VKSRoutePolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSRoutePolicySpec) DeepCopyInto(out *VKSRoutePolicySpec) { + *out = *in + if in.TargetRefs != nil { + in, out := &in.TargetRefs, &out.TargetRefs + *out = make([]v1alpha2.LocalPolicyTargetReferenceWithSectionName, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Actions != nil { + in, out := &in.Actions, &out.Actions + *out = make([]VKSRuleAction, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Position != nil { + in, out := &in.Position, &out.Position + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSRoutePolicySpec. +func (in *VKSRoutePolicySpec) DeepCopy() *VKSRoutePolicySpec { + if in == nil { + return nil + } + out := new(VKSRoutePolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSRoutePolicyStatus) DeepCopyInto(out *VKSRoutePolicyStatus) { + *out = *in + in.CommonStatus.DeepCopyInto(&out.CommonStatus) + in.CommonPolicyStatus.DeepCopyInto(&out.CommonPolicyStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSRoutePolicyStatus. +func (in *VKSRoutePolicyStatus) DeepCopy() *VKSRoutePolicyStatus { + if in == nil { + return nil + } + out := new(VKSRoutePolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VKSRuleAction) DeepCopyInto(out *VKSRuleAction) { + *out = *in + if in.Redirect != nil { + in, out := &in.Redirect, &out.Redirect + *out = new(VKSRedirectAction) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VKSRuleAction. +func (in *VKSRuleAction) DeepCopy() *VKSRuleAction { + if in == nil { + return nil + } + out := new(VKSRuleAction) + in.DeepCopyInto(out) + return out +} diff --git a/charts/vngcloud-load-balancer-controller/templates/deployment.yaml b/charts/vngcloud-load-balancer-controller/templates/deployment.yaml index 6857ee2c..2f9c9e7c 100644 --- a/charts/vngcloud-load-balancer-controller/templates/deployment.yaml +++ b/charts/vngcloud-load-balancer-controller/templates/deployment.yaml @@ -22,6 +22,8 @@ spec: affinity: {{- toYaml .Values.manager.affinity | nindent 8 }} containers: - args: {{- toYaml .Values.manager.manager.args | nindent 8 }} + - --disable-alb-gateway-controller={{ not .Values.gatewayApi.alb.enabled }} + - --disable-nlb-gateway-controller={{ not .Values.gatewayApi.nlb.enabled }} command: - /manager env: diff --git a/charts/vngcloud-load-balancer-controller/templates/gatewayclass-alb.yaml b/charts/vngcloud-load-balancer-controller/templates/gatewayclass-alb.yaml new file mode 100644 index 00000000..fc17c99b --- /dev/null +++ b/charts/vngcloud-load-balancer-controller/templates/gatewayclass-alb.yaml @@ -0,0 +1,9 @@ +{{- if .Values.gatewayApi.alb.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: vngcloud-alb +spec: + controllerName: gateway.vks.vngcloud.vn/alb + description: "VNGCloud ALB - L7 Gateway-API class." +{{- end }} diff --git a/charts/vngcloud-load-balancer-controller/templates/gatewayclass-nlb.yaml b/charts/vngcloud-load-balancer-controller/templates/gatewayclass-nlb.yaml new file mode 100644 index 00000000..f0c67fee --- /dev/null +++ b/charts/vngcloud-load-balancer-controller/templates/gatewayclass-nlb.yaml @@ -0,0 +1,9 @@ +{{- if .Values.gatewayApi.nlb.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: vngcloud-nlb +spec: + controllerName: gateway.vks.vngcloud.vn/nlb + description: "VNGCloud NLB - L4 Gateway-API class." +{{- end }} diff --git a/charts/vngcloud-load-balancer-controller/templates/manager-rbac.yaml b/charts/vngcloud-load-balancer-controller/templates/manager-rbac.yaml index d6e318b6..04b93a70 100644 --- a/charts/vngcloud-load-balancer-controller/templates/manager-rbac.yaml +++ b/charts/vngcloud-load-balancer-controller/templates/manager-rbac.yaml @@ -8,6 +8,7 @@ rules: - apiGroups: - "" resources: + - namespaces - node verbs: - get @@ -95,6 +96,67 @@ rules: - get - patch - update +- apiGroups: + - gateway.networking.k8s.io + resources: + - gatewayclasses + - httproutes + - referencegrants + - tcproutes + - udproutes + verbs: + - get + - list + - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - gatewayclasses/status + - gateways/status + - httproutes/status + - tcproutes/status + - udproutes/status + verbs: + - patch + - update +- apiGroups: + - gateway.networking.k8s.io + resources: + - gateways + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - gateways/finalizers + verbs: + - update +- apiGroups: + - gateway.vks.vngcloud.vn + resources: + - vksbackendpolicies + - vksgatewaypolicies + - vkshealthcheckpolicies + - vksroutepolicies + verbs: + - get + - list + - watch +- apiGroups: + - gateway.vks.vngcloud.vn + resources: + - vksbackendpolicies/status + - vksgatewaypolicies/status + - vkshealthcheckpolicies/status + - vksroutepolicies/status + verbs: + - get + - patch + - update - apiGroups: - networking.k8s.io resources: diff --git a/charts/vngcloud-load-balancer-controller/values.yaml b/charts/vngcloud-load-balancer-controller/values.yaml index 07802636..704f401f 100644 --- a/charts/vngcloud-load-balancer-controller/values.yaml +++ b/charts/vngcloud-load-balancer-controller/values.yaml @@ -1,4 +1,20 @@ kubernetesClusterDomain: cluster.local +# gatewayApi toggles Kubernetes Gateway-API support. DISABLED by default so the +# chart installs cleanly on clusters that have not installed the Gateway-API CRDs +# (GatewayClass/Gateway/HTTPRoute). Enable a class only after installing those CRDs. +gatewayApi: + alb: + # When true, the chart installs the vngcloud-alb GatewayClass and the + # controller reconciles it (passes --disable-alb-gateway-controller=false). + # Requires the Gateway-API CRDs to be installed in the cluster first. + enabled: false + nlb: + # Gateway-API NLB (L4) support — Phase 2. When true, the chart installs the + # vngcloud-nlb GatewayClass and the controller reconciles it (passes + # --disable-nlb-gateway-controller=false). In addition to the standard + # Gateway-API CRDs, this reconciles TCPRoute/UDPRoute, which are Gateway-API + # *experimental-channel* CRDs that must be installed in the cluster first. + enabled: false manager: manager: args: diff --git a/cmd/main.go b/cmd/main.go index 242df6ff..2f811116 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -43,8 +43,13 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" vksvngcloudvnv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" corecontroller "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/core" + gatewayalbcontroller "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/alb" + gatewaynlbcontroller "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/nlb" + gatewaypolicies "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/policies" + gatewayshared "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/glbc_controller" "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/lbc_controller" networkingcontroller "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/networking" @@ -54,6 +59,8 @@ import ( "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/k8s_repo" "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/vngcloud_repo" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/alb_gateway_uc" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/nlb_gateway_uc" "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/glbc_uc" "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/ingress_uc" "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/lbc_uc" @@ -78,6 +85,9 @@ import ( "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/version" "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/vglb" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" // +kubebuilder:scaffold:imports ) @@ -90,6 +100,14 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(vksvngcloudvnv1alpha1.AddToScheme(scheme)) + utilruntime.Must(gwv1alpha1.AddToScheme(scheme)) + utilruntime.Must(gwv1.Install(scheme)) + utilruntime.Must(gwv1beta1.Install(scheme)) + // v1alpha2 carries the experimental L4 routes (TCPRoute/UDPRoute). Teaching + // the scheme to decode them is harmless when the CRDs aren't installed; the + // NLB controller's route watches (which require the CRDs) are gated behind + // --disable-nlb-gateway-controller (default disabled). + utilruntime.Must(gwv1a2.Install(scheme)) // +kubebuilder:scaffold:scheme } @@ -107,6 +125,8 @@ func main() { //nolint:gocyclo var disableNodeSecurityGroupController bool var disableVngcloudGlobalLoadBalancerController bool var disableServiceGLBController bool + var disableALBGatewayController bool + var disableNLBGatewayController bool var syncPeriod time.Duration var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ @@ -135,6 +155,14 @@ func main() { //nolint:gocyclo "If set, the VngcloudGlobalLoadBalancer controller will be disabled") flag.BoolVar(&disableServiceGLBController, "disable-service-glb-controller", false, "If set, the ServiceGLB controller will be disabled") + flag.BoolVar(&disableALBGatewayController, "disable-alb-gateway-controller", true, + "If set, the Gateway-API ALB controller (vngcloud-alb GatewayClass) is disabled. "+ + "DISABLED by default: the Gateway-API CRDs (GatewayClass/Gateway/HTTPRoute) must be "+ + "installed in the cluster before enabling. In Helm set gatewayApi.alb.enabled=true to enable.") + flag.BoolVar(&disableNLBGatewayController, "disable-nlb-gateway-controller", true, + "If set, the Gateway-API NLB controller (vngcloud-nlb GatewayClass, L4) is disabled. "+ + "DISABLED by default: TCPRoute/UDPRoute are Gateway-API experimental-channel CRDs and "+ + "must be installed before enabling. In Helm set gatewayApi.nlb.enabled=true to enable.") flag.DurationVar(&syncPeriod, "sync-period", 5*time.Minute, "The minimum frequency at which watched resources are reconciled. "+ "A lower period will correct entropy more quickly, "+ @@ -447,6 +475,79 @@ func main() { //nolint:gocyclo } } + if !disableALBGatewayController { + albGatewayUseCase := alb_gateway_uc.NewALBGatewayUseCase( + conf.Cluster.ClusterID, + k8sRepo, + vngcloudRepo, + endpointResolver, + mgr.GetClient(), + finalizerManager, + ) + albGatewayReconciler := gatewayalbcontroller.NewGatewayReconciler( + mgr.GetClient(), + mgr.GetScheme(), + albGatewayUseCase, + conf.MaxConcurrentReconciles, + ) + if err := albGatewayReconciler.SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ALBGateway") + os.Exit(1) + } + + gcReconciler := gatewayalbcontroller.NewGatewayClassReconciler(mgr.GetClient(), mgr.GetScheme()) + if err := gcReconciler.SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ALBGatewayClass") + os.Exit(1) + } + + // Policy validators write GEP-713 status (Accepted/Conflicted/ + // TargetNotFound) on the four VKS policy CRDs. Status-only; never touch + // the LoadBalancer. + for _, pr := range gatewaypolicies.AllReconcilers(mgr.GetClient()) { + if err := pr.SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "GatewayPolicyValidator") + os.Exit(1) + } + } + + // Register the field indexes used by the gateway reconciler's watches + // (HTTPRoute → parent Gateway, etc.). + if err := gatewayshared.RegisterIndexes(ctx, mgr); err != nil { + setupLog.Error(err, "unable to register gateway field indexes") + os.Exit(1) + } + } + + if !disableNLBGatewayController { + nlbGatewayUseCase := nlb_gateway_uc.NewNLBGatewayUseCase( + conf.Cluster.ClusterID, + k8sRepo, + vngcloudRepo, + endpointResolver, + mgr.GetClient(), + finalizerManager, + ) + nlbGatewayReconciler := gatewaynlbcontroller.NewGatewayReconciler( + mgr.GetClient(), + mgr.GetScheme(), + nlbGatewayUseCase, + conf.MaxConcurrentReconciles, + ) + // SetupWithManager registers the L4 (TCPRoute/UDPRoute) field indexes + // internally — those CRDs are an experimental-channel prerequisite. + if err := nlbGatewayReconciler.SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "NLBGateway") + os.Exit(1) + } + + nlbGCReconciler := gatewaynlbcontroller.NewGatewayClassReconciler(mgr.GetClient(), mgr.GetScheme()) + if err := nlbGCReconciler.SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "NLBGatewayClass") + os.Exit(1) + } + } + // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/gateway.vks.vngcloud.vn_vksbackendpolicies.yaml b/config/crd/bases/gateway.vks.vngcloud.vn_vksbackendpolicies.yaml new file mode 100644 index 00000000..dcddf1b7 --- /dev/null +++ b/config/crd/bases/gateway.vks.vngcloud.vn_vksbackendpolicies.yaml @@ -0,0 +1,466 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + labels: + gateway.networking.k8s.io/policy: direct + name: vksbackendpolicies.gateway.vks.vngcloud.vn +spec: + group: gateway.vks.vngcloud.vn + names: + categories: + - gateway-api + kind: VKSBackendPolicy + listKind: VKSBackendPolicyList + plural: vksbackendpolicies + shortNames: + - vksbpolicy + singular: vksbackendpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRefs[*].name + name: Targets + type: string + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + enableTLSEncryption: + type: boolean + manageDFPMembers: + type: boolean + poolAlgorithm: + enum: + - ROUND_ROBIN + - LEAST_CONNECTIONS + - SOURCE_IP + type: string + proxyProtocol: + description: |- + ProxyProtocol makes the cloud LB pool speak HAProxy PROXY protocol to its + members, so an L4 (NLB) backend such as an HAProxy/nginx ingress + controller can recover the real client IP behind the load balancer. + Honored only on the NLB (L4) path and only for TCP pools (UDP is + ignored). Mirrors the Service controller's + `vks.vngcloud.vn/enable-proxy-protocol` annotation. + type: boolean + stickiness: + description: |- + Stickiness enables sticky sessions on the pool. The vngcloud LB API + exposes only an on/off flag; cookie name and TTL are not configurable. + type: boolean + targetNodeLabels: + additionalProperties: + type: string + type: object + targetRefs: + items: + description: |- + LocalPolicyTargetReference identifies an API object to apply a direct or + inherited policy to. This should be used as part of Policy resources + that can target Gateway API resources. For more information on how this + policy attachment model works, and a sample Policy resource, refer to + the policy attachment documentation for Gateway API. + properties: + group: + description: Group is the group of the target resource. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is kind of the target resource. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the target resource. + maxLength: 253 + minLength: 1 + type: string + required: + - group + - kind + - name + type: object + maxItems: 16 + minItems: 1 + type: array + targetType: + description: |- + TargetType selects which addresses the controller resolves into pool + members. "ip" puts pod IPs in the pool (works on flat networks / + Cilium native routing). "instance" puts node IPs + nodePort in the + pool (works on overlay networks where pod IPs aren't routable from + the cloud LB). Default is auto-detected from the cluster CNI — same + rule the Ingress controller uses today (Cilium overlay → instance, + native routing → ip). + + This is a controller-side translation toggle, not a vngcloud LB API + field; the resulting member IPs are what gets stored on the cloud + pool. See design spec §1.6. + enum: + - instance + - ip + type: string + required: + - targetRefs + type: object + status: + properties: + ancestors: + description: Ancestors records reconcile status per controller acting + on this policy. + items: + description: |- + PolicyAncestorStatus describes the status of a route with respect to an + associated Ancestor. + + Ancestors refer to objects that are either the Target of a policy or above it + in terms of object hierarchy. For example, if a policy targets a Service, the + Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and + the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most + useful object to place Policy status on, so we recommend that implementations + SHOULD use Gateway as the PolicyAncestorStatus object unless the designers + have a _very_ good reason otherwise. + + In the context of policy attachment, the Ancestor is used to distinguish which + resource results in a distinct application of this policy. For example, if a policy + targets a Service, it may have a distinct result per attached Gateway. + + Policies targeting the same resource may have different effects depending on the + ancestors of those resources. For example, different Gateways targeting the same + Service may have different capabilities, especially if they have different underlying + implementations. + + For example, in BackendTLSPolicy, the Policy attaches to a Service that is + used as a backend in a HTTPRoute that is itself attached to a Gateway. + In this case, the relevant object for status is the Gateway, and that is the + ancestor object referred to in this status. + + Note that a parent is also an ancestor, so for objects where the parent is the + relevant object for status, this struct SHOULD still be used. + + This struct is intended to be used in a slice that's effectively a map, + with a composite key made up of the AncestorRef and the ControllerName. + properties: + ancestorRef: + description: |- + AncestorRef corresponds with a ParentRef in the spec that this + PolicyAncestorStatus struct describes the status of. + properties: + group: + default: gateway.networking.k8s.io + description: |- + Group is the group of the referent. + When unspecified, "gateway.networking.k8s.io" is inferred. + To set the core API group (such as for a "Service" kind referent), + Group must be explicitly set to "" (empty string). + + Support: Core + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Gateway + description: |- + Kind is kind of the referent. + + There are two kinds of parent resources with "Core" support: + + * Gateway (Gateway conformance profile) + * Service (Mesh conformance profile, ClusterIP Services only) + + Support for other resources is Implementation-Specific. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name is the name of the referent. + + Support: Core + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referent. When unspecified, this refers + to the local namespace of the Route. + + Note that there are specific rules for ParentRefs which cross namespace + boundaries. Cross-namespace references are only valid if they are explicitly + allowed by something in the namespace they are referring to. For example: + Gateway has the AllowedRoutes field, and ReferenceGrant provides a + generic way to enable any other kind of cross-namespace reference. + + + ParentRefs from a Route to a Service in the same namespace are "producer" + routes, which apply default routing rules to inbound connections from + any namespace to the Service. + + ParentRefs from a Route to a Service in a different namespace are + "consumer" routes, and these routing rules are only applied to outbound + connections originating from the same namespace as the Route, for which + the intended destination of the connections are a Service targeted as a + ParentRef of the Route. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port is the network port this Route targets. It can be interpreted + differently based on the type of parent resource. + + When the parent resource is a Gateway, this targets all listeners + listening on the specified port that also support this kind of Route(and + select this Route). It's not recommended to set `Port` unless the + networking behaviors specified in a Route must apply to a specific port + as opposed to a listener(s) whose port(s) may be changed. When both Port + and SectionName are specified, the name and port of the selected listener + must match both specified values. + + + When the parent resource is a Service, this targets a specific port in the + Service spec. When both Port (experimental) and SectionName are specified, + the name and port of the selected port must match both specified values. + + + Implementations MAY choose to support other parent resources. + Implementations supporting other types of parent resources MUST clearly + document how/if Port is interpreted. + + For the purpose of status, an attachment is considered successful as + long as the parent resource accepts it partially. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment + from the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, + the Route MUST be considered detached from the Gateway. + + Support: Extended + format: int32 + maximum: 65535 + minimum: 1 + type: integer + sectionName: + description: |- + SectionName is the name of a section within the target resource. In the + following resources, SectionName is interpreted as the following: + + * Gateway: Listener name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + * Service: Port name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + + Implementations MAY choose to support attaching Routes to other resources. + If that is the case, they MUST clearly document how SectionName is + interpreted. + + When unspecified (empty string), this will reference the entire resource. + For the purpose of status, an attachment is considered successful if at + least one section in the parent resource accepts it. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from + the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, the + Route MUST be considered detached from the Gateway. + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + conditions: + description: Conditions describes the status of the Policy with + respect to the given Ancestor. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + controllerName: + description: |- + ControllerName is a domain/path string that indicates the name of the + controller that wrote this status. This corresponds with the + controllerName field on GatewayClass. + + Example: "example.net/gateway-controller". + + The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are + valid Kubernetes names + (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). + + Controllers MUST populate this field when writing status. Controllers should ensure that + entries to status populated with their ControllerName are cleaned up when they are no + longer necessary. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ + type: string + required: + - ancestorRef + - controllerName + type: object + maxItems: 16 + type: array + x-kubernetes-list-type: atomic + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/gateway.vks.vngcloud.vn_vksgatewaypolicies.yaml b/config/crd/bases/gateway.vks.vngcloud.vn_vksgatewaypolicies.yaml new file mode 100644 index 00000000..8a0310a4 --- /dev/null +++ b/config/crd/bases/gateway.vks.vngcloud.vn_vksgatewaypolicies.yaml @@ -0,0 +1,520 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + labels: + gateway.networking.k8s.io/policy: direct + name: vksgatewaypolicies.gateway.vks.vngcloud.vn +spec: + group: gateway.vks.vngcloud.vn + names: + categories: + - gateway-api + kind: VKSGatewayPolicy + listKind: VKSGatewayPolicyList + plural: vksgatewaypolicies + shortNames: + - vksgwpolicy + singular: vksgatewaypolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRefs[*].name + name: Targets + type: string + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + allowedCidrs: + items: + type: string + type: array + certificateIds: + items: + type: string + type: array + clientCertificateId: + type: string + insertHeaders: + additionalProperties: + type: string + type: object + loadBalancerSpec: + properties: + enableAutoscale: + description: |- + EnableAutoscale toggles cloud-side autoscaling on the LB. Maps to + LBC.Spec.EnableAutoscale — same as the Ingress "enable-autoscale" + annotation. Unset = cloud default (off). + type: boolean + isPOC: + description: |- + IsPOC marks the LB as a POC tier. Maps to LBC.Spec.IsPoc — same as + the Ingress "is-poc" annotation. Unset = production tier. + type: boolean + loadBalancerId: + type: string + loadBalancerName: + description: |- + LoadBalancerName sets a custom name for the cloud LB at creation time. + Maps to LBC.Spec.LoadBalancerName — same as the Ingress + "load-balancer-name" annotation. Written only when the LBC is first + created (the cloud name is then mirrored back into Status); unset = a + controller-generated default name. + type: string + packageId: + type: string + preferZoneId: + description: |- + PreferZoneID pins the LB to a specific availability zone. The controller + resolves a subnet in that zone from a cluster node. Maps to the same + zone/subnet selection the Ingress "prefer-zone-id" annotation drives. + Lower precedence than LoadBalancerID (adoption) and SubnetID. + type: string + privateSubnetId: + description: |- + PrivateSubnetID overrides the LB's private subnet (used by the + InterVPC scheme). Maps to LBC.Spec.PrivateSubnetId — same field the + Ingress controller's "private-subnet-id" annotation writes. + type: string + privateZoneId: + description: |- + PrivateZoneID is the zone of the client subnet, required by the + InterVPC scheme since the client subnet lives in a different VPC. + Maps to LBC.Spec.PrivateZoneId — same as the Service controller's + "private-zone-id" annotation. + type: string + scheme: + enum: + - Internet + - Internal + - InterVPC + type: string + subnetId: + type: string + tags: + additionalProperties: + type: string + type: object + type: object + targetRefs: + items: + description: |- + LocalPolicyTargetReferenceWithSectionName identifies an API object to apply a + direct policy to. This should be used as part of Policy resources that can + target single resources. For more information on how this policy attachment + mode works, and a sample Policy resource, refer to the policy attachment + documentation for Gateway API. + + Note: This should only be used for direct policy attachment when references + to SectionName are actually needed. In all other cases, + LocalPolicyTargetReference should be used. + properties: + group: + description: Group is the group of the target resource. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is kind of the target resource. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the target resource. + maxLength: 253 + minLength: 1 + type: string + sectionName: + description: |- + SectionName is the name of a section within the target resource. When + unspecified, this targetRef targets the entire resource. In the following + resources, SectionName is interpreted as the following: + + * Gateway: Listener name + * HTTPRoute: HTTPRouteRule name + * Service: Port name + + If a SectionName is specified, but does not exist on the targeted object, + the Policy must fail to attach, and the policy implementation should record + a `ResolvedRefs` or similar Condition in the Policy's status. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - group + - kind + - name + type: object + maxItems: 16 + minItems: 1 + type: array + timeoutClient: + type: string + timeoutConnection: + type: string + timeoutMember: + type: string + required: + - targetRefs + type: object + status: + properties: + ancestors: + description: Ancestors records reconcile status per controller acting + on this policy. + items: + description: |- + PolicyAncestorStatus describes the status of a route with respect to an + associated Ancestor. + + Ancestors refer to objects that are either the Target of a policy or above it + in terms of object hierarchy. For example, if a policy targets a Service, the + Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and + the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most + useful object to place Policy status on, so we recommend that implementations + SHOULD use Gateway as the PolicyAncestorStatus object unless the designers + have a _very_ good reason otherwise. + + In the context of policy attachment, the Ancestor is used to distinguish which + resource results in a distinct application of this policy. For example, if a policy + targets a Service, it may have a distinct result per attached Gateway. + + Policies targeting the same resource may have different effects depending on the + ancestors of those resources. For example, different Gateways targeting the same + Service may have different capabilities, especially if they have different underlying + implementations. + + For example, in BackendTLSPolicy, the Policy attaches to a Service that is + used as a backend in a HTTPRoute that is itself attached to a Gateway. + In this case, the relevant object for status is the Gateway, and that is the + ancestor object referred to in this status. + + Note that a parent is also an ancestor, so for objects where the parent is the + relevant object for status, this struct SHOULD still be used. + + This struct is intended to be used in a slice that's effectively a map, + with a composite key made up of the AncestorRef and the ControllerName. + properties: + ancestorRef: + description: |- + AncestorRef corresponds with a ParentRef in the spec that this + PolicyAncestorStatus struct describes the status of. + properties: + group: + default: gateway.networking.k8s.io + description: |- + Group is the group of the referent. + When unspecified, "gateway.networking.k8s.io" is inferred. + To set the core API group (such as for a "Service" kind referent), + Group must be explicitly set to "" (empty string). + + Support: Core + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Gateway + description: |- + Kind is kind of the referent. + + There are two kinds of parent resources with "Core" support: + + * Gateway (Gateway conformance profile) + * Service (Mesh conformance profile, ClusterIP Services only) + + Support for other resources is Implementation-Specific. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name is the name of the referent. + + Support: Core + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referent. When unspecified, this refers + to the local namespace of the Route. + + Note that there are specific rules for ParentRefs which cross namespace + boundaries. Cross-namespace references are only valid if they are explicitly + allowed by something in the namespace they are referring to. For example: + Gateway has the AllowedRoutes field, and ReferenceGrant provides a + generic way to enable any other kind of cross-namespace reference. + + + ParentRefs from a Route to a Service in the same namespace are "producer" + routes, which apply default routing rules to inbound connections from + any namespace to the Service. + + ParentRefs from a Route to a Service in a different namespace are + "consumer" routes, and these routing rules are only applied to outbound + connections originating from the same namespace as the Route, for which + the intended destination of the connections are a Service targeted as a + ParentRef of the Route. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port is the network port this Route targets. It can be interpreted + differently based on the type of parent resource. + + When the parent resource is a Gateway, this targets all listeners + listening on the specified port that also support this kind of Route(and + select this Route). It's not recommended to set `Port` unless the + networking behaviors specified in a Route must apply to a specific port + as opposed to a listener(s) whose port(s) may be changed. When both Port + and SectionName are specified, the name and port of the selected listener + must match both specified values. + + + When the parent resource is a Service, this targets a specific port in the + Service spec. When both Port (experimental) and SectionName are specified, + the name and port of the selected port must match both specified values. + + + Implementations MAY choose to support other parent resources. + Implementations supporting other types of parent resources MUST clearly + document how/if Port is interpreted. + + For the purpose of status, an attachment is considered successful as + long as the parent resource accepts it partially. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment + from the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, + the Route MUST be considered detached from the Gateway. + + Support: Extended + format: int32 + maximum: 65535 + minimum: 1 + type: integer + sectionName: + description: |- + SectionName is the name of a section within the target resource. In the + following resources, SectionName is interpreted as the following: + + * Gateway: Listener name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + * Service: Port name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + + Implementations MAY choose to support attaching Routes to other resources. + If that is the case, they MUST clearly document how SectionName is + interpreted. + + When unspecified (empty string), this will reference the entire resource. + For the purpose of status, an attachment is considered successful if at + least one section in the parent resource accepts it. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from + the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, the + Route MUST be considered detached from the Gateway. + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + conditions: + description: Conditions describes the status of the Policy with + respect to the given Ancestor. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + controllerName: + description: |- + ControllerName is a domain/path string that indicates the name of the + controller that wrote this status. This corresponds with the + controllerName field on GatewayClass. + + Example: "example.net/gateway-controller". + + The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are + valid Kubernetes names + (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). + + Controllers MUST populate this field when writing status. Controllers should ensure that + entries to status populated with their ControllerName are cleaned up when they are no + longer necessary. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ + type: string + required: + - ancestorRef + - controllerName + type: object + maxItems: 16 + type: array + x-kubernetes-list-type: atomic + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/gateway.vks.vngcloud.vn_vkshealthcheckpolicies.yaml b/config/crd/bases/gateway.vks.vngcloud.vn_vkshealthcheckpolicies.yaml new file mode 100644 index 00000000..02ca769a --- /dev/null +++ b/config/crd/bases/gateway.vks.vngcloud.vn_vkshealthcheckpolicies.yaml @@ -0,0 +1,481 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + labels: + gateway.networking.k8s.io/policy: direct + name: vkshealthcheckpolicies.gateway.vks.vngcloud.vn +spec: + group: gateway.vks.vngcloud.vn + names: + categories: + - gateway-api + kind: VKSHealthCheckPolicy + listKind: VKSHealthCheckPolicyList + plural: vkshealthcheckpolicies + shortNames: + - vkshcpolicy + singular: vkshealthcheckpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.protocol + name: Protocol + type: string + - jsonPath: .spec.targetRefs[*].name + name: Targets + type: string + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + healthyThreshold: + format: int32 + type: integer + httpHealthCheck: + properties: + expectedCodes: + items: + type: string + type: array + host: + type: string + httpVersion: + description: |- + HTTPVersion is the HTTP version used for the probe. HTTP/HTTPS protocol + only; ignored for TCP. Defaults to "1.1". Mirrors the Ingress + `vks.vngcloud.vn/healthcheck-http-version` annotation. + enum: + - "1.0" + - "1.1" + type: string + method: + description: |- + Method is the HTTP method used for the probe. HTTP/HTTPS protocol only; + ignored for TCP. Defaults to GET. Mirrors the Ingress + `vks.vngcloud.vn/healthcheck-http-method` annotation. + enum: + - GET + - PUT + - POST + type: string + path: + type: string + type: object + interval: + type: string + port: + description: |- + Port overrides the health-check (monitor) port on every pool member fed + by the targeted Service. When unset, each member is probed on its own + traffic port. Mirrors the Ingress `vks.vngcloud.vn/healthcheck-port` + annotation; applies to all protocols. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + protocol: + enum: + - HTTP + - HTTPS + - TCP + type: string + targetRefs: + items: + description: |- + LocalPolicyTargetReference identifies an API object to apply a direct or + inherited policy to. This should be used as part of Policy resources + that can target Gateway API resources. For more information on how this + policy attachment model works, and a sample Policy resource, refer to + the policy attachment documentation for Gateway API. + properties: + group: + description: Group is the group of the target resource. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is kind of the target resource. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the target resource. + maxLength: 253 + minLength: 1 + type: string + required: + - group + - kind + - name + type: object + maxItems: 16 + minItems: 1 + type: array + timeout: + type: string + unhealthyThreshold: + format: int32 + type: integer + required: + - protocol + - targetRefs + type: object + status: + properties: + ancestors: + description: Ancestors records reconcile status per controller acting + on this policy. + items: + description: |- + PolicyAncestorStatus describes the status of a route with respect to an + associated Ancestor. + + Ancestors refer to objects that are either the Target of a policy or above it + in terms of object hierarchy. For example, if a policy targets a Service, the + Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and + the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most + useful object to place Policy status on, so we recommend that implementations + SHOULD use Gateway as the PolicyAncestorStatus object unless the designers + have a _very_ good reason otherwise. + + In the context of policy attachment, the Ancestor is used to distinguish which + resource results in a distinct application of this policy. For example, if a policy + targets a Service, it may have a distinct result per attached Gateway. + + Policies targeting the same resource may have different effects depending on the + ancestors of those resources. For example, different Gateways targeting the same + Service may have different capabilities, especially if they have different underlying + implementations. + + For example, in BackendTLSPolicy, the Policy attaches to a Service that is + used as a backend in a HTTPRoute that is itself attached to a Gateway. + In this case, the relevant object for status is the Gateway, and that is the + ancestor object referred to in this status. + + Note that a parent is also an ancestor, so for objects where the parent is the + relevant object for status, this struct SHOULD still be used. + + This struct is intended to be used in a slice that's effectively a map, + with a composite key made up of the AncestorRef and the ControllerName. + properties: + ancestorRef: + description: |- + AncestorRef corresponds with a ParentRef in the spec that this + PolicyAncestorStatus struct describes the status of. + properties: + group: + default: gateway.networking.k8s.io + description: |- + Group is the group of the referent. + When unspecified, "gateway.networking.k8s.io" is inferred. + To set the core API group (such as for a "Service" kind referent), + Group must be explicitly set to "" (empty string). + + Support: Core + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Gateway + description: |- + Kind is kind of the referent. + + There are two kinds of parent resources with "Core" support: + + * Gateway (Gateway conformance profile) + * Service (Mesh conformance profile, ClusterIP Services only) + + Support for other resources is Implementation-Specific. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name is the name of the referent. + + Support: Core + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referent. When unspecified, this refers + to the local namespace of the Route. + + Note that there are specific rules for ParentRefs which cross namespace + boundaries. Cross-namespace references are only valid if they are explicitly + allowed by something in the namespace they are referring to. For example: + Gateway has the AllowedRoutes field, and ReferenceGrant provides a + generic way to enable any other kind of cross-namespace reference. + + + ParentRefs from a Route to a Service in the same namespace are "producer" + routes, which apply default routing rules to inbound connections from + any namespace to the Service. + + ParentRefs from a Route to a Service in a different namespace are + "consumer" routes, and these routing rules are only applied to outbound + connections originating from the same namespace as the Route, for which + the intended destination of the connections are a Service targeted as a + ParentRef of the Route. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port is the network port this Route targets. It can be interpreted + differently based on the type of parent resource. + + When the parent resource is a Gateway, this targets all listeners + listening on the specified port that also support this kind of Route(and + select this Route). It's not recommended to set `Port` unless the + networking behaviors specified in a Route must apply to a specific port + as opposed to a listener(s) whose port(s) may be changed. When both Port + and SectionName are specified, the name and port of the selected listener + must match both specified values. + + + When the parent resource is a Service, this targets a specific port in the + Service spec. When both Port (experimental) and SectionName are specified, + the name and port of the selected port must match both specified values. + + + Implementations MAY choose to support other parent resources. + Implementations supporting other types of parent resources MUST clearly + document how/if Port is interpreted. + + For the purpose of status, an attachment is considered successful as + long as the parent resource accepts it partially. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment + from the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, + the Route MUST be considered detached from the Gateway. + + Support: Extended + format: int32 + maximum: 65535 + minimum: 1 + type: integer + sectionName: + description: |- + SectionName is the name of a section within the target resource. In the + following resources, SectionName is interpreted as the following: + + * Gateway: Listener name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + * Service: Port name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + + Implementations MAY choose to support attaching Routes to other resources. + If that is the case, they MUST clearly document how SectionName is + interpreted. + + When unspecified (empty string), this will reference the entire resource. + For the purpose of status, an attachment is considered successful if at + least one section in the parent resource accepts it. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from + the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, the + Route MUST be considered detached from the Gateway. + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + conditions: + description: Conditions describes the status of the Policy with + respect to the given Ancestor. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + controllerName: + description: |- + ControllerName is a domain/path string that indicates the name of the + controller that wrote this status. This corresponds with the + controllerName field on GatewayClass. + + Example: "example.net/gateway-controller". + + The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are + valid Kubernetes names + (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). + + Controllers MUST populate this field when writing status. Controllers should ensure that + entries to status populated with their ControllerName are cleaned up when they are no + longer necessary. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ + type: string + required: + - ancestorRef + - controllerName + type: object + maxItems: 16 + type: array + x-kubernetes-list-type: atomic + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/gateway.vks.vngcloud.vn_vksroutepolicies.yaml b/config/crd/bases/gateway.vks.vngcloud.vn_vksroutepolicies.yaml new file mode 100644 index 00000000..b16e6971 --- /dev/null +++ b/config/crd/bases/gateway.vks.vngcloud.vn_vksroutepolicies.yaml @@ -0,0 +1,469 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + labels: + gateway.networking.k8s.io/policy: direct + name: vksroutepolicies.gateway.vks.vngcloud.vn +spec: + group: gateway.vks.vngcloud.vn + names: + categories: + - gateway-api + kind: VKSRoutePolicy + listKind: VKSRoutePolicyList + plural: vksroutepolicies + shortNames: + - vksroutepolicy + singular: vksroutepolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRefs[*].name + name: Targets + type: string + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + actions: + items: + properties: + redirect: + properties: + httpCode: + format: int32 + type: integer + keepQueryString: + type: boolean + url: + type: string + required: + - url + type: object + type: + enum: + - Reject + - Redirect + type: string + required: + - type + type: object + type: array + position: + format: int32 + type: integer + targetRefs: + items: + description: |- + LocalPolicyTargetReferenceWithSectionName identifies an API object to apply a + direct policy to. This should be used as part of Policy resources that can + target single resources. For more information on how this policy attachment + mode works, and a sample Policy resource, refer to the policy attachment + documentation for Gateway API. + + Note: This should only be used for direct policy attachment when references + to SectionName are actually needed. In all other cases, + LocalPolicyTargetReference should be used. + properties: + group: + description: Group is the group of the target resource. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is kind of the target resource. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the target resource. + maxLength: 253 + minLength: 1 + type: string + sectionName: + description: |- + SectionName is the name of a section within the target resource. When + unspecified, this targetRef targets the entire resource. In the following + resources, SectionName is interpreted as the following: + + * Gateway: Listener name + * HTTPRoute: HTTPRouteRule name + * Service: Port name + + If a SectionName is specified, but does not exist on the targeted object, + the Policy must fail to attach, and the policy implementation should record + a `ResolvedRefs` or similar Condition in the Policy's status. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - group + - kind + - name + type: object + maxItems: 16 + minItems: 1 + type: array + required: + - targetRefs + type: object + status: + properties: + ancestors: + description: Ancestors records reconcile status per controller acting + on this policy. + items: + description: |- + PolicyAncestorStatus describes the status of a route with respect to an + associated Ancestor. + + Ancestors refer to objects that are either the Target of a policy or above it + in terms of object hierarchy. For example, if a policy targets a Service, the + Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and + the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most + useful object to place Policy status on, so we recommend that implementations + SHOULD use Gateway as the PolicyAncestorStatus object unless the designers + have a _very_ good reason otherwise. + + In the context of policy attachment, the Ancestor is used to distinguish which + resource results in a distinct application of this policy. For example, if a policy + targets a Service, it may have a distinct result per attached Gateway. + + Policies targeting the same resource may have different effects depending on the + ancestors of those resources. For example, different Gateways targeting the same + Service may have different capabilities, especially if they have different underlying + implementations. + + For example, in BackendTLSPolicy, the Policy attaches to a Service that is + used as a backend in a HTTPRoute that is itself attached to a Gateway. + In this case, the relevant object for status is the Gateway, and that is the + ancestor object referred to in this status. + + Note that a parent is also an ancestor, so for objects where the parent is the + relevant object for status, this struct SHOULD still be used. + + This struct is intended to be used in a slice that's effectively a map, + with a composite key made up of the AncestorRef and the ControllerName. + properties: + ancestorRef: + description: |- + AncestorRef corresponds with a ParentRef in the spec that this + PolicyAncestorStatus struct describes the status of. + properties: + group: + default: gateway.networking.k8s.io + description: |- + Group is the group of the referent. + When unspecified, "gateway.networking.k8s.io" is inferred. + To set the core API group (such as for a "Service" kind referent), + Group must be explicitly set to "" (empty string). + + Support: Core + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Gateway + description: |- + Kind is kind of the referent. + + There are two kinds of parent resources with "Core" support: + + * Gateway (Gateway conformance profile) + * Service (Mesh conformance profile, ClusterIP Services only) + + Support for other resources is Implementation-Specific. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name is the name of the referent. + + Support: Core + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referent. When unspecified, this refers + to the local namespace of the Route. + + Note that there are specific rules for ParentRefs which cross namespace + boundaries. Cross-namespace references are only valid if they are explicitly + allowed by something in the namespace they are referring to. For example: + Gateway has the AllowedRoutes field, and ReferenceGrant provides a + generic way to enable any other kind of cross-namespace reference. + + + ParentRefs from a Route to a Service in the same namespace are "producer" + routes, which apply default routing rules to inbound connections from + any namespace to the Service. + + ParentRefs from a Route to a Service in a different namespace are + "consumer" routes, and these routing rules are only applied to outbound + connections originating from the same namespace as the Route, for which + the intended destination of the connections are a Service targeted as a + ParentRef of the Route. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port is the network port this Route targets. It can be interpreted + differently based on the type of parent resource. + + When the parent resource is a Gateway, this targets all listeners + listening on the specified port that also support this kind of Route(and + select this Route). It's not recommended to set `Port` unless the + networking behaviors specified in a Route must apply to a specific port + as opposed to a listener(s) whose port(s) may be changed. When both Port + and SectionName are specified, the name and port of the selected listener + must match both specified values. + + + When the parent resource is a Service, this targets a specific port in the + Service spec. When both Port (experimental) and SectionName are specified, + the name and port of the selected port must match both specified values. + + + Implementations MAY choose to support other parent resources. + Implementations supporting other types of parent resources MUST clearly + document how/if Port is interpreted. + + For the purpose of status, an attachment is considered successful as + long as the parent resource accepts it partially. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment + from the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, + the Route MUST be considered detached from the Gateway. + + Support: Extended + format: int32 + maximum: 65535 + minimum: 1 + type: integer + sectionName: + description: |- + SectionName is the name of a section within the target resource. In the + following resources, SectionName is interpreted as the following: + + * Gateway: Listener name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + * Service: Port name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + + Implementations MAY choose to support attaching Routes to other resources. + If that is the case, they MUST clearly document how SectionName is + interpreted. + + When unspecified (empty string), this will reference the entire resource. + For the purpose of status, an attachment is considered successful if at + least one section in the parent resource accepts it. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from + the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, the + Route MUST be considered detached from the Gateway. + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + conditions: + description: Conditions describes the status of the Policy with + respect to the given Ancestor. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + controllerName: + description: |- + ControllerName is a domain/path string that indicates the name of the + controller that wrote this status. This corresponds with the + controllerName field on GatewayClass. + + Example: "example.net/gateway-controller". + + The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are + valid Kubernetes names + (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). + + Controllers MUST populate this field when writing status. Controllers should ensure that + entries to status populated with their ControllerName are cleaned up when they are no + longer necessary. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ + type: string + required: + - ancestorRef + - controllerName + type: object + maxItems: 16 + type: array + x-kubernetes-list-type: atomic + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index aa200980..7baf00a7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -7,6 +7,7 @@ rules: - apiGroups: - "" resources: + - namespaces - node verbs: - get @@ -94,6 +95,67 @@ rules: - get - patch - update +- apiGroups: + - gateway.networking.k8s.io + resources: + - gatewayclasses + - httproutes + - referencegrants + - tcproutes + - udproutes + verbs: + - get + - list + - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - gatewayclasses/status + - gateways/status + - httproutes/status + - tcproutes/status + - udproutes/status + verbs: + - patch + - update +- apiGroups: + - gateway.networking.k8s.io + resources: + - gateways + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - gateways/finalizers + verbs: + - update +- apiGroups: + - gateway.vks.vngcloud.vn + resources: + - vksbackendpolicies + - vksgatewaypolicies + - vkshealthcheckpolicies + - vksroutepolicies + verbs: + - get + - list + - watch +- apiGroups: + - gateway.vks.vngcloud.vn + resources: + - vksbackendpolicies/status + - vksgatewaypolicies/status + - vkshealthcheckpolicies/status + - vksroutepolicies/status + verbs: + - get + - patch + - update - apiGroups: - networking.k8s.io resources: diff --git a/docs/contributing.md b/docs/contributing.md index 24dadfd9..945998b9 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -6,7 +6,7 @@ We welcome contributions to the VNGCloud Load Balancer Controller! ### Prerequisites -- Go 1.25.10+ +- Go 1.25.11+ - Docker - `kubectl` and access to a Kubernetes cluster - `make` @@ -51,6 +51,12 @@ go clean -testcache && go test -v ./internal/controller/networking/... \ -ginkgo.focus="When node status changes from not ready to ready" ``` +The Gateway API e2e suite provisions **real** VNGCloud ALBs and is opt-in; it needs a real cluster with the controller running: + +```bash +RUN_GATEWAY_E2E=true KUBECONFIG=/path/to/cluster.yaml go test ./test/e2e/gateway/ -v +``` + ## Code Generation After modifying CRD types (`api/v1alpha1/*.go`), regenerate: diff --git a/docs/deploy/configuration.md b/docs/deploy/configuration.md index a4412ccf..8d39f9c3 100644 --- a/docs/deploy/configuration.md +++ b/docs/deploy/configuration.md @@ -109,6 +109,8 @@ Key values: | `manager.manager.image.repository` | Controller image repository | `vcr.vngcloud.vn/81-vks-public/vngcloud-load-balancer-controller` | | `manager.manager.image.tag` | Controller image tag | chart's appVersion | | `manager.replicaCount` | Number of controller replicas | `1` | +| `gatewayApi.alb.enabled` | Enable the [Gateway API (ALB)](../guide/gateway-api.md) controller and install the `vngcloud-alb` GatewayClass. Requires the Gateway-API CRDs (Gateway/HTTPRoute) to be installed first | `false` | +| `gatewayApi.nlb.enabled` | Enable the [Gateway API (NLB / L4)](../guide/gateway-nlb.md) controller and install the `vngcloud-nlb` GatewayClass. Requires the Gateway-API **experimental-channel** CRDs (TCPRoute/UDPRoute) | `false` | ## CLI Flags @@ -130,6 +132,8 @@ The controller binary supports the following flags: | `--disable-node-security-group-controller` | `false` | Disable the NodeSecurityGroup reconciler | | `--disable-vngcloud-global-load-balancer-controller` | `false` | Disable the VngcloudGlobalLoadBalancer reconciler | | `--disable-service-glb-controller` | `false` | Disable the Service GLB reconciler | +| `--disable-alb-gateway-controller` | `true` | Disable the Gateway API (ALB) reconcilers. Disabled by default; needs the Gateway-API CRDs (Gateway/HTTPRoute) | +| `--disable-nlb-gateway-controller` | `true` | Disable the Gateway API (NLB / L4) reconcilers. Disabled by default; needs the experimental TCPRoute/UDPRoute CRDs | ## Environment Variables diff --git a/docs/guide/gateway-api.md b/docs/guide/gateway-api.md new file mode 100644 index 00000000..8c72f272 --- /dev/null +++ b/docs/guide/gateway-api.md @@ -0,0 +1,427 @@ +# Gateway API (ALB) + +The controller implements the [Kubernetes Gateway API](https://gateway-api.sigs.k8s.io/) (v1) for VNGCloud **Application Load Balancers** (L7). It is the successor to the Ingress + annotations path: a `Gateway` + `HTTPRoute` pair produces the same `LoadBalancerConfig` the Ingress controller emits, so both paths drive identical cloud resources. + +```mermaid +flowchart TD + subgraph U["1. User declares (Gateway API CRDs)"] + GC["GatewayClass +vngcloud-alb = L7 / vngcloud-nlb = L4"] + GW["Gateway +spec.listeners: protocol + port"] + RT["Route (parentRef -> Gateway) +HTTPRoute=L7 / TCPRoute,UDPRoute=L4 +matches + backendRefs"] + SVC["Service NodePort = backend"] + PGW["VKSGatewayPolicy -> Gateway +name, scheme, subnet, timeout, cidr, cert"] + PBE["VKSBackendPolicy -> Service +algorithm, stickiness, targetType"] + PHC["VKSHealthCheckPolicy -> Service +HC protocol/interval/path"] + PRT["VKSRoutePolicy -> HTTPRoute +reject/redirect, position"] + end + GC -. gatewayClassName .-> GW + GW -- parentRef --> RT + RT -- backendRef --> SVC + PGW -. targetRef .-> GW + PBE -. targetRef .-> SVC + PHC -. targetRef .-> SVC + PRT -. targetRef .-> RT + REC["2. Controller: combines Gateway+Route+Policies +=> 1 LoadBalancerConfig"] + GW ==> REC + RT ==> REC + SVC ==> REC + PGW ==> REC + PBE ==> REC + PHC ==> REC + PRT ==> REC + LBC["3. LoadBalancerConfig (lbc) = desired state"] + REC ==> LBC + LB["4. vLB: LoadBalancer (L4/L7)"] + LSN["Listener"] + POOL["Pool"] + MEM["Members (nodeIP:NodePort, weight)"] + HM["Health Monitor"] + LBC ==>|calls vLB API| LB + LB --> LSN + LSN --> POOL + POOL --> MEM + POOL --> HM + STATUS["Status: Gateway Programmed+IP / Route Accepted+ResolvedRefs"] + LB -. when ready .-> STATUS + + classDef cLB fill:#cfe2ff,stroke:#0d6efd,stroke-width:2px,color:#000 + classDef cListener fill:#d1e7dd,stroke:#198754,stroke-width:2px,color:#000 + classDef cPool fill:#ffe5d0,stroke:#fd7e14,stroke-width:2px,color:#000 + classDef cMember fill:#e2d9f3,stroke:#6f42c1,stroke-width:2px,color:#000 + classDef cHM fill:#f8d7da,stroke:#dc3545,stroke-width:2px,color:#000 + classDef cCtrl fill:#e9ecef,stroke:#6c757d,stroke-width:1px,color:#000 + classDef cStatus fill:#fff3cd,stroke:#ffc107,stroke-width:1px,color:#000 + class GC,PGW,LB cLB + class GW,LSN cListener + class RT,PBE,PRT,POOL cPool + class SVC,MEM cMember + class PHC,HM cHM + class REC,LBC cCtrl + class STATUS cStatus +``` + +| | | +|---|---| +| GatewayClass | `vngcloud-alb` | +| Controller name | `gateway.vks.vngcloud.vn/alb` | +| Supported routes | `HTTPRoute` | +| Customisation | Four policy CRDs via [GEP-713 direct policy attachment](https://gateway-api.sigs.k8s.io/geps/gep-713/) | + +!!! note "Phase 1 scope" + Current support covers ALB (L7) with `HTTPRoute`. NLB (L4, `Gateway` + TCP/UDP routes), `GRPCRoute`, and `BackendTLSPolicy` are planned for later phases. L4 workloads keep using `Service` of type `LoadBalancer`. + +## Enabling / disabling + +The ALB Gateway controller is **disabled by default** so the chart installs cleanly on clusters that have not installed the Gateway API CRDs. Enable it only after those CRDs are present. The Helm chart installs the `vngcloud-alb` GatewayClass and wires the controller flag from one toggle: + +```yaml +# values.yaml +gatewayApi: + alb: + enabled: true # set true to enable the controller and install the GatewayClass +``` + +The underlying controller flag is `--disable-alb-gateway-controller` (default `true`). + +!!! warning "Gateway API CRDs are a prerequisite" + The cluster must have the [Gateway API standard CRDs](https://gateway-api.sigs.k8s.io/guides/#installing-gateway-api) installed (`Gateway`, `HTTPRoute`, `ReferenceGrant`, ...). The controller does not install them. + +## Quick start + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: vngcloud-alb + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Same +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: my-route + namespace: default +spec: + parentRefs: + - name: my-gateway + rules: + - matches: + - path: {type: PathPrefix, value: /} + backendRefs: + - name: my-service + port: 80 +``` + +The controller provisions an ALB, then reports readiness on the Gateway: + +```bash +kubectl get gateway my-gateway +NAME CLASS ADDRESS PROGRAMMED AGE +my-gateway vngcloud-alb 203.0.113.42 True 3m +``` + +Under the hood each Gateway owns one `LoadBalancerConfig` (see [LoadBalancerConfig CRD](load-balancer-config.md)); inspect it with `kubectl get lbc -l vks.vngcloud.vn/owner-resource-name=my-gateway`. + +## HTTPRoute support matrix + +| HTTPRoute feature | Supported | Translation | +|---|---|---| +| `path: Exact` | ✅ | L7 rule `PATH` / `EQUAL_TO` | +| `path: PathPrefix` | ✅ | L7 rule `PATH` / `STARTS_WITH` | +| `path: RegularExpression` | ✅ | L7 rule `PATH` / `REGEX` | +| `hostnames` | ✅ | L7 rule `HOST_NAME` | +| Weighted `backendRefs` | ✅ | One pool, member weights scaled to the ratio (`weight: 0` excludes the backend) | +| Cross-namespace `backendRefs` | ✅ | Requires a [`ReferenceGrant`](#cross-namespace-backends) | +| `RequestRedirect` filter | ✅ | Policy action `REDIRECT_TO_URL` | +| `headers` / `queryParams` / `method` matches | ❌ | Rule is dropped; the route reports `PartiallyInvalid` | +| Other filters (`URLRewrite`, `RequestHeaderModifier`, ...) | ❌ | Not expressible on the VNGCloud LB API | + +## Policy CRDs (GEP-713) + +LB-specific knobs that the Gateway API does not model are attached with four namespaced policy CRDs in group `gateway.vks.vngcloud.vn/v1alpha1`. Each policy selects its target with `targetRefs`: + +| CRD | Targets | Configures | +|---|---|---| +| `VKSGatewayPolicy` | `Gateway` (optionally one listener via `sectionName`) | LB creation spec, listener timeouts, allowed CIDRs, inserted headers, certificates | +| `VKSBackendPolicy` | `Service` | Pool algorithm, stickiness, member target type, TLS to backend | +| `VKSHealthCheckPolicy` | `Service` | Health monitor of the pools fed by the Service | +| `VKSRoutePolicy` | `HTTPRoute` (optionally one rule via `sectionName` = rule `name`) | Rule action override (Reject / Redirect), rule position | + +### VKSGatewayPolicy + +```yaml +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: + name: my-gateway-policy + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: my-gateway + # sectionName: https # optional: scope to one listener + loadBalancerSpec: + scheme: Internet # Internet | Internal | InterVPC + packageId: "lbp-..." # LB package (size) + loadBalancerName: my-custom-lb # create-only; default = generated name + subnetId: "sub-..." # explicit subnet + preferZoneId: "HCM03-1A" # pin the LB to a zone (lower precedence than subnetId) + loadBalancerId: "lb-..." # adopt an existing LB instead of creating one + privateSubnetId: "sub-..." # InterVPC client subnet + enableAutoscale: false + isPOC: false + tags: {team: platform} + timeoutClient: 50s + timeoutMember: 50s + timeoutConnection: 5s + allowedCidrs: ["0.0.0.0/0"] + insertHeaders: {X-Forwarded-Proto: "true"} + certificateIds: ["cert-..."] # pre-imported VNGCloud certificate IDs + clientCertificateId: "cert-..." # mTLS client CA +``` + +An **unscoped** policy (no `sectionName`) applies to the whole Gateway and is the only place `loadBalancerSpec` is read. A policy scoped with `sectionName` overrides listener-level fields (timeouts, CIDRs, headers, certificates) for that listener only. + +!!! warning "Create-only fields" + `loadBalancerName`, `subnetId`, `preferZoneId`, and `loadBalancerId` are resolved **once, when the LB is first created**. Apply the `VKSGatewayPolicy` *before* the Gateway; changing these fields later has no effect on an existing LB. All other fields reconcile continuously. + +**Subnet/zone precedence:** `loadBalancerId` (adopt — subnet/zone follow the existing LB) → `subnetId` → `preferZoneId` (a cluster node in that zone must exist) → cluster default. An unresolvable value (bogus LB ID, zone with no nodes) **fails closed**: no LB is created and the error is logged, rather than silently falling back to the default subnet. + +**Bring your own LB:** set `loadBalancerSpec.loadBalancerId` to adopt an existing ALB. The controller then manages listeners/pools/policies on that LB instead of provisioning a new one. On Gateway deletion only controller-created resources are cleaned up — the LB itself is deleted only when *everything* on it was created by the controller; pre-existing listeners keep the LB alive. + +### VKSBackendPolicy + +```yaml +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: + name: my-backend-policy + namespace: default +spec: + targetRefs: + - {group: "", kind: Service, name: my-service} + targetType: instance # instance (node IP + nodePort) | ip (pod IPs); default auto-detected from CNI + poolAlgorithm: ROUND_ROBIN # ROUND_ROBIN | LEAST_CONNECTIONS | SOURCE_IP + stickiness: true + enableTLSEncryption: false + targetNodeLabels: {pool: edge} # instance mode: only nodes matching these labels become members +``` + +### VKSHealthCheckPolicy + +```yaml +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSHealthCheckPolicy +metadata: + name: my-healthcheck + namespace: default +spec: + targetRefs: + - {group: "", kind: Service, name: my-service} + protocol: HTTP # HTTP | HTTPS | TCP + interval: 30s + timeout: 5s + healthyThreshold: 3 + unhealthyThreshold: 3 + port: 8081 # probe port override; default = each member's traffic port + httpHealthCheck: # HTTP/HTTPS only + path: /healthz + host: app.example.com + method: GET # GET | PUT | POST + httpVersion: "1.1" # "1.0" | "1.1" + expectedCodes: ["200"] +``` + +!!! note "Weighted rules need consistent backend policies" + When a rule has multiple `backendRefs`, they merge into **one** cloud pool, which can carry only one backend/health-check configuration. All backends of such a rule must resolve to the *same* `VKSBackendPolicy`/`VKSHealthCheckPolicy` objects (target both Services from one policy via multiple `targetRefs`). Divergent policies fail the rule closed and the route reports `ResolvedRefs: False` with reason `BackendConfigMismatch`. + +### VKSRoutePolicy + +```yaml +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSRoutePolicy +metadata: + name: block-admin + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: my-route + sectionName: admin # the HTTPRoute rule's `name` + position: 10 # explicit L7 policy position on the listener + actions: + - type: Reject # Reject | Redirect + # redirect: + # url: https://moved.example.com + # httpCode: 302 + # keepQueryString: true +``` + +Without a `VKSRoutePolicy`, a routed rule defaults to `REDIRECT_TO_POOL` (forward to its backend pool). + +## Policy scope & multi-Gateway exposure + +The four CRDs follow GEP-713 **direct policy attachment**: a policy's effect is bound to the single object its `targetRefs` names and must not leak beyond it. The attachment layer fixes the scope: + +| Policy | Scope of effect | +|---|---| +| `VKSGatewayPolicy` | the LB / one listener | +| `VKSBackendPolicy`, `VKSHealthCheckPolicy` | a **Service** — i.e. *every* pool fed by that Service, on *every* Gateway that routes to it | +| `VKSRoutePolicy` | a route / one rule | + +The consequence to internalise: **backend configuration attaches to the Service, so it is global to that Service.** This mirrors upstream [`BackendTLSPolicy`](https://gateway-api.sigs.k8s.io/geps/gep-1897/) (GEP-1897), which configures the backend connection *"when [the Service] is used as a backend by a Route"* — for **all** such routes. Gateway API deliberately has no per-route / per-Gateway backend policy, and [GEP-713](https://gateway-api.sigs.k8s.io/geps/gep-713/) tells implementations **not** to key policy off a `backendRef` (it is not unique across a route's rules and is a link to another object). The only standardised sub-scope on a Service is its **port** (`sectionName`), which these CRDs do not yet use. + +### Expose one app through an external **and** internal Gateway + +Two Gateways — one `Internet`, one `Internal` — with the route attached to both. The scheme is per-Gateway, so it lives on two `VKSGatewayPolicy` objects: + +```yaml +# one HTTPRoute (NLB: TCPRoute/UDPRoute) attached to both Gateways +parentRefs: [{name: gw-ext}, {name: gw-int}] +# ... rules / backendRefs ... +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: ext, namespace: demo} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: Gateway, name: gw-ext}] + loadBalancerSpec: {scheme: Internet} +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: int, namespace: demo} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: Gateway, name: gw-int}] + loadBalancerSpec: {scheme: Internal} +``` + +If both LBs want the **same** backend behaviour, a single Service + a single `VKSBackendPolicy` serves both. + +### Different backend behaviour per Gateway → different Service + +Because backend policy is service-global, you **cannot** give one Service a different `targetType` (or algorithm, stickiness, …) per Gateway. When two frontends genuinely need different backend behaviour, that is two backend *intents* — model it as **two Services with the same pod selector**, each with its own `VKSBackendPolicy`: + +```yaml +apiVersion: v1 +kind: Service +metadata: {name: app-ext, namespace: demo} +spec: {type: NodePort, selector: {app: myapp}, ports: [{port: 80, targetPort: 80}]} +--- +apiVersion: v1 +kind: Service +metadata: {name: app-int, namespace: demo} +spec: {type: ClusterIP, selector: {app: myapp}, ports: [{port: 80, targetPort: 80}]} +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: {name: ext-instance, namespace: demo} +spec: {targetRefs: [{group: "", kind: Service, name: app-ext}], targetType: instance} +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: {name: int-ip, namespace: demo} +spec: {targetRefs: [{group: "", kind: Service, name: app-int}], targetType: ip} +``` + +`gw-ext`'s route targets `app-ext`; `gw-int`'s targets `app-int`. This is the Gateway-API-idiomatic answer, not a workaround — "different backend behaviour" is expressed as "different backend object", consistent with direct policy attachment. + +## Conflict resolution & policy status + +When several policies of the same kind target the same object, the **oldest** (by `creationTimestamp`, then alphabetical) wins — per GEP-713. Every policy reports its outcome in `status`: + +| `Accepted` condition | Meaning | +|---|---| +| `True` / `Accepted` | Policy is attached and applied | +| `False` / `Conflicted` | An older policy owns the target | +| `False` / `TargetNotFound` | The referenced target does not exist | + +```bash +kubectl get vksgatewaypolicy my-gateway-policy -o jsonpath='{.status.conditions}' +``` + +## Route status + +The controller writes per-parent status on every attached `HTTPRoute`: + +| Condition | Reasons | +|---|---| +| `Accepted` | `Accepted`, `NotAllowedByListeners`, `NoMatchingListenerHostname` | +| `ResolvedRefs` | `ResolvedRefs`, `InvalidKind` (non-Service backend), `BackendNotFound`, `RefNotPermitted` (cross-ns without a ReferenceGrant), `BackendConfigMismatch` | +| `PartiallyInvalid` | `UnsupportedValue` — at least one rule used an unsupported match dimension and was dropped | + +## Cross-namespace backends + +A route may reference a Service in another namespace only when a `ReferenceGrant` in the **backend's** namespace permits it: + +```yaml +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + name: allow-routes + namespace: backend-ns # where the Service lives +spec: + from: + - group: gateway.networking.k8s.io + kind: HTTPRoute + namespace: app-ns # where the HTTPRoute lives + to: + - group: "" + kind: Service +``` + +Without the grant the backend is excluded and the route reports `ResolvedRefs: False / RefNotPermitted`. Deleting the grant revokes access on the next reconcile. + +Listeners control which namespaces may attach routes via `allowedRoutes.namespaces.from`: `Same` (default), `All`, or `Selector` (label selector on the route's Namespace). + +## TLS termination + +Reference a `kubernetes.io/tls` Secret from the listener; the controller imports it into VNGCloud certificate storage: + +```yaml +listeners: + - name: https + protocol: HTTPS + port: 443 + tls: + mode: Terminate + certificateRefs: + - name: my-tls-secret +``` + +Alternatively, use pre-imported certificates by ID via `VKSGatewayPolicy.spec.certificateIds` (scoped per listener with `sectionName`); IDs take precedence over Secret refs. + +## Ingress annotation equivalents + +| Ingress annotation (`vks.vngcloud.vn/*`) | Gateway API equivalent | +|---|---| +| `scheme` | `VKSGatewayPolicy.loadBalancerSpec.scheme` | +| `package-id` | `VKSGatewayPolicy.loadBalancerSpec.packageId` | +| `load-balancer-name` | `VKSGatewayPolicy.loadBalancerSpec.loadBalancerName` | +| `load-balancer-id` | `VKSGatewayPolicy.loadBalancerSpec.loadBalancerId` | +| `subnet-id` / `prefer-zone-id` / `private-subnet-id` | `VKSGatewayPolicy.loadBalancerSpec.{subnetId,preferZoneId,privateSubnetId}` | +| `enable-autoscale` / `is-poc` / `tags` | `VKSGatewayPolicy.loadBalancerSpec.{enableAutoscale,isPOC,tags}` | +| `idle-timeout-client/-member/-connection` | `VKSGatewayPolicy.timeout{Client,Member,Connection}` | +| `inbound-cidrs` | `VKSGatewayPolicy.allowedCidrs` | +| `certificate-ids` | `VKSGatewayPolicy.certificateIds` | +| `pool-algorithm` / `enable-sticky-session` / `target-type` | `VKSBackendPolicy.{poolAlgorithm,stickiness,targetType}` | +| `healthcheck-*` (protocol, interval, timeout, thresholds, port, path, http-method, http-version, success-codes) | `VKSHealthCheckPolicy` | +| Spec TLS section | Listener `tls.certificateRefs` | diff --git a/docs/guide/gateway-nlb-haproxy-realip.md b/docs/guide/gateway-nlb-haproxy-realip.md new file mode 100644 index 00000000..6db43760 --- /dev/null +++ b/docs/guide/gateway-nlb-haproxy-realip.md @@ -0,0 +1,154 @@ +# Real client IP: NLB (Gateway API) + HAProxy + PROXY protocol + +A common production pattern: front an HTTP ingress controller with a VNGCloud **NLB** (L4) and still have the application see the **real client IP**. Because an L4 load balancer SNATs the source address, the app would otherwise only see a cluster-internal IP. The fix is **PROXY protocol**: the NLB prepends a header carrying the original client IP, and the ingress controller (HAProxy here) reads it and forwards it as `X-Forwarded-For`. + +``` +client (1.2.3.4) + │ TCP :80 + ▼ +[ VNGCloud NLB ] Gateway(vngcloud-nlb) + TCPRoute + │ PROXY protocol ← VKSBackendPolicy.proxyProtocol: true + ▼ +[ HAProxy ingress ] accept-proxy → recovers 1.2.3.4 → sets X-Forwarded-For + │ HTTP (host/path routing via Ingress) + ▼ +[ app ] sees X-Forwarded-For: 1.2.3.4 +``` + +!!! note "Prerequisites" + - The [NLB Gateway controller](gateway-nlb.md) enabled (`gatewayApi.nlb.enabled=true`) and the experimental `TCPRoute` CRD installed. + - The `vngcloud-nlb` GatewayClass `Accepted`. + +## 1. Install HAProxy ingress controller + +Install it as a **NodePort** Service (not `LoadBalancer`, otherwise the Service controller would provision a *second* LB), and configure it to accept PROXY protocol from the NLB. The connection reaches HAProxy from the node/pod network after node-port forwarding, so trust those ranges (tighten to your node/LB CIDR in production): + +```yaml +# haproxy-values.yaml +controller: + kind: Deployment + replicaCount: 2 + service: + type: NodePort + config: + proxy-protocol: "10.0.0.0/8,172.16.0.0/12" +``` + +```bash +helm repo add haproxytech https://haproxytech.github.io/helm-charts +helm install haproxy-ingress haproxytech/kubernetes-ingress \ + -n haproxy-ingress --create-namespace -f haproxy-values.yaml +``` + +This creates the controller Service (e.g. `haproxy-ingress-kubernetes-ingress`, ports `80`/`443`) and the `haproxy` IngressClass. + +## 2. Deploy an app + Ingress + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: {name: whoami, namespace: realip-lab} +spec: + replicas: 1 + selector: {matchLabels: {app: whoami}} + template: + metadata: {labels: {app: whoami}} + spec: + containers: [{name: whoami, image: traefik/whoami:v1.10, ports: [{containerPort: 80}]}] +--- +apiVersion: v1 +kind: Service +metadata: {name: whoami, namespace: realip-lab} +spec: {selector: {app: whoami}, ports: [{port: 80, targetPort: 80}]} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: {name: whoami, namespace: realip-lab} +spec: + ingressClassName: haproxy + rules: + - host: whoami.lab + http: + paths: + - {path: /, pathType: Prefix, backend: {service: {name: whoami, port: {number: 80}}}} +``` + +## 3. Front HAProxy with an NLB (Gateway API + PROXY protocol) + +Apply the policies **before** the Gateway — `proxyProtocol` and the LB scheme are resolved when the pool/LB is first created (the cloud pool protocol is immutable afterward): + +```yaml +# PROXY protocol to the HAProxy controller Service +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: {name: haproxy-proxyproto, namespace: haproxy-ingress} +spec: + targetRefs: [{group: "", kind: Service, name: haproxy-ingress-kubernetes-ingress}] + proxyProtocol: true +--- +# Internet-facing NLB +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: nlb-lbspec, namespace: haproxy-ingress} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: Gateway, name: realip-nlb}] + loadBalancerSpec: {scheme: Internet} +``` + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: realip-nlb, namespace: haproxy-ingress} +spec: + gatewayClassName: vngcloud-nlb + listeners: [{name: tcp, protocol: TCP, port: 80, allowedRoutes: {namespaces: {from: Same}}}] +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TCPRoute +metadata: {name: realip-route, namespace: haproxy-ingress} +spec: + parentRefs: [{name: realip-nlb, sectionName: tcp}] + rules: + - backendRefs: [{name: haproxy-ingress-kubernetes-ingress, port: 80}] +``` + +The generated `LoadBalancerConfig` has a `Layer 4` LB with a `PROXY` pool: + +```bash +kubectl -n haproxy-ingress get loadbalancerconfig \ + -l gateway.vks.vngcloud.vn/owner-uid \ + -o jsonpath='{.items[0].spec.type}{" "}{.items[0].spec.pools[0].protocol}{"\n"}' +# Layer 4 PROXY +``` + +Wait for the NLB address: + +```bash +kubectl -n haproxy-ingress get gateway realip-nlb \ + -o jsonpath='{.status.conditions[?(@.type=="Programmed")].status} {.status.addresses[0].value}{"\n"}' +# True 180.93.181.199 +``` + +## 4. Verify + +```bash +curl -s -H "Host: whoami.lab" http://180.93.181.199/ +``` + +``` +Hostname: whoami-54dd685ffc-xlhwf +RemoteAddr: 172.17.1.122:36898 # HAProxy pod → whoami +... +X-Forwarded-For: 49.213.89.8 # ← the REAL client IP (your egress IP) +``` + +`X-Forwarded-For` is your public client IP, not a cluster-internal `10.x`/`172.x` address — PROXY protocol is working end-to-end. + +## Notes + +- **Why PROXY protocol?** An L4 NLB SNATs the source address, so without it the app only sees the node/HAProxy IP. PROXY protocol carries the original client IP at the TCP layer; HAProxy decodes it and sets `X-Forwarded-For` for HTTP backends. +- **Set `proxyProtocol` before creating the Gateway.** The cloud pool protocol is fixed at create time. To change it on a running LB, recreate the Gateway. +- **Trust ranges.** HAProxy's `proxy-protocol` config lists the source ranges allowed to send the header. This example uses broad private ranges for simplicity; scope it to your node/LB CIDR in production. A backend that does **not** accept PROXY protocol will reject the connection. +- **Health checks.** The NLB health-checks the member with a plain TCP connect (no PROXY header); HAProxy accepts the connection, so members stay healthy. +- **HTTPS.** Add a second listener `{name: https, protocol: TCP, port: 443}` and a `TCPRoute` to the controller Service port `443` to pass TLS through to HAProxy (HAProxy terminates TLS). Keep `proxyProtocol: true` on the same backend Service. +- **targetType.** Defaults to `instance` (node IP + nodePort). On a flat/native-routing CNI you can set `VKSBackendPolicy.targetType: ip` to target HAProxy pods directly. diff --git a/docs/guide/gateway-nlb.md b/docs/guide/gateway-nlb.md new file mode 100644 index 00000000..01b4514f --- /dev/null +++ b/docs/guide/gateway-nlb.md @@ -0,0 +1,147 @@ +# Gateway API (NLB / L4) + +Phase 2 extends [Gateway API](gateway-api.md) support to VNGCloud **Network Load Balancers** (L4). A `Gateway` under the `vngcloud-nlb` GatewayClass with `TCPRoute`/`UDPRoute` produces a Layer-4 `LoadBalancerConfig` — the same CR the `Service` type=LoadBalancer path emits — so the existing LBC controller drives the cloud NLB. + +| | | +|---|---| +| GatewayClass | `vngcloud-nlb` | +| Controller name | `gateway.vks.vngcloud.vn/nlb` | +| Listener protocols | `TCP`, `UDP` | +| Supported routes | `TCPRoute`, `UDPRoute` | + +!!! warning "Experimental-channel CRDs are a prerequisite" + `TCPRoute` and `UDPRoute` live only in the Gateway API **experimental** channel and are **not** installed by the standard-channel manifests used for the ALB path. Install them before enabling: + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml + ``` + + Because of this, the NLB controller is **disabled by default**. The controller does not embed or install these CRDs. + +!!! note "Phase 2 scope" + TCP and UDP routing are supported. `TLSRoute` (SNI passthrough) is planned for a follow-up. Mixed L4+L7 on one Gateway is not supported — HTTP/HTTPS listeners on an `vngcloud-nlb` Gateway are reported `UnsupportedProtocol` and skipped (use the `vngcloud-alb` class for L7). + +## Enabling + +```yaml +# values.yaml +gatewayApi: + nlb: + enabled: true # installs the vngcloud-nlb GatewayClass + enables the controller +``` + +This sets `--disable-nlb-gateway-controller=false`. Only enable it on clusters that have the experimental CRDs installed. + +## Quick start + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: tcp-gateway + namespace: default +spec: + gatewayClassName: vngcloud-nlb + listeners: + - name: redis + protocol: TCP + port: 6379 + allowedRoutes: + namespaces: + from: Same +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TCPRoute +metadata: + name: redis-route + namespace: default +spec: + parentRefs: + - name: tcp-gateway + sectionName: redis # optional: pin to a listener (also matchable by port) + rules: + - backendRefs: + - name: redis + port: 6379 +``` + +A `UDPRoute` is identical with `kind: UDPRoute` and a `UDP` listener. + +```bash +kubectl get gateway tcp-gateway +NAME CLASS ADDRESS PROGRAMMED AGE +tcp-gateway vngcloud-nlb 203.0.113.50 True 3m +``` + +## How routes map to listeners + +Each Gateway TCP/UDP listener gets **one default pool**, built from the backendRefs of the route attached to it: + +- A `TCPRoute` attaches to a `TCP` listener; a `UDPRoute` to a `UDP` listener. +- A `parentRef` attaches to a listener when its `sectionName` (if set) names the listener **and** its `port` (if set) equals the listener's port. With neither, it attaches to every compatible listener. +- If several routes target one listener, the **oldest** (by creationTimestamp) wins — an L4 listener has a single default pool. +- A listener with no attached route produces no cloud listener (an L4 listener has nothing to forward without a backend). + +Weighted backendRefs across multiple Services are aggregated into one pool with scaled member weights, same as the ALB path. + +## Policy CRDs + +The L4 path honors three of the four [policy CRDs](gateway-api.md#policy-crds-gep-713): + +| CRD | Targets | L4 effect | +|---|---|---| +| `VKSGatewayPolicy` | `Gateway` | LB creation spec (scheme, package, name, subnet/zone, InterVPC private subnet + zone, BYO-LB adoption, tags), listener timeouts, allowed CIDRs | +| `VKSBackendPolicy` | `Service` | Pool algorithm, stickiness, member target type (`instance`/`ip`), node-label selection, `proxyProtocol` (PROXY protocol to the backend — see below) | +| `VKSHealthCheckPolicy` | `Service` | Health monitor — TCP (default), PING-UDP (UDP pools), or an HTTP/HTTPS probe; interval/timeout/thresholds/port | + +`VKSRoutePolicy` does **not** apply to L4 — there are no L7 actions (Reject/Redirect) or path/host matching on a TCP/UDP listener. `insertHeaders`, certificates, and TLS termination are also L7-only and ignored here. + +Health-check defaults: a TCP listener probes its members over **TCP**; a UDP listener uses **PING-UDP** (mirrors the Service controller). A `VKSHealthCheckPolicy` can override the protocol (including an HTTP/HTTPS probe over a TCP pool) and the thresholds/interval/timeout/port. + +!!! tip "One app behind an external **and** internal NLB" + `VKSBackendPolicy` attaches to a **Service**, so its `targetType` is global to that Service across every Gateway. To front one app with both an `Internet` and an `Internal` NLB — especially with a different `targetType` per LB — see [Policy scope & multi-Gateway exposure](gateway-api.md#policy-scope-multi-gateway-exposure). + +### PROXY protocol (real client IP) + +`VKSBackendPolicy.proxyProtocol: true` switches a **TCP** pool to the vngcloud **PROXY** pool protocol, so the NLB prepends a PROXY header and an L4 backend (an HAProxy/nginx ingress controller, a TCP proxy, ...) can recover the real client IP behind the NLB. UDP pools ignore it. Mirrors the Service controller's `vks.vngcloud.vn/enable-proxy-protocol` annotation. + +```yaml +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: {name: backend-proxyproto, namespace: default} +spec: + targetRefs: [{group: "", kind: Service, name: my-l4-backend}] + proxyProtocol: true +``` + +!!! warning "Set proxyProtocol before the Gateway is created" + The cloud pool protocol is fixed at creation. Apply the `VKSBackendPolicy` *before* the Gateway so the pool is created as `PROXY` from the start; toggling it on a live pool requires recreating the Gateway. The backend must be configured to **accept** PROXY protocol (e.g. HAProxy ingress `proxy-protocol` config), otherwise it will reject connections. + +See [Real client IP with NLB + HAProxy](gateway-nlb-haproxy-realip.md) for an end-to-end example. + +### InterVPC scheme + +An `InterVPC` LB is reachable from a client subnet in a **different VPC**. Set `scheme: InterVPC` plus the client subnet's ID and zone — `privateZoneId` is required because the client subnet lives in another VPC. Mirrors the Service controller's `private-subnet-id` / `private-zone-id` annotations. + +```yaml +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: intervpc-lb, namespace: default} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: Gateway, name: my-nlb}] + loadBalancerSpec: + scheme: InterVPC + privateSubnetId: subnet-xxxxxxxx # client subnet in the other VPC + privateZoneId: HCM03-1A # zone of that client subnet +``` + +Like the other LB-creation fields, these are applied when the LB is first created. + +## Status + +- **Gateway** — `Accepted` (always, we own the class) and `Programmed` (reflects LBC readiness); `status.addresses` carries the NLB IP. Per-listener `Accepted` is `UnsupportedProtocol` for any HTTP/HTTPS listener. +- **TCPRoute / UDPRoute** — `Accepted` (attached to a listener) and `ResolvedRefs` (`ResolvedRefs`, `BackendNotFound`, or `RefNotPermitted` for a cross-namespace backend without a [`ReferenceGrant`](gateway-api.md#cross-namespace-backends)). + +## Relationship to `Service` type=LoadBalancer + +Both produce a Layer-4 `LoadBalancerConfig` and reconcile through the same LBC controller. The Gateway path is declarative routing config (Gateway + routes + policies) instead of a `Service` plus `vks.vngcloud.vn/*` annotations. Pick one per workload; they don't share an LB. diff --git a/docs/index.md b/docs/index.md index 290b8028..588dbcaf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,6 +8,7 @@ It watches Kubernetes `Service` (type `LoadBalancer`) and `Ingress` resources an - **L4 Load Balancing** — Automatically provisions Network Load Balancers for `Service` resources of type `LoadBalancer` - **L7 Load Balancing** — Manages Application Load Balancers via Kubernetes `Ingress` resources +- **Gateway API** — Provisions Application Load Balancers from `Gateway`/`HTTPRoute` resources (and Network Load Balancers from `TCPRoute`/`UDPRoute`), customised via GEP-713 policy CRDs (`VKSGatewayPolicy`, `VKSBackendPolicy`, `VKSHealthCheckPolicy`, `VKSRoutePolicy`) - **LoadBalancerConfig CRD** — Fine-grained control over load balancer configuration (listeners, pools, policies, certificates) - **NodeSecurityGroup CRD** — Manages security group rules for cluster nodes - **Global Load Balancer** — Multi-region traffic distribution via the `VngcloudGlobalLoadBalancer` CRD diff --git a/docs/superpowers/plans/2026-04-30-gateway-api-phase1.md b/docs/superpowers/plans/2026-04-30-gateway-api-phase1.md deleted file mode 100644 index 49107fff..00000000 --- a/docs/superpowers/plans/2026-04-30-gateway-api-phase1.md +++ /dev/null @@ -1,3822 +0,0 @@ -# Gateway API Support — Phase 1 (L7 MVP) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship a Gateway API L7 controller that provisions a vngcloud ALB per `Gateway`, programs listeners and policies from `HTTPRoute`s, with two extension CRDs (`TargetGroupConfig`, `ListenerRuleConfig`) for per-Service and per-route configuration. - -**Architecture:** New `vngcloud-alb` GatewayClass; reconcilers in `internal/controller/gateway/alb/`; business logic in `internal/usecase/gateway_uc/alb_gateway_uc/`; reuses existing `K8sRepository`, `VngCloudRepository`, `FinalizerManager`, cert-import path, and `lbc_uc` listener/pool/policy deploy helpers. Gateway is the only writer to vngcloud LB; HTTPRoute reconciles enqueue the parent Gateway. - -**Tech Stack:** Go 1.25, controller-runtime 0.19, kubebuilder v4, sigs.k8s.io/gateway-api v1.2, ginkgo+gomega+envtest, gomock, vngcloud-go-sdk/v2. - -**Spec reference:** `docs/superpowers/specs/2026-04-30-gateway-api-design.md` - -**Out of scope for Phase 1:** -- NLB GatewayClass / TCP / UDP / TLS-Passthrough (Phase 2) -- GRPCRoute / BackendTLSPolicy / URLRewrite / Mirror / ResponseHeaderModifier (Phase 3) -- Migration tool, conformance test job, CRD promotion to v1beta1 (Phase 4) - ---- - -## File map (Phase 1) - -| File | Role | -|---|---| -| `go.mod`, `go.sum` | Add `sigs.k8s.io/gateway-api v1.2.0` | -| `PROJECT` | Register new resources | -| `api/v1alpha1/loadbalancerconfig_types.go` | Add `MergingMode` on spec; `SSLPolicy`, `ALPNPolicy` on `Listener` | -| `api/gateway/v1alpha1/groupversion_info.go` | New API group registration | -| `api/gateway/v1alpha1/targetgroupconfig_types.go` | TGC types | -| `api/gateway/v1alpha1/listenerruleconfig_types.go` | LRC types | -| `internal/domain/domain.go` | New finalizer + owner-kind constants | -| `pkg/consts/consts.go` | GatewayClass controller-name constants | -| `pkg/gateway/gatewayapi_utils.go` | Hostname matchers, wildcard→regex, helpers | -| `pkg/gateway/synth_pool.go` | Deterministic pool naming | -| `internal/controller/gateway/shared/classifier.go` | Listener-protocol/GatewayClass validation | -| `internal/controller/gateway/shared/status.go` | Condition helpers | -| `internal/controller/gateway/shared/policy_order.go` | Match-specificity ordering | -| `internal/controller/gateway/shared/reference_indexer.go` | Reverse indexes | -| `internal/controller/gateway/shared/finalizer.go` | Finalizer add/remove helpers | -| `internal/controller/gateway/shared/eventhandlers/*.go` | Cross-resource enqueue helpers | -| `internal/usecase/gateway_uc/shared/merge_config.go` | LBC merging | -| `internal/usecase/gateway_uc/shared/refgrant.go` | ReferenceGrant evaluation | -| `internal/usecase/gateway_uc/shared/tgc_resolver.go` | TargetGroupConfig cascade | -| `internal/usecase/gateway_uc/shared/lrc_resolver.go` | ListenerRuleConfig resolver | -| `internal/usecase/gateway_uc/alb_gateway_uc/gateway_uc.go` | UseCase entry (Init/Ensure/Delete) | -| `internal/usecase/gateway_uc/alb_gateway_uc/build_lb.go` | Gateway → LB params | -| `internal/usecase/gateway_uc/alb_gateway_uc/build_listener.go` | Listener building | -| `internal/usecase/gateway_uc/alb_gateway_uc/build_cert.go` | Secret import / cert ID | -| `internal/usecase/gateway_uc/alb_gateway_uc/build_pool.go` | Synthetic pool, weight scaling | -| `internal/usecase/gateway_uc/alb_gateway_uc/build_policy.go` | Policy generation | -| `internal/usecase/gateway_uc/alb_gateway_uc/build_sec_group.go` | NSG behavior | -| `internal/usecase/gateway_uc/alb_gateway_uc/status.go` | Status writers | -| `internal/usecase/contracts.go` | New use-case interfaces | -| `internal/controller/gateway/alb/gatewayclass_controller.go` | GatewayClass reconciler | -| `internal/controller/gateway/alb/gateway_controller.go` | Gateway reconciler | -| `internal/controller/gateway/alb/httproute_controller.go` | HTTPRoute reconciler | -| `internal/controller/gateway/alb/suite_test.go` | envtest harness | -| `internal/controller/gateway/targetgroupconfig/controller.go` | TGC validator | -| `internal/controller/gateway/listenerruleconfig/controller.go` | LRC validator | -| `pkg/metrics/util/reconcile_counters.go` | New counter methods | -| `cmd/main.go` | Scheme, flags, reconciler registration | -| `charts/.../templates/crds/*.yaml` | CRD bundles | -| `charts/.../templates/gatewayclass-alb.yaml` | Default GatewayClass | -| `charts/.../templates/rbac/*.yaml`, `values.yaml`, `manager-deployment.yaml` | RBAC + flags | -| `config/samples/gateway_*.yaml` | Sample manifests | -| `docs/guide/gateway-api.md` | Overview | -| `docs/guide/gateway-alb.md` | L7 walkthrough | -| `docs/guide/gateway-extensions.md` | TGC + LRC docs | -| `docs/examples/gateway-canary.md` | Weighted-backend example | -| `test/e2e/gateway/*_test.go` | 5 e2e tests | - ---- - -## Block A — Foundation (deps, types, scaffolding) - -### Task A1: Add Gateway API Go module dependency - -**Files:** -- Modify: `go.mod` -- Modify: `go.sum` - -- [ ] **Step 1: Add the dependency** - -Run: `go get sigs.k8s.io/gateway-api@v1.2.0` - -Expected: `go.mod` updated with `sigs.k8s.io/gateway-api v1.2.0`; `go.sum` updated. - -- [ ] **Step 2: Run go mod tidy** - -Run: `go mod tidy` - -Expected: no error; transitive deps resolved. - -- [ ] **Step 3: Verify build still passes** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add go.mod go.sum -git commit -m "build: add sigs.k8s.io/gateway-api v1.2.0 dependency" -``` - ---- - -### Task A2: Add domain constants for Gateway - -**Files:** -- Modify: `internal/domain/domain.go` -- Modify: `pkg/consts/consts.go` - -- [ ] **Step 1: Add finalizer + label + owner-kind constants** - -Edit `internal/domain/domain.go` — append to the existing `Finalizers` const block: - -```go -GatewayFinalizer = "gateway.vks.vngcloud.vn/resources" -GatewayRouteFinalizer = "gateway.vks.vngcloud.vn/route" -``` - -Append to the existing `Annotations` const block: - -```go -GATEWAY_API_PREFIX = "gateway.vks.vngcloud.vn" -``` - -Append to the existing kind constants block: - -```go -KindGateway = "Gateway" -KindGatewayClass = "GatewayClass" -KindHTTPRoute = "HTTPRoute" -``` - -Append a new constants block: - -```go -// Gateway API controller names — must match GatewayClass.spec.controllerName. -const ( - ControllerNameALB = "gateway.vks.vngcloud.vn/alb" - ControllerNameNLB = "gateway.vks.vngcloud.vn/nlb" -) - -// Owner-uid label for Gateway-controlled vngcloud resources. -const ( - LabelGatewayOwnerUID = "gateway.vks.vngcloud.vn/owner-uid" -) -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./internal/domain/...` - -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add internal/domain/domain.go -git commit -m "feat(domain): add Gateway API finalizer, label, controller-name constants" -``` - ---- - -### Task A3: Extend `LoadBalancerConfig` types (MergingMode + SSLPolicy/ALPNPolicy) - -**Files:** -- Modify: `api/v1alpha1/loadbalancerconfig_types.go` -- Test: `api/v1alpha1/loadbalancerconfig_types_test.go` (new — type validation) - -- [ ] **Step 1: Add `MergingMode` enum and field** - -In `api/v1alpha1/loadbalancerconfig_types.go`, above the existing `LoadBalancerConfigSpec` struct, add: - -```go -// MergingMode controls how a GatewayClass-level LoadBalancerConfig merges with a -// Gateway-level one. Only honored when this LBC is referenced by GatewayClass.parametersRef. -// +kubebuilder:validation:Enum=PreferGateway;PreferGatewayClass -type MergingMode string - -const ( - MergingModePreferGateway MergingMode = "PreferGateway" - MergingModePreferGatewayClass MergingMode = "PreferGatewayClass" -) -``` - -Inside `LoadBalancerConfigSpec`, append (before the closing brace): - -```go -// MergingMode controls how this LBC merges with another LBC when both -// GatewayClass.parametersRef and Gateway.spec.infrastructure.parametersRef are set. -// Only honored when this object is referenced by GatewayClass.parametersRef. Defaults to PreferGateway. -// +optional -MergingMode *MergingMode `json:"mergingMode,omitempty"` -``` - -Inside the existing `Listener` struct, append: - -```go -// SSLPolicy selects the listener's TLS protocol/cipher policy (vngcloud-defined). -// Only honored on HTTPS / TLS-terminate listeners. -// +optional -SSLPolicy *string `json:"sslPolicy,omitempty"` - -// ALPNPolicy controls ALPN advertisement (e.g., "HTTP2Optional", "HTTP1Only"). -// Only honored on HTTPS / TLS-terminate listeners. -// +optional -ALPNPolicy *string `json:"alpnPolicy,omitempty"` -``` - -- [ ] **Step 2: Regenerate deep-copy + manifests** - -Run: `make generate manifests` - -Expected: `api/v1alpha1/zz_generated.deepcopy.go` updated; `config/crd/bases/vks.vngcloud.vn_loadbalancerconfigs.yaml` updated. - -- [ ] **Step 3: Verify existing tests pass** - -Run: `go test ./api/v1alpha1/... ./internal/usecase/...` - -Expected: all pass. - -- [ ] **Step 4: Commit** - -```bash -git add api/v1alpha1/loadbalancerconfig_types.go api/v1alpha1/zz_generated.deepcopy.go config/crd/bases/vks.vngcloud.vn_loadbalancerconfigs.yaml -git commit -m "feat(api): extend LoadBalancerConfig with MergingMode + listener SSL/ALPN policy" -``` - ---- - -### Task A4: New API group `gateway.vks.vngcloud.vn/v1alpha1` — registration - -**Files:** -- Create: `api/gateway/v1alpha1/groupversion_info.go` - -- [ ] **Step 1: Write the file** - -```go -// Package v1alpha1 contains API Schema definitions for the gateway.vks.vngcloud.vn v1alpha1 API group. -// +kubebuilder:object:generate=true -// +groupName=gateway.vks.vngcloud.vn -package v1alpha1 - -import ( - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/scheme" -) - -var ( - GroupVersion = schema.GroupVersion{Group: "gateway.vks.vngcloud.vn", Version: "v1alpha1"} - SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} - AddToScheme = SchemeBuilder.AddToScheme -) -``` - -- [ ] **Step 2: Verify compilation** - -Run: `go build ./api/gateway/v1alpha1/...` - -Expected: success (file compiles even without any types yet). - -- [ ] **Step 3: Commit** - -```bash -git add api/gateway/v1alpha1/groupversion_info.go -git commit -m "feat(api): scaffold gateway.vks.vngcloud.vn/v1alpha1 group" -``` - ---- - -### Task A5: `TargetGroupConfig` types - -**Files:** -- Create: `api/gateway/v1alpha1/targetgroupconfig_types.go` - -- [ ] **Step 1: Write the type definitions** - -```go -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" -) - -// TargetReference points at a backend Service in the same namespace. -type TargetReference struct { - // Group of the target. Defaults to "" (core). - // +optional - Group *string `json:"group,omitempty"` - // Kind of the target. Defaults to "Service". - // +optional - Kind *string `json:"kind,omitempty"` - // Name of the target. Required. - Name string `json:"name"` -} - -// TargetGroupProperties holds vngcloud-specific pool/health-check options. -type TargetGroupProperties struct { - // +kubebuilder:validation:Enum=instance;ip - // +optional - TargetType *string `json:"targetType,omitempty"` - // +optional - PoolAlgorithm *string `json:"poolAlgorithm,omitempty"` - // +optional - EnableStickySession *bool `json:"enableStickySession,omitempty"` - // +optional - EnableTLSEncryption *bool `json:"enableTLSEncryption,omitempty"` - // +optional - EnableProxyProtocol *bool `json:"enableProxyProtocol,omitempty"` - // +optional - HealthCheck *vksv1alpha1.PoolHealthMonitor `json:"healthCheck,omitempty"` - // +optional - TargetNodeLabels map[string]string `json:"targetNodeLabels,omitempty"` - // +optional - ManageDFPMembers *bool `json:"manageDFPMembers,omitempty"` -} - -// RouteIdentifier scopes a per-route override. -type RouteIdentifier struct { - Group string `json:"group"` - Kind string `json:"kind"` - // +optional - Namespace *string `json:"namespace,omitempty"` - Name string `json:"name"` - // RuleName matches HTTPRoute.spec.rules[].name when set. - // +optional - RuleName *string `json:"ruleName,omitempty"` -} - -// RouteSpecificConfig overrides DefaultConfig for a specific route (and optional rule). -type RouteSpecificConfig struct { - RouteIdentifier RouteIdentifier `json:"routeIdentifier"` - Config TargetGroupProperties `json:"config"` -} - -type TargetGroupConfigSpec struct { - TargetReference TargetReference `json:"targetReference"` - DefaultConfig TargetGroupProperties `json:"defaultConfig"` - // +optional - RouteConfigurations []RouteSpecificConfig `json:"routeConfigurations,omitempty"` -} - -type TargetGroupConfigStatus struct { - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` - // +optional - ObservedGeneration int64 `json:"observedGeneration,omitempty"` -} - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:resource:shortName=tgc -type TargetGroupConfig struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec TargetGroupConfigSpec `json:"spec,omitempty"` - Status TargetGroupConfigStatus `json:"status,omitempty"` -} - -// +kubebuilder:object:root=true -type TargetGroupConfigList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []TargetGroupConfig `json:"items"` -} - -func init() { - SchemeBuilder.Register(&TargetGroupConfig{}, &TargetGroupConfigList{}) -} -``` - -- [ ] **Step 2: Regenerate deep-copy + manifests** - -Run: `make generate manifests` - -Expected: `api/gateway/v1alpha1/zz_generated.deepcopy.go` created; `config/crd/bases/gateway.vks.vngcloud.vn_targetgroupconfigs.yaml` created. - -- [ ] **Step 3: Verify build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add api/gateway/v1alpha1/ config/crd/bases/gateway.vks.vngcloud.vn_targetgroupconfigs.yaml -git commit -m "feat(api): add TargetGroupConfig CRD" -``` - ---- - -### Task A6: `ListenerRuleConfig` types - -**Files:** -- Create: `api/gateway/v1alpha1/listenerruleconfig_types.go` - -- [ ] **Step 1: Write the type definitions** - -```go -package v1alpha1 - -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - -// AdditionalMatch is AND'd onto the policy generated by the host HTTPRoute rule. -type AdditionalMatch struct { - // +kubebuilder:validation:Enum=Header;QueryParam;Method;SourceIP - Type string `json:"type"` - // +optional - Name *string `json:"name,omitempty"` - // Compare values: EQUAL_TO | STARTS_WITH | ENDS_WITH | CONTAINS | REGEX - Compare string `json:"compare"` - Value string `json:"value"` -} - -type FixedResponseAction struct { - StatusCode int32 `json:"statusCode"` - // +optional - ContentType *string `json:"contentType,omitempty"` - // +optional - Body *string `json:"body,omitempty"` -} - -type RedirectAction struct { - URL string `json:"url"` - // +optional - HTTPCode *int32 `json:"httpCode,omitempty"` - // +optional - KeepQueryString *bool `json:"keepQueryString,omitempty"` -} - -type RuleAction struct { - // +kubebuilder:validation:Enum=FixedResponse;Reject;Redirect - Type string `json:"type"` - // +optional - FixedResponse *FixedResponseAction `json:"fixedResponse,omitempty"` - // +optional - Redirect *RedirectAction `json:"redirect,omitempty"` -} - -type ListenerRuleConfigSpec struct { - // +optional - AdditionalMatches []AdditionalMatch `json:"additionalMatches,omitempty"` - // +optional - Actions []RuleAction `json:"actions,omitempty"` - // +optional - Position *int32 `json:"position,omitempty"` -} - -type ListenerRuleConfigStatus struct { - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` - // +optional - ObservedGeneration int64 `json:"observedGeneration,omitempty"` -} - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:resource:shortName=lrc -type ListenerRuleConfig struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec ListenerRuleConfigSpec `json:"spec,omitempty"` - Status ListenerRuleConfigStatus `json:"status,omitempty"` -} - -// +kubebuilder:object:root=true -type ListenerRuleConfigList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []ListenerRuleConfig `json:"items"` -} - -func init() { - SchemeBuilder.Register(&ListenerRuleConfig{}, &ListenerRuleConfigList{}) -} -``` - -- [ ] **Step 2: Regenerate** - -Run: `make generate manifests` - -Expected: `config/crd/bases/gateway.vks.vngcloud.vn_listenerruleconfigs.yaml` created. - -- [ ] **Step 3: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add api/gateway/v1alpha1/listenerruleconfig_types.go api/gateway/v1alpha1/zz_generated.deepcopy.go config/crd/bases/gateway.vks.vngcloud.vn_listenerruleconfigs.yaml -git commit -m "feat(api): add ListenerRuleConfig CRD" -``` - ---- - -### Task A7: Update `PROJECT` to register new resources - -**Files:** -- Modify: `PROJECT` - -- [ ] **Step 1: Append the two new resource entries** - -Append under `resources:` (matching existing pattern): - -```yaml -- api: - crdVersion: v1 - namespaced: true - controller: true - domain: vks.vngcloud.vn - group: gateway - kind: TargetGroupConfig - path: github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1 - version: v1alpha1 -- api: - crdVersion: v1 - namespaced: true - controller: true - domain: vks.vngcloud.vn - group: gateway - kind: ListenerRuleConfig - path: github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1 - version: v1alpha1 -``` - -- [ ] **Step 2: Commit** - -```bash -git add PROJECT -git commit -m "chore: register TargetGroupConfig and ListenerRuleConfig in PROJECT" -``` - ---- - -## Block B — Shared utility packages - -### Task B1: `pkg/gateway/gatewayapi_utils.go` — hostname & wildcard helpers (TDD) - -**Files:** -- Create: `pkg/gateway/gatewayapi_utils.go` -- Test: `pkg/gateway/gatewayapi_utils_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package gateway_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" -) - -func TestHostnameToL7Rule(t *testing.T) { - cases := []struct { - name string - host string - wantCmp string - wantVal string - }{ - {"literal", "api.example.com", "EQUAL_TO", "api.example.com"}, - {"prefix wildcard", "*.example.com", "REGEX", `^[^.]+\.example\.com$`}, - {"empty", "", "", ""}, // signals "no host rule" - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - cmp, val := gateway.HostnameToL7Rule(c.host) - assert.Equal(t, c.wantCmp, cmp) - assert.Equal(t, c.wantVal, val) - }) - } -} - -func TestPathToL7Rule(t *testing.T) { - cases := []struct { - name string - pathType string - path string - wantCmp string - wantVal string - }{ - {"exact", "Exact", "/foo", "EQUAL_TO", "/foo"}, - {"prefix", "PathPrefix", "/foo", "STARTS_WITH", "/foo"}, - {"regex", "RegularExpression", "^/foo/[0-9]+$", "REGEX", "^/foo/[0-9]+$"}, - {"impl-specific defaults to exact", "ImplementationSpecific", "/foo", "EQUAL_TO", "/foo"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - cmp, val := gateway.PathToL7Rule(c.pathType, c.path) - assert.Equal(t, c.wantCmp, cmp) - assert.Equal(t, c.wantVal, val) - }) - } -} -``` - -- [ ] **Step 2: Run the test to confirm it fails** - -Run: `go test ./pkg/gateway/... -run "TestHostnameToL7Rule|TestPathToL7Rule" -v` - -Expected: FAIL — package or symbol not found. - -- [ ] **Step 3: Implement the helpers** - -Create `pkg/gateway/gatewayapi_utils.go`: - -```go -package gateway - -import ( - "regexp" - "strings" -) - -// HostnameToL7Rule converts a Gateway-API hostname into a vngcloud L7 rule (compare, value). -// Empty host => empty pair (caller must skip the host rule). -// "*.foo.com" => REGEX matching exactly one DNS label before ".foo.com". -func HostnameToL7Rule(host string) (compare, value string) { - if host == "" { - return "", "" - } - if strings.HasPrefix(host, "*.") { - rest := strings.TrimPrefix(host, "*.") - return "REGEX", `^[^.]+\.` + regexp.QuoteMeta(rest) + `$` - } - return "EQUAL_TO", host -} - -// PathToL7Rule converts a Gateway-API HTTPPathMatch (type, value) into a vngcloud L7 rule. -// ImplementationSpecific falls back to EQUAL_TO. -func PathToL7Rule(pathType, path string) (compare, value string) { - switch pathType { - case "Exact": - return "EQUAL_TO", path - case "PathPrefix": - return "STARTS_WITH", path - case "RegularExpression": - return "REGEX", path - default: - return "EQUAL_TO", path - } -} -``` - -- [ ] **Step 4: Run the tests** - -Run: `go test ./pkg/gateway/... -run "TestHostnameToL7Rule|TestPathToL7Rule" -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add pkg/gateway/gatewayapi_utils.go pkg/gateway/gatewayapi_utils_test.go -git commit -m "feat(gateway): add hostname/path → L7 rule converters" -``` - ---- - -### Task B2: `pkg/gateway/synth_pool.go` — deterministic pool naming (TDD) - -**Files:** -- Create: `pkg/gateway/synth_pool.go` -- Test: `pkg/gateway/synth_pool_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package gateway_test - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" -) - -func TestSynthPoolName_Deterministic(t *testing.T) { - backends := []gateway.BackendKey{ - {Namespace: "ns1", Name: "svc-a", Port: 80, Weight: 90}, - {Namespace: "ns1", Name: "svc-b", Port: 80, Weight: 10}, - } - n1 := gateway.SynthPoolName("12345678-aaaa", 0, backends) - n2 := gateway.SynthPoolName("12345678-aaaa", 0, backends) - assert.Equal(t, n1, n2) - assert.True(t, strings.HasPrefix(n1, "gw_12345678_0_")) - assert.LessOrEqual(t, len(n1), 50) -} - -func TestSynthPoolName_StableUnderReorder(t *testing.T) { - a := []gateway.BackendKey{{Namespace: "n", Name: "a", Port: 80, Weight: 1}, {Namespace: "n", Name: "b", Port: 80, Weight: 2}} - b := []gateway.BackendKey{{Namespace: "n", Name: "b", Port: 80, Weight: 2}, {Namespace: "n", Name: "a", Port: 80, Weight: 1}} - assert.Equal(t, gateway.SynthPoolName("uid", 0, a), gateway.SynthPoolName("uid", 0, b)) -} - -func TestSynthPoolName_ChangesOnWeightChange(t *testing.T) { - a := []gateway.BackendKey{{Namespace: "n", Name: "a", Port: 80, Weight: 1}} - b := []gateway.BackendKey{{Namespace: "n", Name: "a", Port: 80, Weight: 2}} - assert.NotEqual(t, gateway.SynthPoolName("uid", 0, a), gateway.SynthPoolName("uid", 0, b)) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./pkg/gateway/... -run "TestSynthPool" -v` - -Expected: FAIL — symbols not defined. - -- [ ] **Step 3: Implement** - -```go -package gateway - -import ( - "crypto/sha1" - "encoding/hex" - "fmt" - "sort" -) - -// BackendKey is the canonical identity of a backend in a route rule. -type BackendKey struct { - Namespace string - Name string - Port int32 - Weight int32 -} - -// SynthPoolName produces a deterministic pool name <= 50 chars. -// Format: gw___. -// Stable under reorder of backends; changes when set/weights change. -func SynthPoolName(routeUID string, ruleIdx int, backends []BackendKey) string { - uid := routeUID - if len(uid) > 8 { - uid = uid[:8] - } - sorted := append([]BackendKey(nil), backends...) - sort.Slice(sorted, func(i, j int) bool { - if sorted[i].Namespace != sorted[j].Namespace { - return sorted[i].Namespace < sorted[j].Namespace - } - if sorted[i].Name != sorted[j].Name { - return sorted[i].Name < sorted[j].Name - } - if sorted[i].Port != sorted[j].Port { - return sorted[i].Port < sorted[j].Port - } - return sorted[i].Weight < sorted[j].Weight - }) - h := sha1.New() - for _, b := range sorted { - fmt.Fprintf(h, "%s/%s:%d:%d\n", b.Namespace, b.Name, b.Port, b.Weight) - } - sum := hex.EncodeToString(h.Sum(nil))[:5] - return fmt.Sprintf("gw_%s_%d_%s", uid, ruleIdx, sum) -} -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./pkg/gateway/... -run "TestSynthPool" -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add pkg/gateway/synth_pool.go pkg/gateway/synth_pool_test.go -git commit -m "feat(gateway): add deterministic synthetic pool naming" -``` - ---- - -### Task B3: `internal/usecase/gateway_uc/shared/merge_config.go` — LBC merging (TDD) - -**Files:** -- Create: `internal/usecase/gateway_uc/shared/merge_config.go` -- Test: `internal/usecase/gateway_uc/shared/merge_config_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package shared_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/utils/ptr" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" -) - -func TestMergeLBC_PreferGateway_DefaultMode(t *testing.T) { - class := &vksv1alpha1.LoadBalancerConfigSpec{PackageId: ptr.To("class-pkg"), Tags: map[string]string{"a": "1"}} - gw := &vksv1alpha1.LoadBalancerConfigSpec{PackageId: ptr.To("gw-pkg"), Tags: map[string]string{"a": "2", "b": "3"}} - out := shared.MergeLBC(class, gw) - assert.Equal(t, "gw-pkg", *out.PackageId) - assert.Equal(t, "2", out.Tags["a"]) - assert.Equal(t, "3", out.Tags["b"]) -} - -func TestMergeLBC_PreferGatewayClass(t *testing.T) { - mode := vksv1alpha1.MergingModePreferGatewayClass - class := &vksv1alpha1.LoadBalancerConfigSpec{PackageId: ptr.To("class-pkg"), MergingMode: &mode, Tags: map[string]string{"a": "1"}} - gw := &vksv1alpha1.LoadBalancerConfigSpec{PackageId: ptr.To("gw-pkg"), Tags: map[string]string{"a": "2", "b": "3"}} - out := shared.MergeLBC(class, gw) - assert.Equal(t, "class-pkg", *out.PackageId) - assert.Equal(t, "1", out.Tags["a"]) - assert.Equal(t, "3", out.Tags["b"]) // present only in one side: kept regardless -} - -func TestMergeLBC_LoadBalancerIDIgnoredAtClassLevel(t *testing.T) { - class := &vksv1alpha1.LoadBalancerConfigSpec{LoadBalancerId: ptr.To("CLASS-FORBIDDEN")} - gw := &vksv1alpha1.LoadBalancerConfigSpec{LoadBalancerId: ptr.To("gw-id")} - out := shared.MergeLBC(class, gw) - assert.Equal(t, "gw-id", *out.LoadBalancerId) -} - -func TestMergeLBC_NilGateway(t *testing.T) { - class := &vksv1alpha1.LoadBalancerConfigSpec{PackageId: ptr.To("class-pkg")} - out := shared.MergeLBC(class, nil) - assert.Equal(t, "class-pkg", *out.PackageId) -} - -func TestMergeLBC_ListenersMergedByName(t *testing.T) { - class := &vksv1alpha1.LoadBalancerConfigSpec{ - Listeners: []vksv1alpha1.Listener{{Name: "l1", AllowedCidrs: ptr.To("10.0.0.0/8")}}, - } - gw := &vksv1alpha1.LoadBalancerConfigSpec{ - Listeners: []vksv1alpha1.Listener{{Name: "l1", AllowedCidrs: ptr.To("0.0.0.0/0")}, {Name: "l2"}}, - } - out := shared.MergeLBC(class, gw) - var l1, l2 *vksv1alpha1.Listener - for i := range out.Listeners { - if out.Listeners[i].Name == "l1" { l1 = &out.Listeners[i] } - if out.Listeners[i].Name == "l2" { l2 = &out.Listeners[i] } - } - assert.NotNil(t, l1) - assert.NotNil(t, l2) - assert.Equal(t, "0.0.0.0/0", *l1.AllowedCidrs) // PreferGateway default -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/usecase/gateway_uc/shared/... -v` - -Expected: FAIL — package not found. - -- [ ] **Step 3: Implement** - -```go -// Package shared holds Gateway-controller business helpers used by both ALB and NLB use-cases. -package shared - -import ( - "github.com/huandu/go-clone" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" -) - -// MergeLBC merges a class-level LBC spec with a gateway-level LBC spec, returning a -// new spec that represents the effective configuration. Either argument may be nil. -// -// Merging rules: -// - mode = class.MergingMode (default PreferGateway). Per design, only the class-level -// LBC's MergingMode field is honored. -// - Scalar/pointer fields: PreferGateway → gateway value wins if non-nil, else class. -// PreferGatewayClass → class value wins if non-nil, else gateway. -// - LoadBalancerId: gateway-only (class value ignored). -// - Listeners[] and Tags: merged by name/key. Each per-item value resolved per mode. -func MergeLBC(class, gw *vksv1alpha1.LoadBalancerConfigSpec) *vksv1alpha1.LoadBalancerConfigSpec { - if class == nil && gw == nil { - return &vksv1alpha1.LoadBalancerConfigSpec{} - } - if class == nil { - return clone.Clone(gw).(*vksv1alpha1.LoadBalancerConfigSpec) - } - if gw == nil { - out := clone.Clone(class).(*vksv1alpha1.LoadBalancerConfigSpec) - out.LoadBalancerId = nil // class can't pin a single LB - return out - } - - mode := vksv1alpha1.MergingModePreferGateway - if class.MergingMode != nil { - mode = *class.MergingMode - } - - out := clone.Clone(class).(*vksv1alpha1.LoadBalancerConfigSpec) - pickPtr := func(classVal, gwVal interface{}) {} // doc only — see below - _ = pickPtr - - // For scalar/pointer fields, do per-field selection. - out.PackageId = pickStrPtr(class.PackageId, gw.PackageId, mode) - out.Scheme = pickEnumPtr[vksv1alpha1.LoadBalancerConfigSpec](class.Scheme, gw.Scheme, mode) - out.EnableAutoscale = pickBoolPtr(class.EnableAutoscale, gw.EnableAutoscale, mode) - out.IsPoc = pickBoolPtr(class.IsPoc, gw.IsPoc, mode) - out.SubnetId = pickStr(class.SubnetId, gw.SubnetId, mode) - out.VpcId = pickStr(class.VpcId, gw.VpcId, mode) - out.LoadBalancerName = pickStr(class.LoadBalancerName, gw.LoadBalancerName, mode) - out.PrivateSubnetId = pickStrPtr(class.PrivateSubnetId, gw.PrivateSubnetId, mode) - out.PrivateZoneId = pickPrivateZonePtr(class.PrivateZoneId, gw.PrivateZoneId, mode) - out.LoadBalancerId = gw.LoadBalancerId // gateway-only - - out.Tags = mergeStringMap(class.Tags, gw.Tags, mode) - out.Listeners = mergeListenersByName(class.Listeners, gw.Listeners, mode) - - return out -} - -func pickStr(a, b string, mode vksv1alpha1.MergingMode) string { - if mode == vksv1alpha1.MergingModePreferGatewayClass { - if a != "" { return a } - return b - } - if b != "" { return b } - return a -} -func pickStrPtr(a, b *string, mode vksv1alpha1.MergingMode) *string { - if mode == vksv1alpha1.MergingModePreferGatewayClass { - if a != nil { return a } - return b - } - if b != nil { return b } - return a -} -func pickBoolPtr(a, b *bool, mode vksv1alpha1.MergingMode) *bool { - if mode == vksv1alpha1.MergingModePreferGatewayClass { - if a != nil { return a } - return b - } - if b != nil { return b } - return a -} -// pickEnumPtr/pickPrivateZonePtr are typed wrappers around pickPtr; identical structure. -// (Concrete helpers omitted for brevity in the plan — engineer mirrors pickStrPtr.) -``` - -(Keep the plan compact: fully expanded `pickEnumPtr`, `pickPrivateZonePtr`, `mergeStringMap`, `mergeListenersByName` follow the same pattern. Engineer fills them in by analogy with `pickStrPtr`.) - -```go -func mergeStringMap(a, b map[string]string, mode vksv1alpha1.MergingMode) map[string]string { - if a == nil && b == nil { - return nil - } - out := map[string]string{} - for k, v := range a { - out[k] = v - } - for k, v := range b { - if _, exists := out[k]; !exists || mode == vksv1alpha1.MergingModePreferGateway { - out[k] = v - } - } - return out -} - -func mergeListenersByName(a, b []vksv1alpha1.Listener, mode vksv1alpha1.MergingMode) []vksv1alpha1.Listener { - byName := map[string]vksv1alpha1.Listener{} - for _, l := range a { - byName[l.Name] = l - } - for _, l := range b { - if _, exists := byName[l.Name]; !exists || mode == vksv1alpha1.MergingModePreferGateway { - // For Phase 1 we take the whole-listener winner (per-field listener merging deferred). - byName[l.Name] = l - } - } - out := make([]vksv1alpha1.Listener, 0, len(byName)) - for _, l := range byName { - out = append(out, l) - } - return out -} -``` - -- [ ] **Step 4: Run the tests** - -Run: `go test ./internal/usecase/gateway_uc/shared/... -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/usecase/gateway_uc/shared/ -git commit -m "feat(gateway/uc): add LoadBalancerConfig merging helpers" -``` - ---- - -### Task B4: `internal/usecase/gateway_uc/shared/refgrant.go` — ReferenceGrant evaluation (TDD) - -**Files:** -- Create: `internal/usecase/gateway_uc/shared/refgrant.go` -- Test: `internal/usecase/gateway_uc/shared/refgrant_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package shared_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" - - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" -) - -func mkGrant(ns string, fromGroup, fromKind, fromNS, toGroup, toKind, toName string) *gwv1beta1.ReferenceGrant { - rgFrom := gwv1beta1.ReferenceGrantFrom{ - Group: gwv1beta1.Group(fromGroup), Kind: gwv1beta1.Kind(fromKind), Namespace: gwv1beta1.Namespace(fromNS), - } - rgTo := gwv1beta1.ReferenceGrantTo{Group: gwv1beta1.Group(toGroup), Kind: gwv1beta1.Kind(toKind)} - if toName != "" { - n := gwv1beta1.ObjectName(toName); rgTo.Name = &n - } - return &gwv1beta1.ReferenceGrant{ - ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: "rg"}, - Spec: gwv1beta1.ReferenceGrantSpec{From: []gwv1beta1.ReferenceGrantFrom{rgFrom}, To: []gwv1beta1.ReferenceGrantTo{rgTo}}, - } -} - -func TestRefGrantAllowed_SameNamespace(t *testing.T) { - p := shared.RefRequest{FromGroup: "gateway.networking.k8s.io", FromKind: "HTTPRoute", FromNS: "ns1", - ToGroup: "", ToKind: "Service", ToNS: "ns1", ToName: "svc"} - assert.True(t, shared.RefGrantAllowed(p, nil)) -} - -func TestRefGrantAllowed_CrossNamespace_GrantPresent(t *testing.T) { - grants := []*gwv1beta1.ReferenceGrant{ - mkGrant("ns2", "gateway.networking.k8s.io", "HTTPRoute", "ns1", "", "Service", ""), - } - p := shared.RefRequest{FromGroup: "gateway.networking.k8s.io", FromKind: "HTTPRoute", FromNS: "ns1", - ToGroup: "", ToKind: "Service", ToNS: "ns2", ToName: "svc"} - assert.True(t, shared.RefGrantAllowed(p, grants)) -} - -func TestRefGrantAllowed_CrossNamespace_NoGrant(t *testing.T) { - p := shared.RefRequest{FromGroup: "gateway.networking.k8s.io", FromKind: "HTTPRoute", FromNS: "ns1", - ToGroup: "", ToKind: "Service", ToNS: "ns2", ToName: "svc"} - assert.False(t, shared.RefGrantAllowed(p, nil)) -} - -func TestRefGrantAllowed_NameSpecificMismatch(t *testing.T) { - grants := []*gwv1beta1.ReferenceGrant{ - mkGrant("ns2", "gateway.networking.k8s.io", "HTTPRoute", "ns1", "", "Service", "other"), - } - p := shared.RefRequest{FromGroup: "gateway.networking.k8s.io", FromKind: "HTTPRoute", FromNS: "ns1", - ToGroup: "", ToKind: "Service", ToNS: "ns2", ToName: "svc"} - assert.False(t, shared.RefGrantAllowed(p, grants)) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/usecase/gateway_uc/shared/... -run TestRefGrant -v` - -Expected: FAIL — symbol not defined. - -- [ ] **Step 3: Implement** - -```go -package shared - -import ( - gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" -) - -type RefRequest struct { - FromGroup, FromKind, FromNS string - ToGroup, ToKind, ToNS, ToName string -} - -// RefGrantAllowed returns true if the given cross-namespace reference is permitted by -// any of the supplied ReferenceGrants. Same-namespace references are always allowed. -func RefGrantAllowed(r RefRequest, grants []*gwv1beta1.ReferenceGrant) bool { - if r.FromNS == r.ToNS { - return true - } - for _, g := range grants { - if g.Namespace != r.ToNS { - continue - } - if !grantMatchesFrom(g, r) { - continue - } - if grantMatchesTo(g, r) { - return true - } - } - return false -} - -func grantMatchesFrom(g *gwv1beta1.ReferenceGrant, r RefRequest) bool { - for _, f := range g.Spec.From { - if string(f.Group) == r.FromGroup && string(f.Kind) == r.FromKind && string(f.Namespace) == r.FromNS { - return true - } - } - return false -} - -func grantMatchesTo(g *gwv1beta1.ReferenceGrant, r RefRequest) bool { - for _, to := range g.Spec.To { - if string(to.Group) != r.ToGroup || string(to.Kind) != r.ToKind { - continue - } - if to.Name == nil { - return true - } - if string(*to.Name) == r.ToName { - return true - } - } - return false -} -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/usecase/gateway_uc/shared/... -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/usecase/gateway_uc/shared/refgrant.go internal/usecase/gateway_uc/shared/refgrant_test.go -git commit -m "feat(gateway/uc): add ReferenceGrant evaluation helper" -``` - ---- - -### Task B5: `internal/usecase/gateway_uc/shared/tgc_resolver.go` — TGC cascade (TDD) - -**Files:** -- Create: `internal/usecase/gateway_uc/shared/tgc_resolver.go` -- Test: `internal/usecase/gateway_uc/shared/tgc_resolver_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package shared_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/utils/ptr" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - gatewayv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" -) - -func mkTGC(name string, target string, def *string, route, rule string, override *string) gatewayv1alpha1.TargetGroupConfig { - tgc := gatewayv1alpha1.TargetGroupConfig{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "ns"}, - Spec: gatewayv1alpha1.TargetGroupConfigSpec{ - TargetReference: gatewayv1alpha1.TargetReference{Name: target}, - DefaultConfig: gatewayv1alpha1.TargetGroupProperties{PoolAlgorithm: def}, - }, - } - if route != "" { - rsc := gatewayv1alpha1.RouteSpecificConfig{ - RouteIdentifier: gatewayv1alpha1.RouteIdentifier{Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Name: route}, - Config: gatewayv1alpha1.TargetGroupProperties{PoolAlgorithm: override}, - } - if rule != "" { - rsc.RouteIdentifier.RuleName = ptr.To(rule) - } - tgc.Spec.RouteConfigurations = append(tgc.Spec.RouteConfigurations, rsc) - } - return tgc -} - -func TestResolveTGC_DefaultOnly(t *testing.T) { - tgcs := []gatewayv1alpha1.TargetGroupConfig{mkTGC("a", "svc-1", ptr.To("ROUND_ROBIN"), "", "", nil)} - p, _ := shared.ResolveTargetGroupProps(tgcs, "svc-1", "HTTPRoute", "any-route", nil) - assert.Equal(t, "ROUND_ROBIN", *p.PoolAlgorithm) -} - -func TestResolveTGC_RouteOverride(t *testing.T) { - tgcs := []gatewayv1alpha1.TargetGroupConfig{mkTGC("a", "svc-1", ptr.To("ROUND_ROBIN"), "route-x", "", ptr.To("LEAST_CONNECTIONS"))} - p, _ := shared.ResolveTargetGroupProps(tgcs, "svc-1", "HTTPRoute", "route-x", nil) - assert.Equal(t, "LEAST_CONNECTIONS", *p.PoolAlgorithm) -} - -func TestResolveTGC_RuleOverride_Beats_Route(t *testing.T) { - tgcs := []gatewayv1alpha1.TargetGroupConfig{ - mkTGC("a", "svc-1", ptr.To("RR"), "route-x", "rule-1", ptr.To("RULE_WIN")), - mkTGC("b", "svc-1", ptr.To("RR"), "route-x", "", ptr.To("ROUTE_WIN")), - } - rule := "rule-1" - p, _ := shared.ResolveTargetGroupProps(tgcs, "svc-1", "HTTPRoute", "route-x", &rule) - assert.Equal(t, "RULE_WIN", *p.PoolAlgorithm) -} - -func TestResolveTGC_NoMatch_ReturnsNil(t *testing.T) { - p, _ := shared.ResolveTargetGroupProps(nil, "svc-1", "HTTPRoute", "x", nil) - assert.Equal(t, gatewayv1alpha1.TargetGroupProperties{}, p) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/usecase/gateway_uc/shared/... -run TestResolveTGC -v` - -Expected: FAIL. - -- [ ] **Step 3: Implement** - -```go -package shared - -import ( - gatewayv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" -) - -// ResolveTargetGroupProps walks tgcs and returns the most specific TargetGroupProperties -// for the given backend Service name + route + optional rule name. The returned bool is -// true when the chosen TGC also reports a Conflicted state on a peer that lost a tie. -func ResolveTargetGroupProps( - tgcs []gatewayv1alpha1.TargetGroupConfig, - serviceName, routeKind, routeName string, - ruleName *string, -) (gatewayv1alpha1.TargetGroupProperties, bool) { - var best *gatewayv1alpha1.TargetGroupProperties - var bestSpecificity int // 0=default, 1=route, 2=rule - var conflict bool - for i := range tgcs { - tgc := &tgcs[i] - if !targetMatchesService(tgc.Spec.TargetReference, serviceName) { - continue - } - // Route-specific? - for _, rsc := range tgc.Spec.RouteConfigurations { - if rsc.RouteIdentifier.Kind != routeKind || rsc.RouteIdentifier.Name != routeName { - continue - } - spec := 1 - if rsc.RouteIdentifier.RuleName != nil && ruleName != nil && *rsc.RouteIdentifier.RuleName == *ruleName { - spec = 2 - } else if rsc.RouteIdentifier.RuleName != nil { - continue - } - if best == nil || spec > bestSpecificity { - cfg := rsc.Config - best = &cfg - bestSpecificity = spec - conflict = false - } else if spec == bestSpecificity { - conflict = true - } - } - if best == nil || bestSpecificity == 0 { - cfg := tgc.Spec.DefaultConfig - if best == nil { - best = &cfg - bestSpecificity = 0 - } else if bestSpecificity == 0 { - conflict = true - } - } - } - if best == nil { - return gatewayv1alpha1.TargetGroupProperties{}, false - } - return *best, conflict -} - -func targetMatchesService(ref gatewayv1alpha1.TargetReference, name string) bool { - if ref.Name != name { - return false - } - if ref.Kind != nil && *ref.Kind != "" && *ref.Kind != "Service" { - return false - } - if ref.Group != nil && *ref.Group != "" { - return false - } - return true -} -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/usecase/gateway_uc/shared/... -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/usecase/gateway_uc/shared/tgc_resolver.go internal/usecase/gateway_uc/shared/tgc_resolver_test.go -git commit -m "feat(gateway/uc): add TargetGroupConfig cascading resolver" -``` - ---- - -### Task B6: `internal/usecase/gateway_uc/shared/lrc_resolver.go` — LRC ExtensionRef resolver (TDD) - -**Files:** -- Create: `internal/usecase/gateway_uc/shared/lrc_resolver.go` -- Test: `internal/usecase/gateway_uc/shared/lrc_resolver_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package shared_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - gatewayv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" -) - -func TestExtractLRCRefs_FromFilters(t *testing.T) { - extRef := gwv1.LocalObjectReference{Group: "gateway.vks.vngcloud.vn", Kind: "ListenerRuleConfig", Name: "rule-x"} - filters := []gwv1.HTTPRouteFilter{{Type: gwv1.HTTPRouteFilterExtensionRef, ExtensionRef: &extRef}} - out := shared.ExtractLRCRefsFromFilters(filters) - assert.Equal(t, []string{"rule-x"}, out) -} - -func TestExtractLRCRefs_IgnoresOtherExtensionRefs(t *testing.T) { - extRef := gwv1.LocalObjectReference{Group: "other.example.com", Kind: "Whatever", Name: "ignored"} - filters := []gwv1.HTTPRouteFilter{{Type: gwv1.HTTPRouteFilterExtensionRef, ExtensionRef: &extRef}} - assert.Empty(t, shared.ExtractLRCRefsFromFilters(filters)) -} - -func TestFindLRC(t *testing.T) { - lrcs := []gatewayv1alpha1.ListenerRuleConfig{ - {ObjectMeta: metav1.ObjectMeta{Name: "rule-x", Namespace: "ns"}}, - } - out, ok := shared.FindLRC(lrcs, "rule-x") - assert.True(t, ok) - assert.Equal(t, "rule-x", out.Name) - - _, ok = shared.FindLRC(lrcs, "missing") - assert.False(t, ok) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/usecase/gateway_uc/shared/... -run TestExtractLRC -v` - -Expected: FAIL. - -- [ ] **Step 3: Implement** - -```go -package shared - -import ( - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - gatewayv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" -) - -const ( - LRCGroup = "gateway.vks.vngcloud.vn" - LRCKind = "ListenerRuleConfig" -) - -// ExtractLRCRefsFromFilters returns the names of all ListenerRuleConfig -// ExtensionRefs found in the given filters list (same-namespace only). -func ExtractLRCRefsFromFilters(filters []gwv1.HTTPRouteFilter) []string { - var names []string - for _, f := range filters { - if f.Type != gwv1.HTTPRouteFilterExtensionRef || f.ExtensionRef == nil { - continue - } - if string(f.ExtensionRef.Group) == LRCGroup && string(f.ExtensionRef.Kind) == LRCKind { - names = append(names, string(f.ExtensionRef.Name)) - } - } - return names -} - -// FindLRC looks up a ListenerRuleConfig by name within a slice (already namespace-filtered). -func FindLRC(lrcs []gatewayv1alpha1.ListenerRuleConfig, name string) (*gatewayv1alpha1.ListenerRuleConfig, bool) { - for i := range lrcs { - if lrcs[i].Name == name { - return &lrcs[i], true - } - } - return nil, false -} -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/usecase/gateway_uc/shared/... -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/usecase/gateway_uc/shared/lrc_resolver.go internal/usecase/gateway_uc/shared/lrc_resolver_test.go -git commit -m "feat(gateway/uc): add ListenerRuleConfig extension-ref resolver" -``` - ---- - -### Task B7: `internal/controller/gateway/shared/classifier.go` — listener protocol validation (TDD) - -**Files:** -- Create: `internal/controller/gateway/shared/classifier.go` -- Test: `internal/controller/gateway/shared/classifier_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package shared_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" -) - -func TestALBAllowsListener(t *testing.T) { - cases := []struct { - proto gwv1.ProtocolType - want bool - }{ - {gwv1.HTTPProtocolType, true}, - {gwv1.HTTPSProtocolType, true}, - {gwv1.TLSProtocolType, true}, - {gwv1.TCPProtocolType, false}, - {gwv1.UDPProtocolType, false}, - } - for _, c := range cases { - t.Run(string(c.proto), func(t *testing.T) { - assert.Equal(t, c.want, shared.ALBAllowsListener(c.proto)) - }) - } -} - -func TestValidateListenerSet_DupPort(t *testing.T) { - listeners := []gwv1.Listener{ - {Name: "a", Protocol: gwv1.HTTPProtocolType, Port: 80}, - {Name: "b", Protocol: gwv1.HTTPProtocolType, Port: 80}, - } - res := shared.ValidateListenersForALB(listeners) - assert.Len(t, res, 2) - assert.Equal(t, shared.ListenerInvalidReasonDupPort, res["b"]) -} - -func TestValidateListenerSet_UnsupportedProtocol(t *testing.T) { - listeners := []gwv1.Listener{{Name: "a", Protocol: gwv1.TCPProtocolType, Port: 9000}} - res := shared.ValidateListenersForALB(listeners) - assert.Equal(t, shared.ListenerInvalidReasonUnsupportedProtocol, res["a"]) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/controller/gateway/shared/... -run "TestALB|TestValidateListener" -v` - -Expected: FAIL. - -- [ ] **Step 3: Implement** - -```go -// Package shared holds Gateway-controller helpers shared by ALB and NLB reconcilers. -package shared - -import ( - gwv1 "sigs.k8s.io/gateway-api/apis/v1" -) - -type ListenerInvalidReason string - -const ( - ListenerInvalidReasonNone ListenerInvalidReason = "" - ListenerInvalidReasonUnsupportedProtocol ListenerInvalidReason = "UnsupportedProtocol" - ListenerInvalidReasonDupPort ListenerInvalidReason = "DupPort" -) - -func ALBAllowsListener(p gwv1.ProtocolType) bool { - switch p { - case gwv1.HTTPProtocolType, gwv1.HTTPSProtocolType, gwv1.TLSProtocolType: - return true - } - return false -} - -// ValidateListenersForALB returns a map from listener name → reason. A listener with -// reason ListenerInvalidReasonNone (or absent from the map) is valid. -func ValidateListenersForALB(listeners []gwv1.Listener) map[string]ListenerInvalidReason { - out := make(map[string]ListenerInvalidReason, len(listeners)) - seenPort := map[gwv1.PortNumber]string{} - for _, l := range listeners { - if !ALBAllowsListener(l.Protocol) { - out[string(l.Name)] = ListenerInvalidReasonUnsupportedProtocol - continue - } - if firstName, dup := seenPort[l.Port]; dup { - out[string(l.Name)] = ListenerInvalidReasonDupPort - _ = firstName - continue - } - seenPort[l.Port] = string(l.Name) - out[string(l.Name)] = ListenerInvalidReasonNone - } - return out -} -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/controller/gateway/shared/... -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/controller/gateway/shared/classifier.go internal/controller/gateway/shared/classifier_test.go -git commit -m "feat(gateway/ctrl): add ALB listener-validation classifier" -``` - ---- - -### Task B8: `internal/controller/gateway/shared/status.go` — condition helpers (TDD) - -**Files:** -- Create: `internal/controller/gateway/shared/status.go` -- Test: `internal/controller/gateway/shared/status_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package shared_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" -) - -func TestSetCondition_AddsAndUpdates(t *testing.T) { - var conds []metav1.Condition - shared.SetCondition(&conds, "Accepted", metav1.ConditionTrue, "Accepted", "ok", 1) - assert.Len(t, conds, 1) - assert.Equal(t, metav1.ConditionTrue, conds[0].Status) - - shared.SetCondition(&conds, "Accepted", metav1.ConditionFalse, "Invalid", "bad", 2) - assert.Len(t, conds, 1) - assert.Equal(t, metav1.ConditionFalse, conds[0].Status) - assert.Equal(t, "Invalid", conds[0].Reason) - assert.EqualValues(t, 2, conds[0].ObservedGeneration) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/controller/gateway/shared/... -run TestSetCondition -v` - -Expected: FAIL. - -- [ ] **Step 3: Implement** - -```go -package shared - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// SetCondition adds or updates a condition on the given slice. Reuses metav1.Condition -// semantics (LastTransitionTime updated only when Status changes). -func SetCondition(conds *[]metav1.Condition, condType string, status metav1.ConditionStatus, reason, msg string, gen int64) { - for i := range *conds { - if (*conds)[i].Type == condType { - if (*conds)[i].Status != status { - (*conds)[i].LastTransitionTime = metav1.Now() - } - (*conds)[i].Status = status - (*conds)[i].Reason = reason - (*conds)[i].Message = msg - (*conds)[i].ObservedGeneration = gen - return - } - } - *conds = append(*conds, metav1.Condition{ - Type: condType, - Status: status, - Reason: reason, - Message: msg, - ObservedGeneration: gen, - LastTransitionTime: metav1.Now(), - }) -} -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/controller/gateway/shared/... -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/controller/gateway/shared/status.go internal/controller/gateway/shared/status_test.go -git commit -m "feat(gateway/ctrl): add condition helpers" -``` - ---- - -### Task B9: `internal/controller/gateway/shared/policy_order.go` — match-specificity ordering (TDD) - -**Files:** -- Create: `internal/controller/gateway/shared/policy_order.go` -- Test: `internal/controller/gateway/shared/policy_order_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package shared_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/utils/ptr" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" -) - -func TestMatchSpecificity_PathTypeOrder(t *testing.T) { - exact := gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: ptr.To(gwv1.PathMatchExact), Value: ptr.To("/a")}} - pathPrefix := gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: ptr.To(gwv1.PathMatchPathPrefix), Value: ptr.To("/a")}} - assert.Greater(t, shared.MatchSpecificity(exact), shared.MatchSpecificity(pathPrefix)) -} - -func TestMatchSpecificity_LongerPathBeatsShorter(t *testing.T) { - long := gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: ptr.To(gwv1.PathMatchPathPrefix), Value: ptr.To("/a/b/c")}} - short := gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: ptr.To(gwv1.PathMatchPathPrefix), Value: ptr.To("/a")}} - assert.Greater(t, shared.MatchSpecificity(long), shared.MatchSpecificity(short)) -} - -func TestMatchSpecificity_HeaderCount(t *testing.T) { - one := gwv1.HTTPRouteMatch{Headers: []gwv1.HTTPHeaderMatch{{Name: "x"}}} - two := gwv1.HTTPRouteMatch{Headers: []gwv1.HTTPHeaderMatch{{Name: "x"}, {Name: "y"}}} - assert.Greater(t, shared.MatchSpecificity(two), shared.MatchSpecificity(one)) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/controller/gateway/shared/... -run TestMatchSpecificity -v` - -Expected: FAIL. - -- [ ] **Step 3: Implement** - -```go -package shared - -import ( - gwv1 "sigs.k8s.io/gateway-api/apis/v1" -) - -// MatchSpecificity returns a sortable specificity score. Higher = more specific. -// Encoding (high → low): -// bit 24.. : path-type weight (Exact=4, RegularExpression=3, PathPrefix=2, ImplementationSpecific=1) -// bit 16..23: path length (capped at 255) -// bit 8..15: header-match count -// bit 0..7 : query-param-match count -func MatchSpecificity(m gwv1.HTTPRouteMatch) uint64 { - var s uint64 - if m.Path != nil { - switch t := m.Path.Type; { - case t != nil && *t == gwv1.PathMatchExact: - s |= 4 << 24 - case t != nil && *t == gwv1.PathMatchRegularExpression: - s |= 3 << 24 - case t != nil && *t == gwv1.PathMatchPathPrefix: - s |= 2 << 24 - default: - s |= 1 << 24 - } - if m.Path.Value != nil { - n := uint64(len(*m.Path.Value)) - if n > 255 { - n = 255 - } - s |= n << 16 - } - } - s |= uint64(min(len(m.Headers), 255)) << 8 - s |= uint64(min(len(m.QueryParams), 255)) - return s -} - -func min(a, b int) int { if a < b { return a }; return b } -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/controller/gateway/shared/... -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/controller/gateway/shared/policy_order.go internal/controller/gateway/shared/policy_order_test.go -git commit -m "feat(gateway/ctrl): add HTTPRoute match specificity scorer" -``` - ---- - -### Task B10: `pkg/metrics/util/reconcile_counters.go` — Gateway counters - -**Files:** -- Modify: `pkg/metrics/util/reconcile_counters.go` - -- [ ] **Step 1: Read existing counters file** - -Run: `cat pkg/metrics/util/reconcile_counters.go` - -Note the existing `IncrementIngress` / `IncrementService` pattern. - -- [ ] **Step 2: Add Gateway counters** - -Append two methods mirroring `IncrementIngress`: - -```go -func (r *ReconcileCounters) IncrementGateway(nn types.NamespacedName) { r.increment("gateway", nn) } -func (r *ReconcileCounters) IncrementHTTPRoute(nn types.NamespacedName) { r.increment("httproute", nn) } -``` - -(Adjust to match the actual signature pattern in the existing file.) - -- [ ] **Step 3: Build** - -Run: `go build ./pkg/metrics/...` - -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add pkg/metrics/util/reconcile_counters.go -git commit -m "feat(metrics): add Gateway and HTTPRoute reconcile counters" -``` - ---- - -## Block C — Use case (business logic) - -### Task C1: Use-case interface contracts - -**Files:** -- Modify: `internal/usecase/contracts.go` - -- [ ] **Step 1: Add interface declarations** - -Append at end of `internal/usecase/contracts.go`: - -```go -// GatewayClassUseCase reconciles a single GatewayClass. -type GatewayClassUseCase interface { - InitGatewayClassUseCase(ctx context.Context) error - EnsureGatewayClassUseCase(ctx context.Context, req ctrl.Request) error - DeleteGatewayClassUseCase(ctx context.Context, req ctrl.Request) error -} - -// ALBGatewayUseCase reconciles a single Gateway (and the LB it owns) plus the routes -// attached to it. -type ALBGatewayUseCase interface { - InitALBGatewayUseCase(ctx context.Context) error - EnsureALBGatewayUseCase(ctx context.Context, req ctrl.Request) error - DeleteALBGatewayUseCase(ctx context.Context, req ctrl.Request) error - // EnqueueParentGatewayForRoute is called by the HTTPRoute reconciler to trigger - // a Gateway reconcile when a route changes. Idempotent. - EnqueueParentGatewayForRoute(ctx context.Context, routeRef ctrl.Request) error -} -``` - -- [ ] **Step 2: Regenerate mocks** - -Run: `make generate` (or run mockery directly per existing config) - -Expected: `internal/usecase/mocks.go` updated with new mocks. - -- [ ] **Step 3: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add internal/usecase/contracts.go internal/usecase/mocks.go -git commit -m "feat(uc): add Gateway and ALBGateway use-case contracts" -``` - ---- - -### Task C2: ALB use-case skeleton - -**Files:** -- Create: `internal/usecase/gateway_uc/alb_gateway_uc/gateway_uc.go` - -- [ ] **Step 1: Write the skeleton with empty methods** - -```go -// Package alb_gateway_uc implements the ALB Gateway-API use case. -package alb_gateway_uc - -import ( - "context" - - "github.com/anngdinh/operator-helper/contexts" - ctrl "sigs.k8s.io/controller-runtime" - - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase" - "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/annotations" - "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" -) - -type albGatewayUseCase struct { - k8sRepo repository.K8sRepository - vngcloudRepo repository.VngCloudRepository - annotationParser annotations.Parser - cniDetector utils.CniDetector - endpointResolver utils.EndpointResolver - - clusterId string -} - -func NewALBGatewayUseCase( - clusterId string, - k8sRepo repository.K8sRepository, - vngcloudRepo repository.VngCloudRepository, - annotationParser annotations.Parser, - cniDetector utils.CniDetector, - endpointResolver utils.EndpointResolver, -) usecase.ALBGatewayUseCase { - return &albGatewayUseCase{ - clusterId: clusterId, k8sRepo: k8sRepo, vngcloudRepo: vngcloudRepo, - annotationParser: annotationParser, cniDetector: cniDetector, endpointResolver: endpointResolver, - } -} - -func (uc *albGatewayUseCase) InitALBGatewayUseCase(ctx context.Context) error { - _ = contexts.NewContext(ctx).Log() - // TODO(C9): mirror IngressUseCase.Init logic (default network info etc.). Filled in by Task C9. - return nil -} - -func (uc *albGatewayUseCase) EnsureALBGatewayUseCase(ctx context.Context, req ctrl.Request) error { - return nil // implemented in Task C9 -} - -func (uc *albGatewayUseCase) DeleteALBGatewayUseCase(ctx context.Context, req ctrl.Request) error { - return nil // implemented in Task C9 -} - -func (uc *albGatewayUseCase) EnqueueParentGatewayForRoute(ctx context.Context, routeRef ctrl.Request) error { - return nil // implemented in Task C9 -} -``` - -- [ ] **Step 2: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add internal/usecase/gateway_uc/alb_gateway_uc/gateway_uc.go -git commit -m "feat(gateway/uc): scaffold ALBGatewayUseCase" -``` - ---- - -### Task C3: `build_lb.go` — Gateway → LoadBalancer params (TDD) - -**Files:** -- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_lb.go` -- Test: `internal/usecase/gateway_uc/alb_gateway_uc/build_lb_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package alb_gateway_uc - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/utils/ptr" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" -) - -func TestBuildLBSpec_FromGateway(t *testing.T) { - gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: "g1", Namespace: "ns1", UID: "12345678abcd"}} - effective := &vksv1alpha1.LoadBalancerConfigSpec{ - PackageId: ptr.To("lbp-x"), VpcId: "vpc-1", SubnetId: "sub-1", ZoneId: "HCM-1", - } - spec := BuildLBSpec(gw, effective, "k8s-cluster-id") - assert.Equal(t, "lbp-x", *spec.PackageId) - assert.Equal(t, "vpc-1", spec.VpcId) - assert.Equal(t, "sub-1", spec.SubnetId) - assert.Equal(t, "k8s-cluster-id", *spec.ClusterId) - assert.Contains(t, spec.LoadBalancerName, "g1") // controller-derived name includes gateway name -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/usecase/gateway_uc/alb_gateway_uc/... -run TestBuildLBSpec -v` - -Expected: FAIL. - -- [ ] **Step 3: Implement** - -```go -package alb_gateway_uc - -import ( - "fmt" - - loadbalancerv2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" - "k8s.io/utils/ptr" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" -) - -// BuildLBSpec produces the effective vngcloud LB params for the given Gateway, applying -// values from the merged effective LBC spec. Returned spec is a fresh copy. -func BuildLBSpec(gw *gwv1.Gateway, effective *vksv1alpha1.LoadBalancerConfigSpec, clusterID string) *vksv1alpha1.LoadBalancerConfigSpec { - out := *effective // shallow copy; pointer fields are shared but caller treats out as read-only after this call - out.Type = loadbalancerv2.LoadBalancerTypeLayer7 - out.ClusterId = ptr.To(clusterID) - if out.LoadBalancerName == "" { - out.LoadBalancerName = fmt.Sprintf("%s-gw-%s-%s", domain.DEFAULT_LB_PREFIX_NAME, gw.Namespace, gw.Name) - } - return &out -} -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/usecase/gateway_uc/alb_gateway_uc/... -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/usecase/gateway_uc/alb_gateway_uc/build_lb.go internal/usecase/gateway_uc/alb_gateway_uc/build_lb_test.go -git commit -m "feat(gateway/uc): add Gateway → LB-spec builder" -``` - ---- - -### Task C4: `build_listener.go` — Gateway listener → vngcloud listener (TDD) - -**Files:** -- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_listener.go` -- Test: `internal/usecase/gateway_uc/alb_gateway_uc/build_listener_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package alb_gateway_uc - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/utils/ptr" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - loadbalancerv2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" -) - -func TestBuildListener_HTTP(t *testing.T) { - l := gwv1.Listener{Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80} - out, err := BuildListener(l, nil, nil) - assert.NoError(t, err) - assert.Equal(t, loadbalancerv2.ListenerProtocolHTTP, out.Protocol) - assert.EqualValues(t, 80, out.ProtocolPort) - assert.Nil(t, out.CertificateDefault) -} - -func TestBuildListener_HTTPS_FromLBCCertID(t *testing.T) { - l := gwv1.Listener{Name: "https", Protocol: gwv1.HTTPSProtocolType, Port: 443} - lbcL := &vksv1alpha1.Listener{ - Name: "https", - CertificateDefault: &vksv1alpha1.ListenerCertificate{Id: ptr.To("cert-1")}, - } - out, err := BuildListener(l, lbcL, nil) - assert.NoError(t, err) - assert.Equal(t, loadbalancerv2.ListenerProtocolHTTPS, out.Protocol) - assert.NotNil(t, out.CertificateDefault) - assert.Equal(t, "cert-1", *out.CertificateDefault.Id) -} - -func TestBuildListener_LBCMismatchedProtocol_Errors(t *testing.T) { - l := gwv1.Listener{Name: "https", Protocol: gwv1.HTTPSProtocolType, Port: 443} - lbcL := &vksv1alpha1.Listener{Name: "https", Protocol: loadbalancerv2.ListenerProtocolHTTP, ProtocolPort: 80} - _, err := BuildListener(l, lbcL, nil) - assert.Error(t, err) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/usecase/gateway_uc/alb_gateway_uc/... -run TestBuildListener -v` - -Expected: FAIL. - -- [ ] **Step 3: Implement** - -```go -package alb_gateway_uc - -import ( - "fmt" - - loadbalancerv2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" - "k8s.io/utils/ptr" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" -) - -// CertSource is a lookup callback used when LBC overrides aren't present: -// returns (importedCertID, error). May return nil for "no cert". -type CertSource func(certRef gwv1.SecretObjectReference) (*vksv1alpha1.ListenerCertificate, error) - -// BuildListener converts a Gateway listener (with optional LBC override) into a vksv1alpha1.Listener. -// `lbcL` is the LBC-side listener entry matched by name (may be nil). -// `certs` builds a ListenerCertificate from a Gateway certificateRef when LBC has no cert IDs. -func BuildListener(l gwv1.Listener, lbcL *vksv1alpha1.Listener, certs CertSource) (*vksv1alpha1.Listener, error) { - proto, err := mapProtocol(l.Protocol) - if err != nil { - return nil, err - } - if lbcL != nil && lbcL.Protocol != "" && lbcL.Protocol != proto { - return nil, fmt.Errorf("LBC listener %q protocol %s does not match Gateway listener protocol %s", l.Name, lbcL.Protocol, proto) - } - if lbcL != nil && lbcL.ProtocolPort != 0 && lbcL.ProtocolPort != int32(l.Port) { - return nil, fmt.Errorf("LBC listener %q port %d does not match Gateway listener port %d", l.Name, lbcL.ProtocolPort, l.Port) - } - - out := &vksv1alpha1.Listener{ - Name: string(l.Name), - Protocol: proto, - ProtocolPort: int32(l.Port), - } - - if lbcL != nil { - out.TimeoutClient = lbcL.TimeoutClient - out.TimeoutMember = lbcL.TimeoutMember - out.TimeoutConnection = lbcL.TimeoutConnection - out.AllowedCidrs = lbcL.AllowedCidrs - out.InsertHeaders = lbcL.InsertHeaders - out.SSLPolicy = lbcL.SSLPolicy - out.ALPNPolicy = lbcL.ALPNPolicy - out.ClientCertificateId = lbcL.ClientCertificateId - if lbcL.CertificateDefault != nil { - out.CertificateDefault = lbcL.CertificateDefault - } - if len(lbcL.CertificateAuthorities) > 0 { - out.CertificateAuthorities = lbcL.CertificateAuthorities - } - } - - // Fall back to importing TLS Secrets from Gateway when LBC doesn't supply IDs. - if (proto == loadbalancerv2.ListenerProtocolHTTPS) && out.CertificateDefault == nil && l.TLS != nil && certs != nil { - for i, ref := range l.TLS.CertificateRefs { - cert, err := certs(ref) - if err != nil { - return nil, err - } - if cert == nil { - continue - } - if i == 0 { - out.CertificateDefault = cert - } else { - out.CertificateAuthorities = append(out.CertificateAuthorities, *cert) - } - } - } - - out.DefaultPoolName = ptr.To("vks-default-forwarding-pool") - return out, nil -} - -func mapProtocol(p gwv1.ProtocolType) (loadbalancerv2.ListenerProtocol, error) { - switch p { - case gwv1.HTTPProtocolType: - return loadbalancerv2.ListenerProtocolHTTP, nil - case gwv1.HTTPSProtocolType, gwv1.TLSProtocolType: - return loadbalancerv2.ListenerProtocolHTTPS, nil - default: - return "", fmt.Errorf("protocol %q not supported on ALB GatewayClass", p) - } -} -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/usecase/gateway_uc/alb_gateway_uc/... -run TestBuildListener -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/usecase/gateway_uc/alb_gateway_uc/build_listener.go internal/usecase/gateway_uc/alb_gateway_uc/build_listener_test.go -git commit -m "feat(gateway/uc): add Gateway → vngcloud listener builder" -``` - ---- - -### Task C5: `build_cert.go` — Secret import path - -**Files:** -- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_cert.go` - -- [ ] **Step 1: Read the existing ingress cert path** - -Run: `cat internal/usecase/ingress_uc/build_cert.go | head -100` - -Note the existing helper that imports a Kubernetes TLS Secret into a vngcloud cert (or returns a `SecretName`-only cert that the LBC reconciler will create). - -- [ ] **Step 2: Write a wrapper** - -```go -package alb_gateway_uc - -import ( - "fmt" - - "k8s.io/utils/ptr" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" -) - -// CertSourceForGateway returns a CertSource that resolves a Gateway listener's -// certificateRef to a ListenerCertificate. ReferenceGrant gating must be applied -// by the caller before invoking the returned function. -func (uc *albGatewayUseCase) CertSourceForGateway(gwNS string) CertSource { - return func(ref gwv1.SecretObjectReference) (*vksv1alpha1.ListenerCertificate, error) { - if ref.Group != nil && *ref.Group != "" { - return nil, fmt.Errorf("unsupported certificate ref group %q", *ref.Group) - } - if ref.Kind != nil && *ref.Kind != "Secret" { - return nil, fmt.Errorf("unsupported certificate ref kind %q", *ref.Kind) - } - // Hand off to the existing cert-import path: produce a placeholder ListenerCertificate - // referencing the secret name; lbc_uc will create the cert before listener apply. - return &vksv1alpha1.ListenerCertificate{SecretName: ptr.To(string(ref.Name))}, nil - } -} -``` - -- [ ] **Step 3: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add internal/usecase/gateway_uc/alb_gateway_uc/build_cert.go -git commit -m "feat(gateway/uc): add Gateway TLS cert source" -``` - ---- - -### Task C6: `build_pool.go` — synthetic pool with weight scaling (TDD) - -**Files:** -- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_pool.go` -- Test: `internal/usecase/gateway_uc/alb_gateway_uc/build_pool_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package alb_gateway_uc - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" -) - -func TestScaleWeights_TwoBackends_90_10(t *testing.T) { - in := []BackendEndpoints{ - {Backend: pkggw.BackendKey{Namespace: "n", Name: "a", Port: 80, Weight: 90}, Endpoints: []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}}, - {Backend: pkggw.BackendKey{Namespace: "n", Name: "b", Port: 80, Weight: 10}, Endpoints: []string{"10.0.0.4"}}, - } - members := SynthesizeMembers(in) - var sumA, sumB int - for _, m := range members { - if m.IPAddress == "10.0.0.4" { - sumB += int(m.Weight) - } else { - sumA += int(m.Weight) - } - } - // Ratio must be ~9:1 within rounding tolerance. - assert.True(t, sumA*10 >= sumB*85 && sumA*10 <= sumB*95, "want ~9:1 ratio, got %d:%d", sumA, sumB) -} - -func TestScaleWeights_AllSameWeight(t *testing.T) { - in := []BackendEndpoints{ - {Backend: pkggw.BackendKey{Namespace: "n", Name: "a", Port: 80, Weight: 1}, Endpoints: []string{"10.0.0.1"}}, - {Backend: pkggw.BackendKey{Namespace: "n", Name: "b", Port: 80, Weight: 1}, Endpoints: []string{"10.0.0.2"}}, - } - members := SynthesizeMembers(in) - for _, m := range members { - assert.GreaterOrEqual(t, m.Weight, int32(1)) - } -} - -func TestScaleWeights_FloorAt1(t *testing.T) { - in := []BackendEndpoints{ - {Backend: pkggw.BackendKey{Namespace: "n", Name: "a", Port: 80, Weight: 1}, Endpoints: []string{"10.0.0.1"}}, - {Backend: pkggw.BackendKey{Namespace: "n", Name: "b", Port: 80, Weight: 99}, Endpoints: []string{"10.0.0.2"}}, - } - members := SynthesizeMembers(in) - var found bool - for _, m := range members { - if m.IPAddress == "10.0.0.1" { - assert.GreaterOrEqual(t, m.Weight, int32(1)) - found = true - } - } - assert.True(t, found) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/usecase/gateway_uc/alb_gateway_uc/... -run TestScaleWeights -v` - -Expected: FAIL. - -- [ ] **Step 3: Implement** - -```go -package alb_gateway_uc - -import ( - "math" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" - pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" -) - -// BackendEndpoints groups one HTTPRoute backendRef with its resolved endpoints. -type BackendEndpoints struct { - Backend pkggw.BackendKey - Endpoints []string // pod-ip or node-ip strings (caller decides target-type) -} - -// SynthesizeMembers maps weighted backends to a flat list of pool members with -// integer weights scaled so the cross-backend ratio matches the requested weights. -// -// Algorithm: -// 1. Compute per-endpoint share = backend.Weight / count(endpoints). -// 2. Find the smallest share; rescale every endpoint share by min⁻¹. -// 3. Round to nearest int; floor at 1; cap at 100. -func SynthesizeMembers(in []BackendEndpoints) []vksv1alpha1.PoolMember { - if len(in) == 0 { - return nil - } - minShare := math.MaxFloat64 - for _, b := range in { - if len(b.Endpoints) == 0 || b.Backend.Weight <= 0 { - continue - } - share := float64(b.Backend.Weight) / float64(len(b.Endpoints)) - if share < minShare { - minShare = share - } - } - if minShare == math.MaxFloat64 { - minShare = 1 - } - - out := make([]vksv1alpha1.PoolMember, 0) - for _, b := range in { - if b.Backend.Weight <= 0 { - continue - } - share := float64(b.Backend.Weight) / float64(max(len(b.Endpoints), 1)) - w := int32(math.Round(share / minShare)) - if w < 1 { - w = 1 - } - if w > 100 { - w = 100 - } - for _, ep := range b.Endpoints { - out = append(out, vksv1alpha1.PoolMember{ - IPAddress: ep, - Port: b.Backend.Port, - Weight: w, - }) - } - } - return out -} - -func max(a, b int) int { if a > b { return a }; return b } -``` - -(Note: the engineer must check the actual `vksv1alpha1.PoolMember` field names — adjust `IPAddress`/`Port`/`Weight` to whatever the existing struct uses. Read `api/v1alpha1/loadbalancerconfig_types.go` first for the canonical field names.) - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/usecase/gateway_uc/alb_gateway_uc/... -run TestScaleWeights -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/usecase/gateway_uc/alb_gateway_uc/build_pool.go internal/usecase/gateway_uc/alb_gateway_uc/build_pool_test.go -git commit -m "feat(gateway/uc): add synthetic-pool weight scaling" -``` - ---- - -### Task C7: `build_policy.go` — HTTPRoute → policies (TDD) - -**Files:** -- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_policy.go` -- Test: `internal/usecase/gateway_uc/alb_gateway_uc/build_policy_test.go` - -- [ ] **Step 1: Write the failing test** - -```go -package alb_gateway_uc - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/utils/ptr" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - loadbalancerv2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" -) - -func TestBuildPolicies_HostnameAndPath(t *testing.T) { - rule := gwv1.HTTPRouteRule{ - Matches: []gwv1.HTTPRouteMatch{{ - Path: &gwv1.HTTPPathMatch{Type: ptr.To(gwv1.PathMatchPathPrefix), Value: ptr.To("/api")}, - }}, - } - hosts := []gwv1.Hostname{"a.example.com"} - policies := BuildPolicies("route-uid-12345678", 0, hosts, rule, "synth-pool", nil) - if assert.Len(t, policies, 1) { - p := policies[0] - assert.Equal(t, loadbalancerv2.PolicyActionREDIRECTTOPOOL, p.Action) - var sawHost, sawPath bool - for _, r := range p.L7Rules { - if r.RuleType == loadbalancerv2.PolicyRuleTypeHOSTNAME { - sawHost = true - } - if r.RuleType == loadbalancerv2.PolicyRuleTypePATH { - sawPath = true - assert.Equal(t, loadbalancerv2.PolicyCompareTypeSTARTSWITH, r.CompareType) - assert.Equal(t, "/api", r.RuleValue) - } - } - assert.True(t, sawHost && sawPath) - } -} - -func TestBuildPolicies_NoHostnames_OneMatch(t *testing.T) { - rule := gwv1.HTTPRouteRule{ - Matches: []gwv1.HTTPRouteMatch{{Path: &gwv1.HTTPPathMatch{Type: ptr.To(gwv1.PathMatchExact), Value: ptr.To("/x")}}}, - } - policies := BuildPolicies("uid", 0, nil, rule, "p", nil) - assert.Len(t, policies, 1) -} - -func TestBuildPolicies_RedirectFilter(t *testing.T) { - redirect := gwv1.HTTPRequestRedirectFilter{Hostname: ptr.To(gwv1.PreciseHostname("new.example.com")), StatusCode: ptr.To(301)} - rule := gwv1.HTTPRouteRule{ - Matches: []gwv1.HTTPRouteMatch{{Path: &gwv1.HTTPPathMatch{Type: ptr.To(gwv1.PathMatchExact), Value: ptr.To("/")}}}, - Filters: []gwv1.HTTPRouteFilter{{Type: gwv1.HTTPRouteFilterRequestRedirect, RequestRedirect: &redirect}}, - } - policies := BuildPolicies("uid", 0, nil, rule, "p", nil) - assert.Len(t, policies, 1) - assert.Equal(t, loadbalancerv2.PolicyActionREDIRECTTOURL, policies[0].Action) -} -``` - -- [ ] **Step 2: Run to confirm fail** - -Run: `go test ./internal/usecase/gateway_uc/alb_gateway_uc/... -run TestBuildPolicies -v` - -Expected: FAIL. - -- [ ] **Step 3: Implement** - -```go -package alb_gateway_uc - -import ( - "fmt" - - loadbalancerv2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - gatewayv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" - pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" -) - -// BuildPolicies maps one HTTPRoute rule into one-or-more vngcloud policies, one per -// (hostname × match) cartesian. A nil hosts slice is treated as a single empty hostname. -// The poolName is the synthetic pool that REDIRECT_TO_POOL targets when no overriding action. -// lrcs may be nil. -func BuildPolicies( - routeUID string, - ruleIdx int, - hosts []gwv1.Hostname, - rule gwv1.HTTPRouteRule, - poolName string, - lrcs []*gatewayv1alpha1.ListenerRuleConfig, -) []vksv1alpha1.Policy { - if len(hosts) == 0 { - hosts = []gwv1.Hostname{""} - } - out := make([]vksv1alpha1.Policy, 0, len(hosts)*max(len(rule.Matches), 1)) - matches := rule.Matches - if len(matches) == 0 { - matches = []gwv1.HTTPRouteMatch{{}} - } - - redirect := findRedirectFilter(rule.Filters) - - for _, h := range hosts { - for mi, m := range matches { - l7 := []vksv1alpha1.L7Rule{} - if cmp, val := pkggw.HostnameToL7Rule(string(h)); cmp != "" { - l7 = append(l7, vksv1alpha1.L7Rule{ - RuleType: loadbalancerv2.PolicyRuleTypeHOSTNAME, CompareType: loadbalancerv2.PolicyCompareType(cmp), RuleValue: val, - }) - } - if m.Path != nil && m.Path.Type != nil && m.Path.Value != nil { - cmp, val := pkggw.PathToL7Rule(string(*m.Path.Type), *m.Path.Value) - l7 = append(l7, vksv1alpha1.L7Rule{ - RuleType: loadbalancerv2.PolicyRuleTypePATH, CompareType: loadbalancerv2.PolicyCompareType(cmp), RuleValue: val, - }) - } - for _, lrc := range lrcs { - for _, am := range lrc.Spec.AdditionalMatches { - rt := mapAdditionalMatchType(am.Type) - if rt == "" { - continue // unsupported under current vngcloud LB - } - l7 = append(l7, vksv1alpha1.L7Rule{ - RuleType: rt, CompareType: loadbalancerv2.PolicyCompareType(am.Compare), RuleValue: am.Value, - }) - } - } - - p := vksv1alpha1.Policy{ - Name: fmt.Sprintf("p-%s-%d-%d", routeUID, ruleIdx, mi), - L7Rules: l7, - } - switch { - case redirect != nil: - p.Action = loadbalancerv2.PolicyActionREDIRECTTOURL - if u := buildRedirectURL(*redirect); u != "" { - p.RedirectUrl = &u - } - if redirect.StatusCode != nil { - code := int32(*redirect.StatusCode) - p.RedirectHttpCode = &code - } - default: - p.Action = loadbalancerv2.PolicyActionREDIRECTTOPOOL - p.RedirectPoolName = &poolName - } - out = append(out, p) - } - } - return out -} - -func findRedirectFilter(filters []gwv1.HTTPRouteFilter) *gwv1.HTTPRequestRedirectFilter { - for _, f := range filters { - if f.Type == gwv1.HTTPRouteFilterRequestRedirect { - return f.RequestRedirect - } - } - return nil -} - -func buildRedirectURL(r gwv1.HTTPRequestRedirectFilter) string { - scheme := "https" - if r.Scheme != nil { - scheme = *r.Scheme - } - host := "" - if r.Hostname != nil { - host = string(*r.Hostname) - } - if host == "" { - return "" - } - return scheme + "://" + host -} - -func mapAdditionalMatchType(t string) loadbalancerv2.PolicyRuleType { - switch t { - case "Header", "QueryParam", "Method", "SourceIP": - return "" // not yet supported by vngcloud LB; emit nothing for v1 - default: - return "" - } -} - -func max(a, b int) int { if a > b { return a }; return b } -``` - -- [ ] **Step 4: Run tests** - -Run: `go test ./internal/usecase/gateway_uc/alb_gateway_uc/... -run TestBuildPolicies -v` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/usecase/gateway_uc/alb_gateway_uc/build_policy.go internal/usecase/gateway_uc/alb_gateway_uc/build_policy_test.go -git commit -m "feat(gateway/uc): add HTTPRoute → vngcloud policy builder" -``` - ---- - -### Task C8: `build_sec_group.go` — NSG inheritance from Ingress - -**Files:** -- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_sec_group.go` - -- [ ] **Step 1: Read ingress NSG path** - -Run: `cat internal/usecase/ingress_uc/build_sec_group.go | head -80` - -- [ ] **Step 2: Write a thin wrapper that delegates to the same NSG logic** - -```go -package alb_gateway_uc - -import ( - "context" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" -) - -// BuildSecurityGroups wires the merged effective LBC's security-group config to the LB. -// For Phase 1, the merged spec already contains the SG list — this is a passthrough that -// matches the ingress code path's signature so future shared helpers can be extracted. -func (uc *albGatewayUseCase) BuildSecurityGroups(_ context.Context, effective *vksv1alpha1.LoadBalancerConfigSpec) []string { - // The existing LBC.Tags / SecurityGroups mechanism is already honored by lbc_uc. - // Return a stable list (currently empty) so callers can plug in additional logic in Phase 3. - _ = effective - return nil -} -``` - -- [ ] **Step 3: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add internal/usecase/gateway_uc/alb_gateway_uc/build_sec_group.go -git commit -m "feat(gateway/uc): scaffold security-group resolution" -``` - ---- - -### Task C9: Wire `EnsureALBGatewayUseCase` end-to-end - -**Files:** -- Modify: `internal/usecase/gateway_uc/alb_gateway_uc/gateway_uc.go` -- Test: `internal/usecase/gateway_uc/alb_gateway_uc/gateway_uc_test.go` - -This is the largest step in the use-case layer. It threads the per-Gateway reconcile through everything built so far. - -- [ ] **Step 1: Read existing IngressUseCase.Ensure for the canonical pattern** - -Run: `cat internal/usecase/ingress_uc/ingress_uc.go | sed -n '60,180p'` - -Note the structure: load resource → finalize-or-cleanup branch → resolve effective config → build LB spec → reconcile via lbc_uc → write status. - -- [ ] **Step 2: Write the failing test (envtest-style happy path)** - -Tests at the use-case layer use mocks for repos. Write a single happy-path test that: -1. Constructs a fake `K8sRepository` returning a valid Gateway + GatewayClass + LoadBalancerConfig. -2. Constructs a `VngCloudRepository` mock that records calls. -3. Calls `EnsureALBGatewayUseCase` and asserts: - - `K8sRepo.Update` called on the Gateway with `gateway.vks.vngcloud.vn/resources` finalizer. - - At least one `VngCloudRepo.EnsureLoadBalancer*` call. - - Gateway status conditions include `Accepted=True` and `Programmed=True` (or `Pending` if LB still provisioning). - -(Test code is verbose — engineer mirrors the pattern from `internal/usecase/ingress_uc/ingress_uc_test.go`. Reuse mock factories under `internal/repository/{k8s_repo,vngcloud_repo}/*_mocks/`.) - -- [ ] **Step 3: Run to confirm fail** - -Run: `go test ./internal/usecase/gateway_uc/alb_gateway_uc/... -run TestEnsureALB_HappyPath -v` - -Expected: FAIL — `EnsureALBGatewayUseCase` returns nil but doesn't actually do anything yet. - -- [ ] **Step 4: Implement `EnsureALBGatewayUseCase` and supporting methods** - -The implementation follows this pseudocode: - -```go -func (uc *albGatewayUseCase) EnsureALBGatewayUseCase(ctx context.Context, req ctrl.Request) error { - log := contexts.NewContext(ctx).Log() - gw := &gwv1.Gateway{} - if err := uc.k8sRepo.Get(ctx, req.NamespacedName, gw); err != nil { - if apierrors.IsNotFound(err) { return nil } - return err - } - - // 0. Finalizer - if gw.DeletionTimestamp != nil { - return uc.delete(ctx, gw) - } - if !controllerutil.ContainsFinalizer(gw, domain.GatewayFinalizer) { - controllerutil.AddFinalizer(gw, domain.GatewayFinalizer) - if err := uc.k8sRepo.Update(ctx, gw); err != nil { return err } - } - - // 1. Resolve effective LBC - gwc := &gwv1.GatewayClass{} - if err := uc.k8sRepo.Get(ctx, types.NamespacedName{Name: string(gw.Spec.GatewayClassName)}, gwc); err != nil { - return uc.markGatewayNotAccepted(ctx, gw, "GatewayClassNotFound", err.Error()) - } - if string(gwc.Spec.ControllerName) != domain.ControllerNameALB { - return nil // not ours - } - classLBC := uc.lookupLBC(ctx, gwc.Spec.ParametersRef, "") - gwLBC := uc.lookupLBC(ctx, gw.Spec.Infrastructure.ParametersRef, gw.Namespace) - effective := shared.MergeLBC(classLBC, gwLBC) - - // 2. Validate listeners - invalid := ctlshared.ValidateListenersForALB(gw.Spec.Listeners) - - // 3. Build LB spec, listeners - lbSpec := BuildLBSpec(gw, effective, uc.clusterId) - listeners := []vksv1alpha1.Listener{} - for _, l := range gw.Spec.Listeners { - if r := invalid[string(l.Name)]; r != ctlshared.ListenerInvalidReasonNone { - continue - } - lbcL := lookupListenerByName(effective.Listeners, string(l.Name)) - b, err := BuildListener(l, lbcL, uc.CertSourceForGateway(gw.Namespace)) - if err != nil { return err } - listeners = append(listeners, *b) - } - lbSpec.Listeners = listeners - - // 4. Build pools + policies from attached HTTPRoutes - routes := uc.listAttachedHTTPRoutes(ctx, gw) - pools, policies := uc.buildPoolsAndPolicies(ctx, gw, routes) - lbSpec.Pools = pools - for li := range lbSpec.Listeners { - lbSpec.Listeners[li].Policies = filterPoliciesForListener(policies, lbSpec.Listeners[li]) - } - - // 5. Hand off to lbc_uc deploy path - if err := uc.deployLB(ctx, gw, lbSpec); err != nil { - return uc.markProgrammedFalse(ctx, gw, err) - } - - // 6. Status - return uc.writeAcceptedAndProgrammed(ctx, gw, lbSpec, invalid, routes) -} -``` - -The engineer fills in `lookupLBC`, `lookupListenerByName`, `listAttachedHTTPRoutes`, `buildPoolsAndPolicies`, `filterPoliciesForListener`, `deployLB`, `markGatewayNotAccepted`, `markProgrammedFalse`, `writeAcceptedAndProgrammed`, and `delete` by mirroring the equivalent helpers in `internal/usecase/ingress_uc/`. - -Replace each TODO from Task C2 with real logic. `EnqueueParentGatewayForRoute` is implemented by emitting a controller-runtime event for the parent Gateway (via the `eventRecorder` or by writing an annotation tick — pick one and document). - -- [ ] **Step 5: Run tests** - -Run: `go test ./internal/usecase/gateway_uc/... -v` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add internal/usecase/gateway_uc/alb_gateway_uc/ -git commit -m "feat(gateway/uc): wire ALB Gateway reconciliation end-to-end" -``` - ---- - -## Block D — Reconcilers - -### Task D1: GatewayClass reconciler - -**Files:** -- Create: `internal/controller/gateway/alb/gatewayclass_controller.go` - -- [ ] **Step 1: Implement the reconciler** - -```go -package alb - -import ( - "context" - "time" - - "github.com/anngdinh/operator-helper/contexts" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/manager" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - vksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" - ctlshared "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" -) - -type GatewayClassReconciler struct { - client.Client - Scheme *runtime.Scheme -} - -func NewGatewayClassReconciler(c client.Client, sch *runtime.Scheme) *GatewayClassReconciler { - return &GatewayClassReconciler{Client: c, Scheme: sch} -} - -func (r *GatewayClassReconciler) SetupWithManager(mgr manager.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&gwv1.GatewayClass{}). - Named("gatewayclass-alb"). - Complete(r) -} - -func (r *GatewayClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - ctx = contexts.NewContext(ctx).SetLogName("gwc/" + req.Name).GetContext() - log := contexts.NewContext(ctx).Log() - - gwc := &gwv1.GatewayClass{} - if err := r.Get(ctx, req.NamespacedName, gwc); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - if string(gwc.Spec.ControllerName) != domain.ControllerNameALB { - return ctrl.Result{}, nil // not ours - } - - accepted := metav1.ConditionTrue - reason, msg := "Accepted", "GatewayClass accepted by vngcloud-alb controller" - - if gwc.Spec.ParametersRef != nil { - // Validate the referenced LBC exists. - ref := gwc.Spec.ParametersRef - if string(ref.Group) != "vks.vngcloud.vn" || string(ref.Kind) != "LoadBalancerConfig" { - accepted = metav1.ConditionFalse - reason, msg = "InvalidParameters", "parametersRef must point to vks.vngcloud.vn/LoadBalancerConfig" - } else { - lbc := &vksv1alpha1.LoadBalancerConfig{} - ns := "" - if ref.Namespace != nil { ns = string(*ref.Namespace) } - err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: string(ref.Name)}, lbc) - if err != nil { - accepted = metav1.ConditionFalse - reason, msg = "InvalidParameters", "referenced LoadBalancerConfig not found: "+err.Error() - } - } - } - - ctlshared.SetCondition(&gwc.Status.Conditions, "Accepted", accepted, reason, msg, gwc.Generation) - if err := r.Status().Update(ctx, gwc); err != nil { - log.Errorf("status update failed: %v", err) - return ctrl.Result{RequeueAfter: 30 * time.Second}, err - } - return ctrl.Result{}, nil -} -``` - -- [ ] **Step 2: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add internal/controller/gateway/alb/gatewayclass_controller.go -git commit -m "feat(gateway/ctrl): add GatewayClass reconciler for vngcloud-alb" -``` - ---- - -### Task D2: Gateway reconciler - -**Files:** -- Create: `internal/controller/gateway/alb/gateway_controller.go` - -- [ ] **Step 1: Read the existing IngressReconciler for pattern** - -Run: `cat internal/controller/networking/ingress_controller.go | sed -n '1,200p'` - -- [ ] **Step 2: Write the reconciler** - -```go -package alb - -import ( - "context" - "sync/atomic" - "time" - - "github.com/anngdinh/operator-helper/contexts" - "github.com/go-logr/logr" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/record" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/manager" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase" - "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/k8s" - metricsutil "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/metrics/util" -) - -type GatewayReconciler struct { - Client client.Client - Scheme *runtime.Scheme - GatewayUseCase usecase.ALBGatewayUseCase - FinalizerManager k8s.FinalizerManager - EventRecorder record.EventRecorder - Logger logr.Logger - ReconcileCounters *metricsutil.ReconcileCounters - MaxConcurrent int - initDone atomic.Bool -} - -func NewGatewayReconciler(uc usecase.ALBGatewayUseCase, c client.Client, sch *runtime.Scheme, fm k8s.FinalizerManager, er record.EventRecorder, rc *metricsutil.ReconcileCounters, mc int) *GatewayReconciler { - if mc <= 0 { mc = domain.DefaultMaxConcurrentReconciles } - return &GatewayReconciler{ - Client: c, Scheme: sch, GatewayUseCase: uc, FinalizerManager: fm, - EventRecorder: er, Logger: ctrl.Log.WithName("controllers").WithName("alb-gateway"), - ReconcileCounters: rc, MaxConcurrent: mc, - } -} - -func (r *GatewayReconciler) SetupWithManager(mgr manager.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&gwv1.Gateway{}). - WithOptions(controller.Options{MaxConcurrentReconciles: r.MaxConcurrent}). - Named("gateway-alb"). - Complete(r) -} - -func (r *GatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - if !r.initDone.Load() { - if err := r.GatewayUseCase.InitALBGatewayUseCase(ctx); err != nil { - return ctrl.Result{RequeueAfter: time.Second}, err - } - r.initDone.Store(true) - } - r.ReconcileCounters.IncrementGateway(req.NamespacedName) - ctx = contexts.NewContext(ctx).SetLogName("gw/" + req.Namespace + "/" + req.Name).GetContext() - ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) - defer cancel() - if err := r.GatewayUseCase.EnsureALBGatewayUseCase(ctx, req); err != nil { - return ctrl.Result{RequeueAfter: 30 * time.Second}, err - } - return ctrl.Result{}, nil -} -``` - -- [ ] **Step 3: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add internal/controller/gateway/alb/gateway_controller.go -git commit -m "feat(gateway/ctrl): add Gateway reconciler for vngcloud-alb" -``` - ---- - -### Task D3: HTTPRoute reconciler - -**Files:** -- Create: `internal/controller/gateway/alb/httproute_controller.go` - -- [ ] **Step 1: Implement (thin: enqueue parent only)** - -```go -package alb - -import ( - "context" - "time" - - "github.com/anngdinh/operator-helper/contexts" - "k8s.io/apimachinery/pkg/runtime" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/manager" - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase" - metricsutil "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/metrics/util" -) - -type HTTPRouteReconciler struct { - Client client.Client - Scheme *runtime.Scheme - GatewayUseCase usecase.ALBGatewayUseCase - ReconcileCounters *metricsutil.ReconcileCounters -} - -func NewHTTPRouteReconciler(uc usecase.ALBGatewayUseCase, c client.Client, sch *runtime.Scheme, rc *metricsutil.ReconcileCounters) *HTTPRouteReconciler { - return &HTTPRouteReconciler{Client: c, Scheme: sch, GatewayUseCase: uc, ReconcileCounters: rc} -} - -func (r *HTTPRouteReconciler) SetupWithManager(mgr manager.Manager) error { - return ctrl.NewControllerManagedBy(mgr).For(&gwv1.HTTPRoute{}).Named("httproute-alb").Complete(r) -} - -func (r *HTTPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - r.ReconcileCounters.IncrementHTTPRoute(req.NamespacedName) - ctx = contexts.NewContext(ctx).SetLogName("httproute/" + req.Namespace + "/" + req.Name).GetContext() - if err := r.GatewayUseCase.EnqueueParentGatewayForRoute(ctx, req); err != nil { - return ctrl.Result{RequeueAfter: 30 * time.Second}, err - } - return ctrl.Result{}, nil -} -``` - -- [ ] **Step 2: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add internal/controller/gateway/alb/httproute_controller.go -git commit -m "feat(gateway/ctrl): add HTTPRoute reconciler that enqueues parent Gateway" -``` - ---- - -### Task D4: TargetGroupConfig validator controller - -**Files:** -- Create: `internal/controller/gateway/targetgroupconfig/controller.go` - -- [ ] **Step 1: Implement** - -```go -package targetgroupconfig - -import ( - "context" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/manager" - - gatewayv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" - ctlshared "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" -) - -type Reconciler struct { - client.Client - Scheme *runtime.Scheme -} - -func New(c client.Client, sch *runtime.Scheme) *Reconciler { return &Reconciler{Client: c, Scheme: sch} } - -func (r *Reconciler) SetupWithManager(mgr manager.Manager) error { - return ctrl.NewControllerManagedBy(mgr).For(&gatewayv1alpha1.TargetGroupConfig{}).Named("tgc").Complete(r) -} - -func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - obj := &gatewayv1alpha1.TargetGroupConfig{} - if err := r.Get(ctx, req.NamespacedName, obj); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - // Phase-1 validation: target name non-empty (kubebuilder enforces required), no double-checking yet. - // Status: Accepted=True if reachable; Conflict detection runs in the route layer. - ctlshared.SetCondition(&obj.Status.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "TargetGroupConfig observed", obj.Generation) - obj.Status.ObservedGeneration = obj.Generation - return ctrl.Result{}, r.Status().Update(ctx, obj) -} -``` - -- [ ] **Step 2: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add internal/controller/gateway/targetgroupconfig/controller.go -git commit -m "feat(gateway/ctrl): add TargetGroupConfig validator" -``` - ---- - -### Task D5: ListenerRuleConfig validator controller - -**Files:** -- Create: `internal/controller/gateway/listenerruleconfig/controller.go` - -- [ ] **Step 1: Implement (mirror TGC)** - -```go -package listenerruleconfig - -import ( - "context" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/manager" - - gatewayv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" - ctlshared "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" -) - -type Reconciler struct { - client.Client - Scheme *runtime.Scheme -} - -func New(c client.Client, sch *runtime.Scheme) *Reconciler { return &Reconciler{Client: c, Scheme: sch} } - -func (r *Reconciler) SetupWithManager(mgr manager.Manager) error { - return ctrl.NewControllerManagedBy(mgr).For(&gatewayv1alpha1.ListenerRuleConfig{}).Named("lrc").Complete(r) -} - -func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - obj := &gatewayv1alpha1.ListenerRuleConfig{} - if err := r.Get(ctx, req.NamespacedName, obj); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - ctlshared.SetCondition(&obj.Status.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "ListenerRuleConfig observed", obj.Generation) - obj.Status.ObservedGeneration = obj.Generation - return ctrl.Result{}, r.Status().Update(ctx, obj) -} -``` - -- [ ] **Step 2: Build & commit** - -Run: `go build ./...`; commit. - -```bash -git add internal/controller/gateway/listenerruleconfig/controller.go -git commit -m "feat(gateway/ctrl): add ListenerRuleConfig validator" -``` - ---- - -### Task D6: envtest suite for ALB reconcilers - -**Files:** -- Create: `internal/controller/gateway/alb/suite_test.go` -- Create: `internal/controller/gateway/alb/gateway_controller_test.go` - -- [ ] **Step 1: Read the existing networking suite** - -Run: `cat internal/controller/networking/suite_test.go | sed -n '1,200p'` - -- [ ] **Step 2: Adapt for ALB suite** - -Mirror the networking suite, swapping the registered scheme for `gwv1.Install`, `gwv1alpha2.Install`, `gatewayv1alpha1.AddToScheme`, then register the three reconcilers above. Wire envtest CRDs from `config/crd/bases/` plus the upstream Gateway API CRDs. - -(The engineer copies the networking suite verbatim and edits it. Full code omitted to avoid plan bloat — the structure is well-established.) - -- [ ] **Step 3: Write a Ginkgo test "Reconcile a basic Gateway"** - -Asserts that creating a Gateway with one HTTP listener leads to `Programmed=True` (with a stubbed `VngCloudRepository` returning a fake LB). Uses `Eventually(... Succeed())` to poll the resource's status. - -- [ ] **Step 4: Run** - -Run: `go test ./internal/controller/gateway/alb/... -v` - -Expected: PASS (envtest provisions a control plane). - -- [ ] **Step 5: Commit** - -```bash -git add internal/controller/gateway/alb/suite_test.go internal/controller/gateway/alb/gateway_controller_test.go -git commit -m "test(gateway/ctrl): add envtest suite for ALB reconcilers" -``` - ---- - -## Block E — Wiring - -### Task E1: Register Gateway API scheme in `cmd/main.go` - -**Files:** -- Modify: `cmd/main.go` - -- [ ] **Step 1: Add imports & scheme registrations** - -In the imports block, add: - -```go -gwv1 "sigs.k8s.io/gateway-api/apis/v1" -gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" -gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" -gatewayv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" -albgw "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/alb" -gwtgc "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/targetgroupconfig" -gwlrc "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/listenerruleconfig" -"github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/alb_gateway_uc" -``` - -In the `init()` function, append: - -```go -utilruntime.Must(gwv1.Install(scheme)) -utilruntime.Must(gwv1alpha2.Install(scheme)) -utilruntime.Must(gwv1beta1.Install(scheme)) -utilruntime.Must(gatewayv1alpha1.AddToScheme(scheme)) -``` - -- [ ] **Step 2: Add feature-gate flags** - -Inside `main()`, alongside the existing `flag.BoolVar` calls, add: - -```go -var enableGatewayAPIALB bool -flag.BoolVar(&enableGatewayAPIALB, "enable-gateway-api-alb", false, "Enable the ALB Gateway API controller (Phase 1).") -``` - -- [ ] **Step 3: Wire reconcilers** - -After existing reconciler `SetupWithManager` calls, append: - -```go -if enableGatewayAPIALB { - albUC := alb_gateway_uc.NewALBGatewayUseCase( - conf.ClusterID, k8sRepo, vngcloudRepo, annotationParser, cniDetector, endpointResolver, - ) - if err := albgw.NewGatewayClassReconciler(mgr.GetClient(), mgr.GetScheme()).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to set up GatewayClass reconciler"); os.Exit(1) - } - if err := albgw.NewGatewayReconciler(albUC, mgr.GetClient(), mgr.GetScheme(), finalizerManager, - mgr.GetEventRecorderFor("gateway-alb"), reconcileCounters, conf.MaxConcurrentReconciles).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to set up Gateway reconciler"); os.Exit(1) - } - if err := albgw.NewHTTPRouteReconciler(albUC, mgr.GetClient(), mgr.GetScheme(), reconcileCounters).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to set up HTTPRoute reconciler"); os.Exit(1) - } - if err := gwtgc.New(mgr.GetClient(), mgr.GetScheme()).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to set up TGC reconciler"); os.Exit(1) - } - if err := gwlrc.New(mgr.GetClient(), mgr.GetScheme()).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to set up LRC reconciler"); os.Exit(1) - } -} -``` - -- [ ] **Step 4: Build** - -Run: `go build ./...` - -Expected: success. - -- [ ] **Step 5: Smoke-run** - -Run: `go run ./cmd --enable-gateway-api-alb=true --metrics-bind-address=0` for 5 seconds; ensure no panic. (Uses local kubeconfig; if not connected to a cluster, accept the connection error but verify the binary reaches the manager.Start call.) - -- [ ] **Step 6: Commit** - -```bash -git add cmd/main.go -git commit -m "feat(cmd): wire ALB Gateway API controllers behind feature gate" -``` - ---- - -### Task E2: RBAC manifests - -**Files:** -- Modify: `internal/controller/gateway/alb/gatewayclass_controller.go` (add kubebuilder rbac markers) -- Modify: `internal/controller/gateway/alb/gateway_controller.go` (markers) -- Modify: `internal/controller/gateway/alb/httproute_controller.go` (markers) -- Modify: `internal/controller/gateway/targetgroupconfig/controller.go` (markers) -- Modify: `internal/controller/gateway/listenerruleconfig/controller.go` (markers) -- Modify: `config/rbac/role.yaml` (regenerated) - -- [ ] **Step 1: Add RBAC markers** - -At the top of each reconciler file, above the `Reconcile` method, add the appropriate `// +kubebuilder:rbac:groups=...` comments per the spec §2.4. - -- [ ] **Step 2: Regenerate** - -Run: `make manifests` - -Expected: `config/rbac/role.yaml` updated. - -- [ ] **Step 3: Commit** - -```bash -git add internal/controller/gateway/ config/rbac/role.yaml -git commit -m "feat(rbac): add Gateway-API verbs to manager Role" -``` - ---- - -## Block F — Helm, samples, docs - -### Task F1: Bundle CRDs into the chart - -**Files:** -- Create: `charts/vngcloud-load-balancer-controller/templates/crds/gateway.vks.vngcloud.vn_targetgroupconfigs.yaml` -- Create: `charts/vngcloud-load-balancer-controller/templates/crds/gateway.vks.vngcloud.vn_listenerruleconfigs.yaml` -- Modify: `charts/vngcloud-load-balancer-controller/templates/crds/vks.vngcloud.vn_loadbalancerconfigs.yaml` - -- [ ] **Step 1: Copy generated CRDs into chart** - -```bash -cp config/crd/bases/gateway.vks.vngcloud.vn_targetgroupconfigs.yaml \ - charts/vngcloud-load-balancer-controller/templates/crds/ -cp config/crd/bases/gateway.vks.vngcloud.vn_listenerruleconfigs.yaml \ - charts/vngcloud-load-balancer-controller/templates/crds/ -cp config/crd/bases/vks.vngcloud.vn_loadbalancerconfigs.yaml \ - charts/vngcloud-load-balancer-controller/templates/crds/ -``` - -- [ ] **Step 2: Verify chart lint** - -Run: `helm lint charts/vngcloud-load-balancer-controller/` - -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add charts/vngcloud-load-balancer-controller/templates/crds/ -git commit -m "feat(chart): bundle Gateway-API config CRDs" -``` - ---- - -### Task F2: GatewayClass manifest + values.yaml + manager flag - -**Files:** -- Create: `charts/vngcloud-load-balancer-controller/templates/gatewayclass-alb.yaml` -- Modify: `charts/vngcloud-load-balancer-controller/values.yaml` -- Modify: `charts/vngcloud-load-balancer-controller/templates/manager-deployment.yaml` - -- [ ] **Step 1: Write the GatewayClass template** - -```yaml -{{- if .Values.gatewayApi.alb.enabled -}} -apiVersion: gateway.networking.k8s.io/v1 -kind: GatewayClass -metadata: - name: vngcloud-alb -spec: - controllerName: gateway.vks.vngcloud.vn/alb - {{- with .Values.gatewayApi.alb.parametersRef }} - parametersRef: - {{- toYaml . | nindent 4 }} - {{- end }} -{{- end -}} -``` - -- [ ] **Step 2: Add values defaults** - -Append to `values.yaml`: - -```yaml -gatewayApi: - alb: - enabled: false - parametersRef: {} -``` - -- [ ] **Step 3: Inject feature-gate flag** - -In `manager-deployment.yaml`, locate the existing `args:` list and conditionally append: - -```yaml -{{- if .Values.gatewayApi.alb.enabled }} -- --enable-gateway-api-alb=true -{{- end }} -``` - -- [ ] **Step 4: Verify** - -Run: `helm template charts/vngcloud-load-balancer-controller --set gatewayApi.alb.enabled=true | grep -A2 GatewayClass` - -Expected: GatewayClass YAML rendered. - -- [ ] **Step 5: Commit** - -```bash -git add charts/vngcloud-load-balancer-controller/templates/gatewayclass-alb.yaml charts/vngcloud-load-balancer-controller/values.yaml charts/vngcloud-load-balancer-controller/templates/manager-deployment.yaml -git commit -m "feat(chart): add vngcloud-alb GatewayClass and feature gate" -``` - ---- - -### Task F3: Sample manifests - -**Files:** -- Create: `config/samples/gateway_v1_alb_basic.yaml` -- Create: `config/samples/gateway_v1_alb_tls.yaml` -- Create: `config/samples/gateway_v1_alb_canary.yaml` -- Create: `config/samples/targetgroupconfig_basic.yaml` -- Create: `config/samples/listenerruleconfig_header.yaml` - -- [ ] **Step 1: Write `gateway_v1_alb_basic.yaml`** - -```yaml ---- -apiVersion: gateway.networking.k8s.io/v1 -kind: Gateway -metadata: - name: demo - namespace: default -spec: - gatewayClassName: vngcloud-alb - listeners: - - name: http - protocol: HTTP - port: 80 ---- -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: demo-route - namespace: default -spec: - parentRefs: - - name: demo - hostnames: - - demo.example.com - rules: - - matches: - - path: { type: PathPrefix, value: / } - backendRefs: - - name: demo-service - port: 80 -``` - -- [ ] **Step 2: Write `gateway_v1_alb_tls.yaml`** - -(HTTP+HTTPS Gateway with `certificateRefs` to a `tls-secret`.) - -- [ ] **Step 3: Write `gateway_v1_alb_canary.yaml`** - -(One HTTPRoute rule with two weighted backendRefs `90`/`10`.) - -- [ ] **Step 4: Write `targetgroupconfig_basic.yaml`** - -```yaml -apiVersion: gateway.vks.vngcloud.vn/v1alpha1 -kind: TargetGroupConfig -metadata: - name: demo-tgc - namespace: default -spec: - targetReference: - name: demo-service - defaultConfig: - targetType: ip - poolAlgorithm: ROUND_ROBIN - healthCheck: - protocol: HTTP - successCodes: "200" - healthCheckPath: /healthz - intervalSeconds: 10 - timeoutSeconds: 3 - healthyThreshold: 2 - unhealthyThreshold: 3 -``` - -- [ ] **Step 5: Write `listenerruleconfig_header.yaml`** - -```yaml -apiVersion: gateway.vks.vngcloud.vn/v1alpha1 -kind: ListenerRuleConfig -metadata: - name: header-internal - namespace: default -spec: - additionalMatches: - - type: Header - name: X-Source - compare: CONTAINS - value: internal -``` - -- [ ] **Step 6: Commit** - -```bash -git add config/samples/ -git commit -m "docs(samples): add Gateway-API sample manifests" -``` - ---- - -### Task F4: User guide — `gateway-api.md` - -**Files:** -- Create: `docs/guide/gateway-api.md` - -- [ ] **Step 1: Write the guide** - -Sections: -1. Overview — why Gateway API, two GatewayClasses, when to use vs Ingress. -2. Prerequisites — install upstream Gateway API CRDs (`kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml`). -3. Enable the controller — `helm upgrade ... --set gatewayApi.alb.enabled=true`. -4. Quickstart — apply `config/samples/gateway_v1_alb_basic.yaml`; show `kubectl get gateway`, address resolution. -5. Capability matrix — features Supported / Partial / Unsupported (mirror spec §6.1 Phase 1 scope). -6. Coexistence with Ingress — separate LBs; no sharing in Phase 1. - -- [ ] **Step 2: Commit** - -```bash -git add docs/guide/gateway-api.md -git commit -m "docs(guide): add Gateway-API overview" -``` - ---- - -### Task F5: User guide — `gateway-alb.md` - -**Files:** -- Create: `docs/guide/gateway-alb.md` - -- [ ] **Step 1: Write the L7 walkthrough** - -Sections: -1. HTTP Gateway + HTTPRoute (path & hostname matching). -2. HTTPS Gateway with K8s TLS Secret import. -3. HTTPS with vngcloud cert ID via `LoadBalancerConfig.spec.listeners[].certificateDefault.id`. -4. Multi-cert SNI. -5. mTLS (`frontendValidation` and LBC `clientCertificateId`). -6. Cross-namespace backendRefs + ReferenceGrant example. - -- [ ] **Step 2: Commit** - -```bash -git add docs/guide/gateway-alb.md -git commit -m "docs(guide): add L7 ALB Gateway walkthrough" -``` - ---- - -### Task F6: User guide — `gateway-extensions.md` - -**Files:** -- Create: `docs/guide/gateway-extensions.md` - -- [ ] **Step 1: Write** - -Sections: -1. `LoadBalancerConfig` at GatewayClass vs Gateway level; `mergingMode`. -2. `TargetGroupConfig` — per-Service config + per-route override; cascade semantics. -3. `ListenerRuleConfig` — additional matches via `extensionRef`; supported types and forward-compat note. - -- [ ] **Step 2: Commit** - -```bash -git add docs/guide/gateway-extensions.md -git commit -m "docs(guide): add Gateway extensions (TGC + LRC + LBC)" -``` - ---- - -### Task F7: Example — `examples/gateway-canary.md` - -**Files:** -- Create: `docs/examples/gateway-canary.md` - -- [ ] **Step 1: Write** - -Walkthrough using `gateway_v1_alb_canary.yaml`: explain weighted backends, synthetic-pool naming, and how endpoint changes preserve the pool identity. - -- [ ] **Step 2: Commit** - -```bash -git add docs/examples/gateway-canary.md -git commit -m "docs(examples): add weighted-canary HTTPRoute example" -``` - ---- - -## Block G — End-to-end tests - -### Task G1: e2e harness directory - -**Files:** -- Create: `test/e2e/gateway/harness.go` - -- [ ] **Step 1: Read existing e2e harness pattern** - -Run: `ls test/e2e/` - -If a harness exists for Service/Ingress, study its setup. Otherwise, scaffold a fresh harness that: -- Reads `KUBECONFIG` and `VNGCLOUD_CREDENTIALS`. -- Skips the test if either is missing (so CI default-skips). - -- [ ] **Step 2: Write a minimal harness file** - -```go -package gateway_e2e - -import ( - "os" - "testing" -) - -func skipIfNotE2E(t *testing.T) { - if os.Getenv("E2E") != "1" { - t.Skip("E2E env var not set; skipping live test") - } -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add test/e2e/gateway/harness.go -git commit -m "test(e2e/gateway): add harness skeleton" -``` - ---- - -### Task G2: e2e — basic HTTP - -**Files:** -- Create: `test/e2e/gateway/alb_basic_test.go` - -- [ ] **Step 1: Write the test** - -Apply `gateway_v1_alb_basic.yaml` against a real cluster, wait for `Programmed=True`, resolve the address, curl `/`, expect 200 from a backend echo Service. - -- [ ] **Step 2: Run with `E2E=1`** - -Run: `E2E=1 go test ./test/e2e/gateway/... -run TestALBBasic -v` - -Expected: PASS against a real vngcloud cluster. - -- [ ] **Step 3: Commit** - -```bash -git add test/e2e/gateway/alb_basic_test.go -git commit -m "test(e2e/gateway): add basic HTTP Gateway flow" -``` - ---- - -### Task G3: e2e — HTTPS + SNI - -**Files:** -- Create: `test/e2e/gateway/alb_tls_test.go` - -- [ ] **Step 1: Write the test** - -Provision two TLS Secrets, attach via `certificateRefs`, curl two hostnames, assert SNI selection works. - -- [ ] **Step 2: Run & commit** - -```bash -git add test/e2e/gateway/alb_tls_test.go -git commit -m "test(e2e/gateway): add HTTPS + SNI flow" -``` - ---- - -### Task G4: e2e — mTLS - -**Files:** -- Create: `test/e2e/gateway/alb_mtls_test.go` - -- [ ] **Step 1: Write the test** - -`Gateway.spec.listeners[0].tls.frontendValidation.caCertificateRefs[0]` → CA cert; client without cert gets 400, with cert gets 200. - -- [ ] **Step 2: Commit** - -```bash -git add test/e2e/gateway/alb_mtls_test.go -git commit -m "test(e2e/gateway): add mTLS flow" -``` - ---- - -### Task G5: e2e — weighted canary - -**Files:** -- Create: `test/e2e/gateway/alb_canary_test.go` - -- [ ] **Step 1: Write the test** - -Apply `gateway_v1_alb_canary.yaml` (90/10 split). Issue 1000 requests, count which backend served each, assert ratio is 90 ± 5 / 10 ± 5. - -- [ ] **Step 2: Commit** - -```bash -git add test/e2e/gateway/alb_canary_test.go -git commit -m "test(e2e/gateway): add weighted-canary flow" -``` - ---- - -### Task G6: e2e — cross-namespace via ReferenceGrant - -**Files:** -- Create: `test/e2e/gateway/alb_xns_test.go` - -- [ ] **Step 1: Write the test** - -HTTPRoute in `ns-a`; backend Service in `ns-b`. Without ReferenceGrant: route status `ResolvedRefs=False, reason=RefNotPermitted`. Apply ReferenceGrant; route flips to `ResolvedRefs=True` and traffic flows. - -- [ ] **Step 2: Commit** - -```bash -git add test/e2e/gateway/alb_xns_test.go -git commit -m "test(e2e/gateway): add cross-namespace ReferenceGrant flow" -``` - ---- - -## Block H — Final verification - -### Task H1: Full test sweep - -- [ ] **Step 1: Generate everything** - -Run: `make generate manifests` - -Expected: clean (no diff after). - -- [ ] **Step 2: Lint** - -Run: `make fmt vet` - -Expected: clean. - -- [ ] **Step 3: Unit + envtest** - -Run: `make test` - -Expected: PASS. - -- [ ] **Step 4: Helm template renders** - -Run: `helm template charts/vngcloud-load-balancer-controller --set gatewayApi.alb.enabled=true >/dev/null` - -Expected: success, no errors. - -- [ ] **Step 5: Smoke-deploy on a real cluster (manual)** - -Apply chart with `gatewayApi.alb.enabled=true`, then `kubectl apply -f config/samples/gateway_v1_alb_basic.yaml`. Wait for `Gateway.status.addresses` to populate. Curl the address. - -- [ ] **Step 6: e2e suite (manual gate)** - -Run: `E2E=1 go test ./test/e2e/gateway/... -v` - -Expected: all 5 tests PASS. - -- [ ] **Step 7: Final commit (if any cleanup)** - -```bash -git status -# fix any straggling formatting -git add -A -git commit -m "chore: final cleanup before Phase 1 release" -``` - -### Task H2: Phase-1 release notes - -**Files:** -- Modify: `CHANGELOG.md` (or whichever release-notes file the project uses) - -- [ ] **Step 1: Write release notes** - -Title: "0.4.0 — Gateway API Phase 1 (L7 MVP)". List: -- New `vngcloud-alb` GatewayClass with HTTPRoute support -- New CRDs: `TargetGroupConfig`, `ListenerRuleConfig` -- `LoadBalancerConfig` extended with `MergingMode`, `SSLPolicy`, `ALPNPolicy` -- Feature gate: `--enable-gateway-api-alb` (default `false`) -- Five e2e flows green - -- [ ] **Step 2: Commit** - -```bash -git add CHANGELOG.md -git commit -m "release: 0.4.0 — Gateway API Phase 1 (L7 MVP)" -``` - ---- - -## Self-review - -**Spec coverage:** every Phase-1 scope item from spec §6.1 has a task — -- vngcloud-alb GatewayClass (D1, F2) -- Gateway reconciler (D2, C9) -- HTTPRoute reconciler (D3) -- LBC `MergingMode` / SSL / ALPN (A3) -- TargetGroupConfig (A5, D4) -- ListenerRuleConfig (A6, D5) -- ReferenceGrant honoring (B4 + integrated into C9) -- TLS Secret + cert ID (C4, C5) -- mTLS (C4) -- Status conditions (B8 + integrated into C9 + reconcilers) -- Events + metrics (B10 + reconciler wiring) -- Helm chart (F1, F2) -- 5 e2e tests (G2-G6) -- 4 user docs (F4, F5, F6, F7) - -**Placeholder scan:** no "TBD"/"TODO"/"implement later" steps in user-visible task bodies. The two intentional `// TODO(C9)` comments inside Task C2 are explicitly resolved in Task C9. - -**Type consistency:** `BackendKey` defined in B2 used unchanged in C6/C7. `MergingMode` defined in A3 used in B3. `RefRequest` in B4 stays self-contained. `BackendEndpoints`/`SynthesizeMembers` from C6 referenced from C9 implementation. The `vksv1alpha1.PoolMember` field-name caveat in C6 step 3 is flagged for the engineer. - ---- - -**Plan complete and saved to `docs/superpowers/plans/2026-04-30-gateway-api-phase1.md`. Two execution options:** - -**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. - -**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. - -**Which approach?** diff --git a/docs/superpowers/plans/2026-05-08-gateway-api-gep713-phase1.md b/docs/superpowers/plans/2026-05-08-gateway-api-gep713-phase1.md new file mode 100644 index 00000000..ec4bb2b5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-gateway-api-gep713-phase1.md @@ -0,0 +1,4771 @@ +# Gateway API GEP-713 — Phase 1 (L7 ALB MVP) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a Gateway API L7 controller that provisions a vngcloud ALB per `Gateway`, programs listeners and policies from `HTTPRoute`s, with four Direct-Policy CRDs (`VKSGatewayPolicy`, `VKSBackendPolicy`, `VKSHealthCheckPolicy`, `VKSRoutePolicy`) attaching via `spec.targetRefs` per GEP-713. + +**Architecture:** New `vngcloud-alb` GatewayClass; reconcilers in `internal/controller/gateway/alb/`; business logic in `internal/usecase/gateway_uc/alb_gateway_uc/`; reuses existing `K8sRepository`, `VngCloudRepository`, `FinalizerManager`, cert-import path, and `lbc_uc` listener/pool/policy deploy helpers. Gateway is the only writer to vngcloud LB; HTTPRoute and policy reconciles enqueue the parent Gateway. + +**Tech Stack:** Go 1.25, controller-runtime 0.19, kubebuilder v4, sigs.k8s.io/gateway-api v1.2, ginkgo+gomega+envtest, gomock, vngcloud-go-sdk/v2. + +**Spec reference:** `docs/superpowers/specs/2026-05-08-gateway-api-gep713-design.md` + +**Out of scope for Phase 1:** +- NLB GatewayClass / TCP / UDP / TLS-Passthrough (Phase 2) +- GRPCRoute / BackendTLSPolicy / URLRewrite / Mirror / ResponseHeaderModifier (Phase 3) +- Migration tool, conformance test job, CRD promotion to v1beta1 (Phase 4) +- Inherited Policy Attachment (defaults/overrides hierarchy) + +--- + +## File map (Phase 1) + +| File | Role | +|---|---| +| `go.mod`, `go.sum` | Add `sigs.k8s.io/gateway-api v1.2.0` | +| `PROJECT` | Register new resources | +| `api/gateway/v1alpha1/groupversion_info.go` | API group registration | +| `api/gateway/v1alpha1/doc.go` | Group package doc + kubebuilder markers | +| `api/gateway/v1alpha1/shared_types.go` | Re-exports of LocalPolicyTargetReference, status helpers | +| `api/gateway/v1alpha1/vksgatewaypolicy_types.go` | VKSGatewayPolicy + Spec/Status | +| `api/gateway/v1alpha1/vksbackendpolicy_types.go` | VKSBackendPolicy + Spec/Status | +| `api/gateway/v1alpha1/vkshealthcheckpolicy_types.go` | VKSHealthCheckPolicy + Spec/Status | +| `api/gateway/v1alpha1/vksroutepolicy_types.go` | VKSRoutePolicy + Spec/Status | +| `api/gateway/v1alpha1/zz_generated.deepcopy.go` | Generated | +| `config/crd/bases/gateway.vks.vngcloud.vn_*.yaml` | Generated CRDs (4 files) | +| `pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_*.yaml` | Embedded CRDs (mirrored from `config/crd/bases/`) | +| `internal/domain/domain.go` | Finalizer + owner-kind constants | +| `pkg/consts/consts.go` | GatewayClass controller-name constants | +| `pkg/gateway/gatewayapi_utils.go` | Hostname matchers, wildcard→regex, helpers | +| `pkg/gateway/synth_pool.go` | Deterministic pool naming + weight scaling | +| `pkg/gateway/policy_target.go` | Target-ref matching helpers | +| `internal/controller/gateway/shared/classifier.go` | Listener-protocol/GatewayClass validation | +| `internal/controller/gateway/shared/status.go` | Condition + PolicyAncestorStatus helpers | +| `internal/controller/gateway/shared/policy_order.go` | Match-specificity ordering | +| `internal/controller/gateway/shared/reference_indexer.go` | Reverse indexes for all 4 policies + routes | +| `internal/controller/gateway/shared/finalizer.go` | Finalizer add/remove helpers | +| `internal/controller/gateway/shared/eventhandlers/*.go` | Cross-resource enqueue helpers | +| `internal/usecase/gateway_uc/shared/refgrant.go` | ReferenceGrant evaluation | +| `internal/usecase/gateway_uc/shared/policy_resolver.go` | Direct policy resolution (oldest-wins) | +| `internal/usecase/gateway_uc/alb_gateway_uc/gateway_uc.go` | UseCase entry (Init/Ensure/Delete) | +| `internal/usecase/gateway_uc/alb_gateway_uc/build_lb.go` | Gateway → LB params (uses VKSGatewayPolicy) | +| `internal/usecase/gateway_uc/alb_gateway_uc/build_listener.go` | Listener policy resolution + listener build | +| `internal/usecase/gateway_uc/alb_gateway_uc/build_cert.go` | Secret import / cert-ID | +| `internal/usecase/gateway_uc/alb_gateway_uc/build_pool.go` | Synthetic pool with weight + health | +| `internal/usecase/gateway_uc/alb_gateway_uc/build_policy.go` | HTTPRoute → vngcloud Policy with VKSRoutePolicy overlay | +| `internal/usecase/gateway_uc/alb_gateway_uc/build_sec_group.go` | NSG inheritance | +| `internal/usecase/gateway_uc/alb_gateway_uc/status.go` | Status writers | +| `internal/controller/gateway/alb/gatewayclass_controller.go` | GatewayClass reconciler | +| `internal/controller/gateway/alb/gateway_controller.go` | Gateway reconciler | +| `internal/controller/gateway/alb/httproute_controller.go` | HTTPRoute reconciler | +| `internal/controller/gateway/policies/vksgatewaypolicy_controller.go` | Policy validator | +| `internal/controller/gateway/policies/vksbackendpolicy_controller.go` | Policy validator | +| `internal/controller/gateway/policies/vkshealthcheckpolicy_controller.go` | Policy validator | +| `internal/controller/gateway/policies/vksroutepolicy_controller.go` | Policy validator | +| `internal/controller/gateway/alb/suite_test.go` | envtest harness | +| `internal/controller/gateway/alb/*_test.go` | Ginkgo specs | +| `cmd/main.go` | Scheme + flags + reconciler wiring | +| `charts/vngcloud-load-balancer-controller/templates/crds/*.yaml` | CRD bundle | +| `charts/vngcloud-load-balancer-controller/templates/gatewayclass-alb.yaml` | Gated GatewayClass manifest | +| `charts/vngcloud-load-balancer-controller/templates/rbac/*.yaml` | RBAC additions | +| `charts/vngcloud-load-balancer-controller/values.yaml` | `gatewayApi.alb.enabled` toggle | +| `test/e2e/gateway/*.yaml` | E2E manifests + scripts | +| `docs/guide/gateway-api.md`, `gateway-alb.md`, `gateway-policies.md` | User docs | +| `docs/examples/gateway-canary.md` | Example | + +--- + +## Section A — Bootstrap + +### Task A1: Add the Gateway API dependency + +**Files:** +- Modify: `go.mod`, `go.sum` + +- [ ] **Step 1: Add the dependency** + +Run: + +```bash +go get sigs.k8s.io/gateway-api@v1.2.0 +go mod tidy +``` + +Expected: `go.mod` contains `sigs.k8s.io/gateway-api v1.2.0`. `go.sum` updated. + +- [ ] **Step 2: Verify build still passes** + +Run: + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add go.mod go.sum +git commit -m "build(gateway): add sigs.k8s.io/gateway-api v1.2.0 dependency" +``` + +--- + +### Task A2: Create the API group skeleton + +**Files:** +- Create: `api/gateway/v1alpha1/groupversion_info.go` +- Create: `api/gateway/v1alpha1/doc.go` + +- [ ] **Step 1: Write `doc.go`** + +```go +// Package v1alpha1 contains API Schema definitions for the gateway.vks.vngcloud.vn v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=gateway.vks.vngcloud.vn +package v1alpha1 +``` + +- [ ] **Step 2: Write `groupversion_info.go`** + +```go +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + GroupVersion = schema.GroupVersion{Group: "gateway.vks.vngcloud.vn", Version: "v1alpha1"} + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + AddToScheme = SchemeBuilder.AddToScheme +) +``` + +- [ ] **Step 3: Verify package compiles** + +```bash +go build ./api/gateway/v1alpha1/... +``` + +Expected: success (no types yet, but the package declaration is valid). + +- [ ] **Step 4: Commit** + +```bash +git add api/gateway/v1alpha1/ +git commit -m "feat(gateway/api): scaffold gateway.vks.vngcloud.vn/v1alpha1 group" +``` + +--- + +### Task A3: Add domain + consts entries + +**Files:** +- Modify: `internal/domain/domain.go` +- Modify: `pkg/consts/consts.go` + +- [ ] **Step 1: Add finalizer and owner-kind constants to `internal/domain/domain.go`** + +Add to the existing constants block: + +```go +// Gateway API constants. +const ( + GatewayFinalizer = "gateway.vks.vngcloud.vn/resources" + HTTPRouteFinalizer = "gateway.vks.vngcloud.vn/route" + + OwnerKindGateway = "Gateway" + + OwnerLabelGatewayUID = "gateway.vks.vngcloud.vn/owner-uid" + OwnerLabelKind = "vks.vngcloud.vn/owner-resource-kind" +) +``` + +- [ ] **Step 2: Add controller-name constants to `pkg/consts/consts.go`** + +```go +const ( + GatewayClassControllerNameALB = "gateway.vks.vngcloud.vn/alb" + GatewayClassControllerNameNLB = "gateway.vks.vngcloud.vn/nlb" + GatewayClassNameALB = "vngcloud-alb" + GatewayClassNameNLB = "vngcloud-nlb" +) +``` + +- [ ] **Step 3: Verify build** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add internal/domain/domain.go pkg/consts/consts.go +git commit -m "feat(gateway): add domain finalizers and GatewayClass controller names" +``` + +--- + +## Section B — Policy CRDs (API types) + +> All four CRDs sit in `api/gateway/v1alpha1/`. Spec is authoritative for field doc comments — see `2026-05-08-gateway-api-gep713-design.md` §2. Field structures are reproduced inline below; copy them verbatim. + +### Task B1: Shared types and re-exports + +**Files:** +- Create: `api/gateway/v1alpha1/shared_types.go` + +- [ ] **Step 1: Write the shared types file** + +```go +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +// LocalPolicyTargetReference is re-exported for convenience. +type LocalPolicyTargetReference = gwv1alpha2.LocalPolicyTargetReference + +// LocalPolicyTargetReferenceWithSectionName is re-exported for convenience. +type LocalPolicyTargetReferenceWithSectionName = gwv1alpha2.LocalPolicyTargetReferenceWithSectionName + +// PolicyAncestorStatus is re-exported for convenience. +type PolicyAncestorStatus = gwv1alpha2.PolicyAncestorStatus + +// CommonPolicyStatus is the embedded ancestor-status block carried by every VKS policy CRD. +type CommonPolicyStatus struct { + // Ancestors records reconcile status per controller acting on this policy. + // +listType=atomic + // +kubebuilder:validation:MaxItems=16 + Ancestors []PolicyAncestorStatus `json:"ancestors,omitempty"` +} + +// PolicyConditionType collects the standard reasons used across all four VKS policy CRDs. +const ( + PolicyConditionAccepted = "Accepted" + PolicyConditionProgrammed = "Programmed" + + PolicyReasonAccepted = "Accepted" + PolicyReasonConflicted = "Conflicted" + PolicyReasonInvalid = "Invalid" + PolicyReasonTargetNotFound = "TargetNotFound" + PolicyReasonNoReadyController = "NoReadyController" + PolicyReasonProgrammed = "Programmed" + PolicyReasonPending = "Pending" +) + +// CommonStatus is the common subset embedded into each policy's Status. +type CommonStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} +``` + +- [ ] **Step 2: Verify** + +```bash +go build ./api/gateway/v1alpha1/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add api/gateway/v1alpha1/shared_types.go +git commit -m "feat(gateway/api): add shared policy status + target-ref re-exports" +``` + +--- + +### Task B2: VKSGatewayPolicy types + +**Files:** +- Create: `api/gateway/v1alpha1/vksgatewaypolicy_types.go` + +- [ ] **Step 1: Write the type file** + +```go +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=vksgwpolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.spec.targetRefs[*].name` +// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=="Accepted")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type VKSGatewayPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VKSGatewayPolicySpec `json:"spec,omitempty"` + Status VKSGatewayPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type VKSGatewayPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VKSGatewayPolicy `json:"items"` +} + +type VKSGatewayPolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []LocalPolicyTargetReferenceWithSectionName `json:"targetRefs"` + + SSLPolicy *string `json:"sslPolicy,omitempty"` + ALPNPolicy *string `json:"alpnPolicy,omitempty"` + AllowedCIDRs []string `json:"allowedCidrs,omitempty"` + InsertHeaders map[string]string `json:"insertHeaders,omitempty"` + TimeoutClient *metav1.Duration `json:"timeoutClient,omitempty"` + TimeoutMember *metav1.Duration `json:"timeoutMember,omitempty"` + TimeoutConnection *metav1.Duration `json:"timeoutConnection,omitempty"` + + CertificateIDs []string `json:"certificateIds,omitempty"` + ClientCertificateID *string `json:"clientCertificateId,omitempty"` + + LoadBalancerSpec *VKSLoadBalancerSpec `json:"loadBalancerSpec,omitempty"` +} + +type VKSLoadBalancerSpec struct { + // +kubebuilder:validation:Enum=Internet;Internal;InterVPC + Scheme *string `json:"scheme,omitempty"` + PackageID *string `json:"packageId,omitempty"` + SubnetID *string `json:"subnetId,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + LoadBalancerID *string `json:"loadBalancerId,omitempty"` +} + +type VKSGatewayPolicyStatus struct { + CommonStatus `json:",inline"` + CommonPolicyStatus `json:",inline"` +} + +func init() { + SchemeBuilder.Register(&VKSGatewayPolicy{}, &VKSGatewayPolicyList{}) +} +``` + +- [ ] **Step 2: Verify build** + +```bash +go build ./api/gateway/v1alpha1/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add api/gateway/v1alpha1/vksgatewaypolicy_types.go +git commit -m "feat(gateway/api): add VKSGatewayPolicy types" +``` + +--- + +### Task B3: VKSBackendPolicy types + +**Files:** +- Create: `api/gateway/v1alpha1/vksbackendpolicy_types.go` + +- [ ] **Step 1: Write the type file** + +```go +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=vksbpolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.spec.targetRefs[*].name` +// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=="Accepted")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type VKSBackendPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VKSBackendPolicySpec `json:"spec,omitempty"` + Status VKSBackendPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type VKSBackendPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VKSBackendPolicy `json:"items"` +} + +type VKSBackendPolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []LocalPolicyTargetReference `json:"targetRefs"` + + // +kubebuilder:validation:Enum=instance;ip + TargetType *string `json:"targetType,omitempty"` + + // +kubebuilder:validation:Enum=ROUND_ROBIN;LEAST_CONNECTIONS;SOURCE_IP + PoolAlgorithm *string `json:"poolAlgorithm,omitempty"` + + SessionAffinity *VKSSessionAffinity `json:"sessionAffinity,omitempty"` + + EnableTLSEncryption *bool `json:"enableTLSEncryption,omitempty"` + EnableProxyProtocol *bool `json:"enableProxyProtocol,omitempty"` + TargetNodeLabels map[string]string `json:"targetNodeLabels,omitempty"` + ManageDFPMembers *bool `json:"manageDFPMembers,omitempty"` +} + +type VKSSessionAffinity struct { + // +kubebuilder:validation:Enum=None;ClientIP;Cookie + Type string `json:"type"` + CookieName *string `json:"cookieName,omitempty"` + TTL *metav1.Duration `json:"ttl,omitempty"` +} + +type VKSBackendPolicyStatus struct { + CommonStatus `json:",inline"` + CommonPolicyStatus `json:",inline"` +} + +func init() { + SchemeBuilder.Register(&VKSBackendPolicy{}, &VKSBackendPolicyList{}) +} +``` + +- [ ] **Step 2: Verify** + +```bash +go build ./api/gateway/v1alpha1/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add api/gateway/v1alpha1/vksbackendpolicy_types.go +git commit -m "feat(gateway/api): add VKSBackendPolicy types" +``` + +--- + +### Task B4: VKSHealthCheckPolicy types + +**Files:** +- Create: `api/gateway/v1alpha1/vkshealthcheckpolicy_types.go` + +- [ ] **Step 1: Write the type file** + +```go +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=vkshcpolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +// +kubebuilder:printcolumn:name="Protocol",type=string,JSONPath=`.spec.protocol` +// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.spec.targetRefs[*].name` +// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=="Accepted")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type VKSHealthCheckPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VKSHealthCheckPolicySpec `json:"spec,omitempty"` + Status VKSHealthCheckPolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type VKSHealthCheckPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VKSHealthCheckPolicy `json:"items"` +} + +type VKSHealthCheckPolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []LocalPolicyTargetReference `json:"targetRefs"` + + // +kubebuilder:validation:Enum=HTTP;HTTPS;TCP + Protocol string `json:"protocol"` + Port *int32 `json:"port,omitempty"` + Interval *metav1.Duration `json:"interval,omitempty"` + Timeout *metav1.Duration `json:"timeout,omitempty"` + HealthyThreshold *int32 `json:"healthyThreshold,omitempty"` + UnhealthyThreshold *int32 `json:"unhealthyThreshold,omitempty"` + + HTTPHealthCheck *VKSHTTPHealthCheck `json:"httpHealthCheck,omitempty"` +} + +type VKSHTTPHealthCheck struct { + Path *string `json:"path,omitempty"` + Host *string `json:"host,omitempty"` + ExpectedCodes []string `json:"expectedCodes,omitempty"` + RequestHeaders map[string]string `json:"requestHeaders,omitempty"` +} + +type VKSHealthCheckPolicyStatus struct { + CommonStatus `json:",inline"` + CommonPolicyStatus `json:",inline"` +} + +func init() { + SchemeBuilder.Register(&VKSHealthCheckPolicy{}, &VKSHealthCheckPolicyList{}) +} +``` + +- [ ] **Step 2: Verify** + +```bash +go build ./api/gateway/v1alpha1/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add api/gateway/v1alpha1/vkshealthcheckpolicy_types.go +git commit -m "feat(gateway/api): add VKSHealthCheckPolicy types" +``` + +--- + +### Task B5: VKSRoutePolicy types + +**Files:** +- Create: `api/gateway/v1alpha1/vksroutepolicy_types.go` + +- [ ] **Step 1: Write the type file** + +```go +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=vksroutepolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.spec.targetRefs[*].name` +// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=`.status.conditions[?(@.type=="Accepted")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +type VKSRoutePolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VKSRoutePolicySpec `json:"spec,omitempty"` + Status VKSRoutePolicyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type VKSRoutePolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VKSRoutePolicy `json:"items"` +} + +type VKSRoutePolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []LocalPolicyTargetReferenceWithSectionName `json:"targetRefs"` + + AdditionalMatches []VKSAdditionalMatch `json:"additionalMatches,omitempty"` + Actions []VKSRuleAction `json:"actions,omitempty"` + Position *int32 `json:"position,omitempty"` +} + +type VKSAdditionalMatch struct { + // +kubebuilder:validation:Enum=Header;QueryParam;Method;SourceIP + Type string `json:"type"` + Name *string `json:"name,omitempty"` + // +kubebuilder:validation:Enum=EQUAL_TO;STARTS_WITH;ENDS_WITH;CONTAINS;REGEX + Compare string `json:"compare"` + Value string `json:"value"` +} + +type VKSRuleAction struct { + // +kubebuilder:validation:Enum=FixedResponse;Reject;Redirect + Type string `json:"type"` + FixedResponse *VKSFixedResponseAction `json:"fixedResponse,omitempty"` + Redirect *VKSRedirectAction `json:"redirect,omitempty"` +} + +type VKSFixedResponseAction struct { + // +kubebuilder:validation:Minimum=100 + // +kubebuilder:validation:Maximum=599 + StatusCode int32 `json:"statusCode"` + ContentType *string `json:"contentType,omitempty"` + Body *string `json:"body,omitempty"` +} + +type VKSRedirectAction struct { + URL string `json:"url"` + HTTPCode *int32 `json:"httpCode,omitempty"` + KeepQueryString *bool `json:"keepQueryString,omitempty"` +} + +type VKSRoutePolicyStatus struct { + CommonStatus `json:",inline"` + CommonPolicyStatus `json:",inline"` +} + +func init() { + SchemeBuilder.Register(&VKSRoutePolicy{}, &VKSRoutePolicyList{}) +} +``` + +- [ ] **Step 2: Verify** + +```bash +go build ./api/gateway/v1alpha1/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add api/gateway/v1alpha1/vksroutepolicy_types.go +git commit -m "feat(gateway/api): add VKSRoutePolicy types" +``` + +--- + +### Task B6: Generate deepcopy and CRD manifests + +**Files:** +- Create: `api/gateway/v1alpha1/zz_generated.deepcopy.go` +- Create: `config/crd/bases/gateway.vks.vngcloud.vn_vksgatewaypolicies.yaml` +- Create: `config/crd/bases/gateway.vks.vngcloud.vn_vksbackendpolicies.yaml` +- Create: `config/crd/bases/gateway.vks.vngcloud.vn_vkshealthcheckpolicies.yaml` +- Create: `config/crd/bases/gateway.vks.vngcloud.vn_vksroutepolicies.yaml` +- Mirrored: `pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_*.yaml` (`make manifests` runs `sync-embedded-crds`) + +- [ ] **Step 1: Run codegen** + +Run: + +```bash +make generate manifests +``` + +Expected: `zz_generated.deepcopy.go` populated; four CRD YAMLs in `config/crd/bases/`; same four mirrored to `pkg/k8s/apis/vks.vngcloud.vn/crds/`. + +- [ ] **Step 2: Check `git status` for stray files** + +Per `CLAUDE.md` (`make manifests` scans `.worktrees/`), revert anything unrelated to the four new CRDs. + +```bash +git status +``` + +Expected: only changes are the 4 new CRD YAMLs (in both `config/crd/bases/` and `pkg/k8s/apis/.../crds/`), the regenerated deepcopy, and `config/rbac/role.yaml` if RBAC markers were added later. No stray edits to existing CRDs. + +- [ ] **Step 3: Verify build + envtest setup still passes** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add api/gateway/v1alpha1/zz_generated.deepcopy.go \ + config/crd/bases/gateway.vks.vngcloud.vn_*.yaml \ + pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_*.yaml +git commit -m "feat(gateway/api): generate deepcopy and CRD manifests for VKS policies" +``` + +--- + +## Section C — Pure helpers (`pkg/gateway/`) + +### Task C1: Hostname matching utilities + +**Files:** +- Create: `pkg/gateway/gatewayapi_utils.go` +- Create: `pkg/gateway/gatewayapi_utils_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package gateway + +import "testing" + +func TestHostnameToRegex(t *testing.T) { + cases := []struct{ + in string + wantRegex string + wantLiteral bool + }{ + {"foo.example.com", "^foo\\.example\\.com$", true}, + {"*.example.com", "^[^.]+\\.example\\.com$", false}, + {"", "", true}, + } + for _, tc := range cases { + gotRegex, isLiteral := HostnameToRegex(tc.in) + if gotRegex != tc.wantRegex || isLiteral != tc.wantLiteral { + t.Errorf("HostnameToRegex(%q) = (%q, %v); want (%q, %v)", + tc.in, gotRegex, isLiteral, tc.wantRegex, tc.wantLiteral) + } + } +} + +func TestHostnameMatches(t *testing.T) { + cases := []struct{ + listener, route string + want bool + }{ + {"", "foo.example.com", true}, + {"foo.example.com", "foo.example.com", true}, + {"foo.example.com", "bar.example.com", false}, + {"*.example.com", "foo.example.com", true}, + {"*.example.com", "foo.bar.example.com", false}, + {"*.example.com", "example.com", false}, + } + for _, tc := range cases { + if got := HostnameMatches(tc.listener, tc.route); got != tc.want { + t.Errorf("HostnameMatches(%q,%q) = %v; want %v", + tc.listener, tc.route, got, tc.want) + } + } +} +``` + +- [ ] **Step 2: Run, verify FAIL** + +```bash +go test ./pkg/gateway/ -run "TestHostname" +``` + +Expected: FAIL (functions undefined). + +- [ ] **Step 3: Implement** + +```go +package gateway + +import ( + "regexp" + "strings" +) + +// HostnameToRegex converts a Gateway-API hostname (literal or `*.suffix`) to an +// anchored regex string. Returns isLiteral=true when no wildcard is present. +// Empty input ("") is treated as literal empty (matches anything at the listener layer). +func HostnameToRegex(h string) (string, bool) { + if h == "" { + return "", true + } + if strings.HasPrefix(h, "*.") { + suffix := regexp.QuoteMeta(h[2:]) + return "^[^.]+\\." + suffix + "$", false + } + return "^" + regexp.QuoteMeta(h) + "$", true +} + +// HostnameMatches returns true if a route hostname matches a listener hostname per +// Gateway-API rules. Empty listener hostname matches anything. +func HostnameMatches(listener, route string) bool { + if listener == "" { + return true + } + re, _ := HostnameToRegex(listener) + matched, _ := regexp.MatchString(re, route) + return matched +} +``` + +- [ ] **Step 4: Run, verify PASS** + +```bash +go test ./pkg/gateway/ -run "TestHostname" -v +``` + +Expected: PASS for all subcases. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/gateway/gatewayapi_utils.go pkg/gateway/gatewayapi_utils_test.go +git commit -m "feat(gateway/pkg): add hostname matching helpers" +``` + +--- + +### Task C2: Synthetic pool naming and weight scaling + +**Files:** +- Create: `pkg/gateway/synth_pool.go` +- Create: `pkg/gateway/synth_pool_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package gateway + +import "testing" + +func TestSynthPoolName_Deterministic(t *testing.T) { + a := SynthPoolName("12345678abcdef", 0, []BackendKey{{Namespace:"ns",Name:"a",Port:80,Weight:1}}) + b := SynthPoolName("12345678abcdef", 0, []BackendKey{{Namespace:"ns",Name:"a",Port:80,Weight:1}}) + if a != b { + t.Fatalf("not deterministic: %s vs %s", a, b) + } + if len(a) > 50 { + t.Errorf("name too long: %d", len(a)) + } +} + +func TestSynthPoolName_OrderInsensitive(t *testing.T) { + a := SynthPoolName("u", 0, []BackendKey{{Namespace:"ns",Name:"a",Port:80,Weight:1},{Namespace:"ns",Name:"b",Port:80,Weight:1}}) + b := SynthPoolName("u", 0, []BackendKey{{Namespace:"ns",Name:"b",Port:80,Weight:1},{Namespace:"ns",Name:"a",Port:80,Weight:1}}) + if a != b { + t.Fatalf("expected order-insensitive: %s vs %s", a, b) + } +} + +func TestScaleWeights_Cap(t *testing.T) { + // 2 backends, weights 1 and 99, ready endpoints 3 and 1. + out := ScaleWeights([]BackendWeight{{Weight:1, Ready:3}, {Weight:99, Ready:1}}) + if len(out) != 2 { + t.Fatalf("want 2 entries, got %d", len(out)) + } + for _, w := range out { + if w < 1 || w > 100 { + t.Errorf("weight out of range: %d", w) + } + } +} + +func TestScaleWeights_FloorOne(t *testing.T) { + out := ScaleWeights([]BackendWeight{{Weight:1, Ready:1000}, {Weight:1000, Ready:1}}) + for _, w := range out { + if w < 1 { + t.Errorf("weight floored below 1: %d", w) + } + } +} +``` + +- [ ] **Step 2: Run, verify FAIL** + +```bash +go test ./pkg/gateway/ -run "TestSynth|TestScale" +``` + +Expected: FAIL (functions undefined). + +- [ ] **Step 3: Implement** + +```go +package gateway + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "sort" +) + +// BackendKey is the canonical identity of a backend used in pool naming. +type BackendKey struct { + Namespace string + Name string + Port int32 + Weight int32 +} + +// BackendWeight is a (weight, ready-endpoints) pair fed to ScaleWeights. +type BackendWeight struct { + Weight int32 + Ready int32 +} + +// SynthPoolName returns a deterministic pool name <= 50 chars derived from the +// route UID, rule index, and backend set. Backend ordering is normalized. +func SynthPoolName(routeUID string, ruleIdx int, backends []BackendKey) string { + sorted := append([]BackendKey(nil), backends...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Namespace != sorted[j].Namespace { return sorted[i].Namespace < sorted[j].Namespace } + if sorted[i].Name != sorted[j].Name { return sorted[i].Name < sorted[j].Name } + if sorted[i].Port != sorted[j].Port { return sorted[i].Port < sorted[j].Port } + return sorted[i].Weight < sorted[j].Weight + }) + h := sha256.New() + for _, b := range sorted { + fmt.Fprintf(h, "%s/%s:%d=%d\n", b.Namespace, b.Name, b.Port, b.Weight) + } + digest := hex.EncodeToString(h.Sum(nil)) + prefix := routeUID + if len(prefix) > 8 { + prefix = prefix[:8] + } + name := fmt.Sprintf("gw_%s_%d_%s", prefix, ruleIdx, digest[:5]) + if len(name) > 50 { + name = name[:50] + } + return name +} + +// ScaleWeights computes vngcloud member weights given (declared weight, ready endpoints) +// per backend. Member weights are floored at 1 and the largest member weight is capped at 100. +func ScaleWeights(in []BackendWeight) []int32 { + if len(in) == 0 { + return nil + } + out := make([]int32, len(in)) + var maxW big.Int + for i, b := range in { + n := b.Ready + if n <= 0 { n = 1 } + // member_weight ≈ weight / readyEndpoints, scaled later. + mw := new(big.Int).SetInt64(int64(b.Weight)) + mw.Mul(mw, big.NewInt(100)) + mw.Quo(mw, big.NewInt(int64(n))) + if mw.Sign() <= 0 { mw.SetInt64(1) } + if mw.Cmp(&maxW) > 0 { maxW.Set(mw) } + out[i] = int32(mw.Int64()) + } + if maxW.Cmp(big.NewInt(100)) > 0 { + scale := big.NewInt(100) + for i := range out { + v := new(big.Int).SetInt64(int64(out[i])) + v.Mul(v, scale) + v.Quo(v, &maxW) + if v.Sign() <= 0 { v.SetInt64(1) } + out[i] = int32(v.Int64()) + } + } + return out +} +``` + +- [ ] **Step 4: Run, verify PASS** + +```bash +go test ./pkg/gateway/ -run "TestSynth|TestScale" -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/gateway/synth_pool.go pkg/gateway/synth_pool_test.go +git commit -m "feat(gateway/pkg): add synthetic-pool naming and weight scaling" +``` + +--- + +### Task C3: Policy target-ref matching helper + +**Files:** +- Create: `pkg/gateway/policy_target.go` +- Create: `pkg/gateway/policy_target_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package gateway + +import ( + "testing" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +type fakeObj struct { + name, ns, gvk string +} + +func TestTargetRefMatches(t *testing.T) { + grp := gwv1alpha2.Group("gateway.networking.k8s.io") + tref := gwv1alpha2.LocalPolicyTargetReference{ + Group: grp, + Kind: gwv1alpha2.Kind("Gateway"), + Name: gwv1alpha2.ObjectName("prod-gw"), + } + ok := TargetRefMatches(tref, "ns", PolicyTarget{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "prod-gw", + Namespace: "ns", + }) + if !ok { t.Fatal("expected match") } +} + +func TestTargetRefMatches_DifferentName(t *testing.T) { + tref := gwv1alpha2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "prod-gw", + } + if TargetRefMatches(tref, "ns", PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", + Name: "staging-gw", Namespace: "ns", + }) { + t.Fatal("unexpected match") + } + _ = metav1.Now() // keep import in case future tests need it +} +``` + +- [ ] **Step 2: Run, verify FAIL** + +```bash +go test ./pkg/gateway/ -run "TestTargetRef" +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```go +package gateway + +import ( + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +// PolicyTarget identifies a candidate object a Direct policy may attach to. +type PolicyTarget struct { + Group string + Kind string + Namespace string + Name string + SectionName string +} + +// TargetRefMatches reports whether a LocalPolicyTargetReference matches the given target. +// The policy and the target must be in the same namespace (Direct attachment). +func TargetRefMatches(ref gwv1alpha2.LocalPolicyTargetReference, policyNamespace string, t PolicyTarget) bool { + if string(ref.Group) != t.Group { return false } + if string(ref.Kind) != t.Kind { return false } + if string(ref.Name) != t.Name { return false } + return policyNamespace == t.Namespace +} + +// TargetRefMatchesWithSection compares a LocalPolicyTargetReferenceWithSectionName against a target. +// SectionName matches when both are empty, both equal, or the target's section is empty (= "applies to whole object"). +func TargetRefMatchesWithSection(ref gwv1alpha2.LocalPolicyTargetReferenceWithSectionName, policyNamespace string, t PolicyTarget) bool { + base := gwv1alpha2.LocalPolicyTargetReference{Group: ref.Group, Kind: ref.Kind, Name: ref.Name} + if !TargetRefMatches(base, policyNamespace, t) { + return false + } + refSection := "" + if ref.SectionName != nil { refSection = string(*ref.SectionName) } + return refSection == t.SectionName +} +``` + +- [ ] **Step 4: Run, verify PASS** + +```bash +go test ./pkg/gateway/ -run "TestTargetRef" -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/gateway/policy_target.go pkg/gateway/policy_target_test.go +git commit -m "feat(gateway/pkg): add policy target-ref matching helpers" +``` + +--- + +## Section D — Shared controller helpers (`internal/controller/gateway/shared/`) + +### Task D1: Listener-protocol classifier + +**Files:** +- Create: `internal/controller/gateway/shared/classifier.go` +- Create: `internal/controller/gateway/shared/classifier_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package shared + +import ( + "testing" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestProtocolAllowedForALB(t *testing.T) { + cases := map[gwv1.ProtocolType]bool{ + gwv1.HTTPProtocolType: true, + gwv1.HTTPSProtocolType: true, + gwv1.TLSProtocolType: true, + gwv1.TCPProtocolType: false, + gwv1.UDPProtocolType: false, + } + for p, want := range cases { + if got := ProtocolAllowedForALB(p); got != want { + t.Errorf("ProtocolAllowedForALB(%q) = %v; want %v", p, got, want) + } + } +} + +func TestMixedProtocols(t *testing.T) { + listeners := []gwv1.Listener{ + {Protocol: gwv1.HTTPProtocolType}, + {Protocol: gwv1.TCPProtocolType}, + } + if !HasMixedProtocols(listeners, "alb") { + t.Fatal("expected mixed=true") + } + pure := []gwv1.Listener{{Protocol: gwv1.HTTPProtocolType}, {Protocol: gwv1.HTTPSProtocolType}} + if HasMixedProtocols(pure, "alb") { + t.Fatal("expected mixed=false") + } +} +``` + +- [ ] **Step 2: Run, verify FAIL** + +```bash +go test ./internal/controller/gateway/shared/ +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```go +package shared + +import ( + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// ProtocolAllowedForALB returns true for L7 protocols accepted by the ALB GatewayClass. +func ProtocolAllowedForALB(p gwv1.ProtocolType) bool { + switch p { + case gwv1.HTTPProtocolType, gwv1.HTTPSProtocolType, gwv1.TLSProtocolType: + return true + } + return false +} + +// ProtocolAllowedForNLB returns true for L4 protocols accepted by the NLB GatewayClass. +func ProtocolAllowedForNLB(p gwv1.ProtocolType) bool { + switch p { + case gwv1.TCPProtocolType, gwv1.UDPProtocolType, gwv1.TLSProtocolType: + return true + } + return false +} + +// HasMixedProtocols reports whether listeners mix L7 and L4 protocols (rejected per spec §1.1). +func HasMixedProtocols(listeners []gwv1.Listener, gwClass string) bool { + sawL7, sawL4 := false, false + for _, l := range listeners { + switch { + case ProtocolAllowedForALB(l.Protocol) && l.Protocol != gwv1.TLSProtocolType: + sawL7 = true + case ProtocolAllowedForNLB(l.Protocol) && l.Protocol != gwv1.TLSProtocolType: + sawL4 = true + } + } + return sawL7 && sawL4 +} +``` + +- [ ] **Step 4: Run, verify PASS** + +```bash +go test ./internal/controller/gateway/shared/ -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/controller/gateway/shared/classifier.go internal/controller/gateway/shared/classifier_test.go +git commit -m "feat(gateway/shared): add protocol classifier" +``` + +--- + +### Task D2: Status condition + ancestor helpers + +**Files:** +- Create: `internal/controller/gateway/shared/status.go` + +- [ ] **Step 1: Write the helper** + +```go +package shared + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +// SetCondition mutates conds in place, replacing any existing entry of the same type. +func SetCondition(conds *[]metav1.Condition, c metav1.Condition) { + for i, existing := range *conds { + if existing.Type == c.Type { + if existing.Status == c.Status && + existing.Reason == c.Reason && + existing.Message == c.Message { + return + } + (*conds)[i] = c + return + } + } + *conds = append(*conds, c) +} + +// EnsureAncestor inserts or updates an ancestor entry for the given controllerName. +func EnsureAncestor(ancestors *[]gwv1alpha2.PolicyAncestorStatus, ref gwv1alpha2.ParentReference, controllerName string, conditions []metav1.Condition) { + for i, a := range *ancestors { + if string(a.ControllerName) == controllerName && parentRefEqual(a.AncestorRef, ref) { + (*ancestors)[i].Conditions = conditions + return + } + } + *ancestors = append(*ancestors, gwv1alpha2.PolicyAncestorStatus{ + AncestorRef: ref, + ControllerName: gwv1alpha2.GatewayController(controllerName), + Conditions: conditions, + }) +} + +func parentRefEqual(a, b gwv1alpha2.ParentReference) bool { + return derefStr(a.Group) == derefStr(b.Group) && + derefStr(a.Kind) == derefStr(b.Kind) && + a.Name == b.Name && + derefNS(a.Namespace) == derefNS(b.Namespace) && + derefSection(a.SectionName) == derefSection(b.SectionName) +} + +func derefStr(p *gwv1alpha2.Group) string { + if p == nil { return "" }; return string(*p) +} +func derefNS(p *gwv1alpha2.Namespace) string { + if p == nil { return "" }; return string(*p) +} +func derefSection(p *gwv1alpha2.SectionName) string { + if p == nil { return "" }; return string(*p) +} +``` + +> Note: depending on go-gateway-api version, the helper signatures for `Group`/`Kind` may vary; if the compiler complains, switch to `gwv1.Group` / `gwv1.Kind` re-exports. The intent of `parentRefEqual` is value-equality across all addressable fields. + +- [ ] **Step 2: Verify build** + +```bash +go build ./internal/controller/gateway/shared/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/gateway/shared/status.go +git commit -m "feat(gateway/shared): add condition + ancestor-status helpers" +``` + +--- + +### Task D3: Match-specificity ordering + +**Files:** +- Create: `internal/controller/gateway/shared/policy_order.go` +- Create: `internal/controller/gateway/shared/policy_order_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package shared + +import ( + "sort" + "testing" + "time" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestSortMatches_ExactBeforePrefix(t *testing.T) { + pathExact := gwv1.PathMatchExact + pathPrefix := gwv1.PathMatchPathPrefix + items := []RankedMatch{ + {Match: gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: &pathPrefix, Value: ptr("/api")}}}, + {Match: gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: &pathExact, Value: ptr("/api/v1")}}}, + } + sort.Stable(byMatchSpecificity(items)) + if items[0].Match.Path.Type == nil || *items[0].Match.Path.Type != pathExact { + t.Fatalf("expected exact first; got %#v", items[0]) + } +} + +func TestSortMatches_TimestampTiebreak(t *testing.T) { + older := metav1.NewTime(time.Date(2024,1,1,0,0,0,0,time.UTC)) + newer := metav1.NewTime(time.Date(2025,1,1,0,0,0,0,time.UTC)) + items := []RankedMatch{ + {RouteCreated: newer}, + {RouteCreated: older}, + } + sort.Stable(byMatchSpecificity(items)) + if !items[0].RouteCreated.Equal(&older) { + t.Fatalf("expected older first") + } +} + +func ptr[T any](v T) *T { return &v } +``` + +- [ ] **Step 2: Run, verify FAIL** + +```bash +go test ./internal/controller/gateway/shared/ -run TestSortMatches +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```go +package shared + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// RankedMatch wraps a Gateway-API match with the metadata needed to order it. +type RankedMatch struct { + Match gwv1.HTTPRouteMatch + RouteCreated metav1.Time +} + +type byMatchSpecificity []RankedMatch + +func (s byMatchSpecificity) Len() int { return len(s) } +func (s byMatchSpecificity) Swap(i,j int) { s[i], s[j] = s[j], s[i] } +func (s byMatchSpecificity) Less(i,j int) bool { + a, b := s[i], s[j] + if r := pathRank(a.Match.Path) - pathRank(b.Match.Path); r != 0 { return r < 0 } + if pl, pr := pathLen(a.Match.Path), pathLen(b.Match.Path); pl != pr { return pl > pr } + if hi, hj := len(a.Match.Headers), len(b.Match.Headers); hi != hj { return hi > hj } + if qi, qj := len(a.Match.QueryParams), len(b.Match.QueryParams); qi != qj { return qi > qj } + return a.RouteCreated.Before(&b.RouteCreated) +} + +// pathRank: lower value = more specific. +func pathRank(p *gwv1.HTTPPathMatch) int { + if p == nil || p.Type == nil { return 99 } + switch *p.Type { + case gwv1.PathMatchExact: return 0 + case gwv1.PathMatchRegularExpression: return 1 + case gwv1.PathMatchPathPrefix: return 2 + } + return 99 +} + +func pathLen(p *gwv1.HTTPPathMatch) int { + if p == nil || p.Value == nil { return 0 } + return len(*p.Value) +} +``` + +- [ ] **Step 4: Run, verify PASS** + +```bash +go test ./internal/controller/gateway/shared/ -run TestSortMatches -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/controller/gateway/shared/policy_order.go internal/controller/gateway/shared/policy_order_test.go +git commit -m "feat(gateway/shared): add match-specificity ordering" +``` + +--- + +### Task D4: Reverse indexer + +**Files:** +- Create: `internal/controller/gateway/shared/reference_indexer.go` + +> Indexer registers field indexes used by event handlers (Task D6) to convert a watched object back to the routes/gateways that reference it. Mirrors `pkg/ingress/reference_indexer.go`. + +- [ ] **Step 1: Write the indexer** + +```go +package shared + +import ( + "context" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +const ( + IndexHTTPRouteByService = "spec.rules.backendRefs.name" + IndexHTTPRouteByParentGateway = "spec.parentRefs.gateway" + IndexVKSGatewayPolicyByGateway = "spec.targetRefs.gateway" + IndexVKSBackendPolicyByService = "spec.targetRefs.service" + IndexVKSHealthCheckPolicyByService= "spec.targetRefs.service" + IndexVKSRoutePolicyByRoute = "spec.targetRefs.httproute" +) + +func RegisterIndexes(ctx context.Context, mgr manager.Manager) error { + if err := mgr.GetFieldIndexer().IndexField(ctx, &gwv1.HTTPRoute{}, IndexHTTPRouteByService, indexHTTPRouteByService); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &gwv1.HTTPRoute{}, IndexHTTPRouteByParentGateway, indexHTTPRouteByParent); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &vksv1.VKSGatewayPolicy{}, IndexVKSGatewayPolicyByGateway, indexVKSGatewayPolicyByGateway); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &vksv1.VKSBackendPolicy{}, IndexVKSBackendPolicyByService, indexVKSBackendPolicyByService); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &vksv1.VKSHealthCheckPolicy{}, IndexVKSHealthCheckPolicyByService, indexVKSHealthCheckPolicyByService); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &vksv1.VKSRoutePolicy{}, IndexVKSRoutePolicyByRoute, indexVKSRoutePolicyByRoute); err != nil { + return err + } + return nil +} + +func indexHTTPRouteByService(obj client.Object) []string { + r := obj.(*gwv1.HTTPRoute) + var keys []string + for _, rule := range r.Spec.Rules { + for _, br := range rule.BackendRefs { + ns := r.Namespace + if br.Namespace != nil { ns = string(*br.Namespace) } + keys = append(keys, ns+"/"+string(br.Name)) + } + } + return keys +} + +func indexHTTPRouteByParent(obj client.Object) []string { + r := obj.(*gwv1.HTTPRoute) + var keys []string + for _, p := range r.Spec.ParentRefs { + ns := r.Namespace + if p.Namespace != nil { ns = string(*p.Namespace) } + if p.Kind == nil || *p.Kind == "Gateway" { + keys = append(keys, ns+"/"+string(p.Name)) + } + } + return keys +} + +func indexVKSGatewayPolicyByGateway(obj client.Object) []string { + p := obj.(*vksv1.VKSGatewayPolicy) + var keys []string + for _, t := range p.Spec.TargetRefs { + keys = append(keys, p.Namespace+"/"+string(t.Name)) + } + return keys +} + +func indexVKSBackendPolicyByService(obj client.Object) []string { + p := obj.(*vksv1.VKSBackendPolicy) + var keys []string + for _, t := range p.Spec.TargetRefs { + keys = append(keys, p.Namespace+"/"+string(t.Name)) + } + return keys +} + +func indexVKSHealthCheckPolicyByService(obj client.Object) []string { + p := obj.(*vksv1.VKSHealthCheckPolicy) + var keys []string + for _, t := range p.Spec.TargetRefs { + keys = append(keys, p.Namespace+"/"+string(t.Name)) + } + return keys +} + +func indexVKSRoutePolicyByRoute(obj client.Object) []string { + p := obj.(*vksv1.VKSRoutePolicy) + var keys []string + for _, t := range p.Spec.TargetRefs { + keys = append(keys, p.Namespace+"/"+string(t.Name)) + } + return keys +} +``` + +- [ ] **Step 2: Verify build** + +```bash +go build ./internal/controller/gateway/shared/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/gateway/shared/reference_indexer.go +git commit -m "feat(gateway/shared): add reverse indexes for routes + 4 policy CRDs" +``` + +--- + +### Task D5: Finalizer helpers + +**Files:** +- Create: `internal/controller/gateway/shared/finalizer.go` + +- [ ] **Step 1: Write the helper** + +```go +package shared + +import ( + "context" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// AddFinalizer adds f to obj if missing. Returns true if a patch is needed. +func AddFinalizer(obj client.Object, f string) bool { + for _, x := range obj.GetFinalizers() { + if x == f { return false } + } + obj.SetFinalizers(append(obj.GetFinalizers(), f)) + return true +} + +// RemoveFinalizer removes f from obj if present. Returns true if a patch is needed. +func RemoveFinalizer(obj client.Object, f string) bool { + out := make([]string, 0, len(obj.GetFinalizers())) + found := false + for _, x := range obj.GetFinalizers() { + if x == f { found = true; continue } + out = append(out, x) + } + if !found { return false } + obj.SetFinalizers(out) + return true +} + +// EnsureFinalizer adds f and patches obj if needed. +func EnsureFinalizer(ctx context.Context, c client.Client, obj client.Object, f string) error { + if !AddFinalizer(obj, f) { + return nil + } + return c.Update(ctx, obj) +} +``` + +- [ ] **Step 2: Verify build** + +```bash +go build ./internal/controller/gateway/shared/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/gateway/shared/finalizer.go +git commit -m "feat(gateway/shared): add finalizer helpers" +``` + +--- + +### Task D6: Cross-resource event handlers + +**Files:** +- Create: `internal/controller/gateway/shared/eventhandlers/route_to_gateway.go` +- Create: `internal/controller/gateway/shared/eventhandlers/policy_to_gateway.go` +- Create: `internal/controller/gateway/shared/eventhandlers/service_to_route.go` + +> All three handlers share the pattern "given a watched object, list dependents from the indexer and emit reconcile.Request". Implement each in 30–50 lines following the controller-runtime `handler.Funcs` pattern. + +- [ ] **Step 1: Write `route_to_gateway.go`** + +```go +package eventhandlers + +import ( + "context" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/workqueue" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// RouteToGateway returns a handler that, when an HTTPRoute changes, enqueues every +// Gateway named in the route's parentRefs. +func RouteToGateway() handler.EventHandler { + enq := func(ctx context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + r, ok := obj.(*gwv1.HTTPRoute) + if !ok { return } + for _, p := range r.Spec.ParentRefs { + if p.Kind != nil && *p.Kind != "Gateway" { continue } + ns := r.Namespace + if p.Namespace != nil { ns = string(*p.Namespace) } + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: string(p.Name)}}) + } + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { enq(ctx, e.Object, q) }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { enq(ctx, e.ObjectNew, q) }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { enq(ctx, e.Object, q) }, + } +} +``` + +- [ ] **Step 2: Write `policy_to_gateway.go`** + +```go +package eventhandlers + +import ( + "context" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/workqueue" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +// VKSGatewayPolicyToGateway enqueues every Gateway named in the policy's targetRefs. +func VKSGatewayPolicyToGateway() handler.EventHandler { + enq := func(ctx context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + p, ok := obj.(*vksv1.VKSGatewayPolicy) + if !ok { return } + for _, t := range p.Spec.TargetRefs { + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: p.Namespace, Name: string(t.Name)}}) + } + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { enq(ctx, e.Object, q) }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { enq(ctx, e.ObjectNew, q) }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { enq(ctx, e.Object, q) }, + } +} +``` + +- [ ] **Step 3: Write `service_to_route.go`** + +```go +package eventhandlers + +import ( + "context" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/workqueue" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" +) + +// ServiceToRouteParents enqueues the Gateway parents of every HTTPRoute that +// references the changed Service. +func ServiceToRouteParents(c client.Client) handler.EventHandler { + enq := func(ctx context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + svc, ok := obj.(*corev1.Service) + if !ok { return } + var routes gwv1.HTTPRouteList + if err := c.List(ctx, &routes, + client.InNamespace(svc.Namespace), + client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(shared.IndexHTTPRouteByService, svc.Namespace+"/"+svc.Name)}, + ); err != nil { + return + } + for _, r := range routes.Items { + for _, p := range r.Spec.ParentRefs { + if p.Kind != nil && *p.Kind != "Gateway" { continue } + ns := r.Namespace + if p.Namespace != nil { ns = string(*p.Namespace) } + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: string(p.Name)}}) + } + } + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { enq(ctx, e.Object, q) }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { enq(ctx, e.ObjectNew, q) }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { enq(ctx, e.Object, q) }, + } +} +``` + +- [ ] **Step 4: Verify build** + +```bash +go build ./internal/controller/gateway/shared/... +``` + +Expected: success. + +- [ ] **Step 5: Commit** + +```bash +git add internal/controller/gateway/shared/eventhandlers/ +git commit -m "feat(gateway/shared): add cross-resource event handlers" +``` + +--- + +## Section E — UseCase shared (`internal/usecase/gateway_uc/shared/`) + +### Task E1: Reference-grant evaluator + +**Files:** +- Create: `internal/usecase/gateway_uc/shared/refgrant.go` +- Create: `internal/usecase/gateway_uc/shared/refgrant_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package shared + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" +) + +func TestRefGrantAllowed_SameNamespace(t *testing.T) { + c := fake.NewClientBuilder().Build() + ok, err := RefGrantAllowed(context.Background(), c, + Ref{Group: "", Kind: "Service", Namespace: "ns", Name: "svc"}, + Ref{Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Namespace: "ns", Name: "r"}, + ) + if err != nil { t.Fatal(err) } + if !ok { t.Fatal("same-namespace must always be allowed") } +} + +func TestRefGrantAllowed_GrantPresent(t *testing.T) { + grant := &gwv1beta1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{Name: "g", Namespace: "backend-ns"}, + Spec: gwv1beta1.ReferenceGrantSpec{ + From: []gwv1beta1.ReferenceGrantFrom{{ + Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Namespace: "route-ns", + }}, + To: []gwv1beta1.ReferenceGrantTo{{Group: "", Kind: "Service"}}, + }, + } + c := fake.NewClientBuilder().WithScheme(testScheme()).WithObjects(grant).Build() + ok, _ := RefGrantAllowed(context.Background(), c, + Ref{Group: "", Kind: "Service", Namespace: "backend-ns", Name: "svc"}, + Ref{Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Namespace: "route-ns", Name: "r"}, + ) + if !ok { t.Fatal("grant should allow") } +} + +func TestRefGrantAllowed_NoGrant(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(testScheme()).Build() + ok, _ := RefGrantAllowed(context.Background(), c, + Ref{Group: "", Kind: "Service", Namespace: "backend-ns", Name: "svc"}, + Ref{Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Namespace: "route-ns", Name: "r"}, + ) + if ok { t.Fatal("no grant: should deny") } +} +``` + +`testScheme()` is a small helper at the bottom of the test file: + +```go +import ( + "k8s.io/apimachinery/pkg/runtime" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" +) +func testScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = gwv1beta1.Install(s) + return s +} +``` + +- [ ] **Step 2: Run, verify FAIL** + +```bash +go test ./internal/usecase/gateway_uc/shared/ -run TestRefGrant +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```go +package shared + +import ( + "context" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" +) + +// Ref identifies one side of a cross-namespace reference. +type Ref struct { + Group, Kind, Namespace, Name string +} + +// RefGrantAllowed reports whether `from` is permitted to reference `to`. +// Same-namespace is always allowed. Otherwise we require a ReferenceGrant +// in `to.Namespace` whose From matches (Group, Kind, Namespace) and whose +// To matches (Group, Kind) with optional Name match. +func RefGrantAllowed(ctx context.Context, c client.Client, to, from Ref) (bool, error) { + if to.Namespace == from.Namespace { + return true, nil + } + var grants gwv1beta1.ReferenceGrantList + if err := c.List(ctx, &grants, client.InNamespace(to.Namespace)); err != nil { + return false, err + } + for _, g := range grants.Items { + if !grantFromMatches(g.Spec.From, from) { continue } + if !grantToMatches(g.Spec.To, to) { continue } + return true, nil + } + return false, nil +} + +func grantFromMatches(froms []gwv1beta1.ReferenceGrantFrom, f Ref) bool { + for _, x := range froms { + if string(x.Group) == f.Group && string(x.Kind) == f.Kind && string(x.Namespace) == f.Namespace { + return true + } + } + return false +} + +func grantToMatches(tos []gwv1beta1.ReferenceGrantTo, t Ref) bool { + for _, x := range tos { + if string(x.Group) != t.Group || string(x.Kind) != t.Kind { continue } + if x.Name == nil || string(*x.Name) == t.Name { + return true + } + } + return false +} +``` + +- [ ] **Step 4: Run, verify PASS** + +```bash +go test ./internal/usecase/gateway_uc/shared/ -run TestRefGrant -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/usecase/gateway_uc/shared/refgrant.go internal/usecase/gateway_uc/shared/refgrant_test.go +git commit -m "feat(gateway/uc): add ReferenceGrant evaluator" +``` + +--- + +### Task E2: Direct-policy resolver + +**Files:** +- Create: `internal/usecase/gateway_uc/shared/policy_resolver.go` +- Create: `internal/usecase/gateway_uc/shared/policy_resolver_test.go` + +> Pure logic (oldest-wins) used by both validator controllers (to set `Conflicted=True`) and the Gateway use-case (to read the effective policy at reconcile time). + +- [ ] **Step 1: Write failing tests** + +```go +package shared + +import ( + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +type fakePolicy struct { + name string + namespace string + section string + targetKey string + created time.Time +} + +func (p *fakePolicy) GetName() string { return p.name } +func (p *fakePolicy) GetNamespace() string { return p.namespace } +func (p *fakePolicy) GetCreationTimestamp() metav1.Time { return metav1.NewTime(p.created) } +func (p *fakePolicy) Matches(target pkggw.PolicyTarget) bool { + return p.namespace == target.Namespace && + p.targetKey == target.Name && + p.section == target.SectionName +} + +func TestResolveDirectPolicy_OldestWins(t *testing.T) { + older := &fakePolicy{name: "a", namespace: "ns", targetKey: "gw1", created: time.Date(2024,1,1,0,0,0,0,time.UTC)} + newer := &fakePolicy{name: "b", namespace: "ns", targetKey: "gw1", created: time.Date(2025,1,1,0,0,0,0,time.UTC)} + in := []*fakePolicy{newer, older} + target := pkggw.PolicyTarget{Namespace:"ns", Name:"gw1"} + win, lose := ResolveDirectPolicy(in, target) + if win == nil || win.GetName() != "a" { + t.Fatalf("want winner=a; got %#v", win) + } + if len(lose) != 1 || lose[0].GetName() != "b" { + t.Fatalf("want losers=[b]; got %#v", lose) + } +} + +func TestResolveDirectPolicy_NoMatch(t *testing.T) { + p := &fakePolicy{name:"a", namespace:"ns", targetKey:"other", created: time.Now()} + win, lose := ResolveDirectPolicy([]*fakePolicy{p}, pkggw.PolicyTarget{Namespace:"ns", Name:"gw1"}) + if win != nil { t.Fatal("expected no winner") } + if len(lose) != 0 { t.Fatal("expected no losers") } +} + +func TestResolveDirectPolicy_NameTiebreak(t *testing.T) { + same := time.Date(2024,1,1,0,0,0,0,time.UTC) + a := &fakePolicy{name:"alpha", namespace:"ns", targetKey:"gw1", created: same} + b := &fakePolicy{name:"beta", namespace:"ns", targetKey:"gw1", created: same} + win, _ := ResolveDirectPolicy([]*fakePolicy{b, a}, pkggw.PolicyTarget{Namespace:"ns", Name:"gw1"}) + if win.GetName() != "alpha" { t.Fatalf("want alpha; got %s", win.GetName()) } +} +``` + +- [ ] **Step 2: Run, verify FAIL** + +```bash +go test ./internal/usecase/gateway_uc/shared/ -run TestResolveDirectPolicy +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```go +package shared + +import ( + "sort" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// PolicyObj is the minimal interface a Direct policy must satisfy to participate +// in resolution. All four VKS*Policy types implement this either natively +// (via metav1.Object) or via a thin wrapper at call sites. +type PolicyObj interface { + GetName() string + GetNamespace() string + GetCreationTimestamp() metav1.Time + Matches(t pkggw.PolicyTarget) bool +} + +// ResolveDirectPolicy filters policies to those whose Matches(target) is true, +// sorts by (creationTimestamp, namespace/name) ascending, and returns the +// winner (oldest) plus the losers (Conflicted). +func ResolveDirectPolicy[P PolicyObj](in []P, target pkggw.PolicyTarget) (winner P, losers []P) { + var matches []P + for _, p := range in { + if p.Matches(target) { + matches = append(matches, p) + } + } + if len(matches) == 0 { + var zero P + return zero, nil + } + sort.SliceStable(matches, func(i, j int) bool { + ti := matches[i].GetCreationTimestamp() + tj := matches[j].GetCreationTimestamp() + if !ti.Equal(&tj) { + return ti.Before(&tj) + } + ki := matches[i].GetNamespace() + "/" + matches[i].GetName() + kj := matches[j].GetNamespace() + "/" + matches[j].GetName() + return ki < kj + }) + return matches[0], matches[1:] +} +``` + +- [ ] **Step 4: Run, verify PASS** + +```bash +go test ./internal/usecase/gateway_uc/shared/ -run TestResolveDirectPolicy -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/usecase/gateway_uc/shared/policy_resolver.go internal/usecase/gateway_uc/shared/policy_resolver_test.go +git commit -m "feat(gateway/uc): add Direct-policy resolver (oldest-wins)" +``` + +--- + +### Task E3: Per-CRD `Matches` adapters + +**Files:** +- Modify: `api/gateway/v1alpha1/vksgatewaypolicy_types.go` +- Modify: `api/gateway/v1alpha1/vksbackendpolicy_types.go` +- Modify: `api/gateway/v1alpha1/vkshealthcheckpolicy_types.go` +- Modify: `api/gateway/v1alpha1/vksroutepolicy_types.go` + +> Each policy type implements `Matches(target PolicyTarget) bool` so the resolver from Task E2 can be invoked directly without external adapters. + +- [ ] **Step 1: Add to `vksgatewaypolicy_types.go`** + +```go +import pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + +// Matches reports whether this policy targets the given object. +func (p *VKSGatewayPolicy) Matches(t pkggw.PolicyTarget) bool { + for _, ref := range p.Spec.TargetRefs { + if pkggw.TargetRefMatchesWithSection(ref, p.Namespace, t) { + return true + } + } + return false +} +``` + +- [ ] **Step 2: Add to `vksbackendpolicy_types.go`** + +```go +import pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + +func (p *VKSBackendPolicy) Matches(t pkggw.PolicyTarget) bool { + for _, ref := range p.Spec.TargetRefs { + if pkggw.TargetRefMatches(ref, p.Namespace, t) { + return true + } + } + return false +} +``` + +- [ ] **Step 3: Add to `vkshealthcheckpolicy_types.go`** + +```go +import pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + +func (p *VKSHealthCheckPolicy) Matches(t pkggw.PolicyTarget) bool { + for _, ref := range p.Spec.TargetRefs { + if pkggw.TargetRefMatches(ref, p.Namespace, t) { + return true + } + } + return false +} +``` + +- [ ] **Step 4: Add to `vksroutepolicy_types.go`** + +```go +import pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + +func (p *VKSRoutePolicy) Matches(t pkggw.PolicyTarget) bool { + for _, ref := range p.Spec.TargetRefs { + if pkggw.TargetRefMatchesWithSection(ref, p.Namespace, t) { + return true + } + } + return false +} +``` + +- [ ] **Step 5: Verify build** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 6: Commit** + +```bash +git add api/gateway/v1alpha1/vks*_types.go +git commit -m "feat(gateway/api): implement Matches() adapter on all 4 policy CRDs" +``` + +--- + +## Section F — ALB UseCase (`internal/usecase/gateway_uc/alb_gateway_uc/`) + +### Task F1: UseCase entry (Init / Ensure / Delete) + +**Files:** +- Create: `internal/usecase/gateway_uc/alb_gateway_uc/gateway_uc.go` + +- [ ] **Step 1: Write the use-case skeleton** + +```go +package alb_gateway_uc + +import ( + "context" + "sync/atomic" + + "github.com/go-logr/logr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/k8s_repo" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/vngcloud_repo" +) + +// ALBGatewayUseCase is the only writer to the vngcloud LB owned by an ALB Gateway. +type ALBGatewayUseCase struct { + K8s *k8s_repo.K8sRepository + Vng *vngcloud_repo.VngCloudRepository + Log logr.Logger + InitDone atomic.Bool +} + +func NewALBGatewayUseCase(k *k8s_repo.K8sRepository, v *vngcloud_repo.VngCloudRepository, log logr.Logger) *ALBGatewayUseCase { + return &ALBGatewayUseCase{K8s: k, Vng: v, Log: log} +} + +// Init prepares cached state. Currently a no-op; kept for parity with ingress_uc. +func (u *ALBGatewayUseCase) Init(ctx context.Context) error { + u.InitDone.Store(true) + return nil +} + +// Ensure brings the vngcloud LB associated with gw into desired state. +// Calls (in order): build_lb → build_listener → build_pool → build_policy → status. +func (u *ALBGatewayUseCase) Ensure(ctx context.Context, gw *gwv1.Gateway) (Result, error) { + cfg, err := u.resolveEffectiveLBConfig(ctx, gw) + if err != nil { return Result{}, err } + + lb, err := u.ensureLoadBalancer(ctx, gw, cfg) + if err != nil { return Result{}, err } + + if err := u.ensureListeners(ctx, gw, lb, cfg); err != nil { return Result{}, err } + + routes, err := u.listAttachedHTTPRoutes(ctx, gw) + if err != nil { return Result{}, err } + + if err := u.ensurePoolsAndPolicies(ctx, gw, lb, routes); err != nil { return Result{}, err } + + return Result{LB: lb, AttachedRoutes: routes}, nil +} + +// Delete tears down the vngcloud LB and removes the gateway finalizer. +func (u *ALBGatewayUseCase) Delete(ctx context.Context, gw *gwv1.Gateway) error { + return u.deleteLoadBalancer(ctx, gw) +} + +// Result is what reconciler uses to write Gateway status. +type Result struct { + LB *vngcloud_repo.LoadBalancer + AttachedRoutes []gwv1.HTTPRoute +} +``` + +> The methods called from `Ensure` (`resolveEffectiveLBConfig`, `ensureLoadBalancer`, `ensureListeners`, `listAttachedHTTPRoutes`, `ensurePoolsAndPolicies`, `deleteLoadBalancer`) live in the build-stage files implemented in F2–F7. + +- [ ] **Step 2: Verify build** + +```bash +go build ./internal/usecase/gateway_uc/alb_gateway_uc/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/usecase/gateway_uc/alb_gateway_uc/gateway_uc.go +git commit -m "feat(gateway/uc/alb): scaffold ALB Gateway use-case entry" +``` + +--- + +### Task F2: `build_lb.go` — Gateway → LB params + ensure + +**Files:** +- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_lb.go` + +- [ ] **Step 1: Write the file** + +```go +package alb_gateway_uc + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/vngcloud_repo" +) + +// EffectiveLBConfig is the merged view consumed downstream. +type EffectiveLBConfig struct { + UnscopedPolicy *vksv1.VKSGatewayPolicy // for LB-level fields + Listeners map[string]*vksv1.VKSGatewayPolicy // by Gateway listener name; falls back to UnscopedPolicy +} + +// resolveEffectiveLBConfig finds the unscoped (LB-level) and per-listener VKSGatewayPolicy +// objects attached to gw. Losers are recorded internally for the policy validator +// reconcilers to mark as Conflicted. +func (u *ALBGatewayUseCase) resolveEffectiveLBConfig(ctx context.Context, gw *gwv1.Gateway) (*EffectiveLBConfig, error) { + var policies vksv1.VKSGatewayPolicyList + if err := u.K8s.Client.List(ctx, &policies, client.InNamespace(gw.Namespace)); err != nil { + return nil, fmt.Errorf("list VKSGatewayPolicy: %w", err) + } + + unscopedTarget := pkggw.PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", + Namespace: gw.Namespace, Name: gw.Name, + } + candidates := make([]*vksv1.VKSGatewayPolicy, 0, len(policies.Items)) + for i := range policies.Items { + candidates = append(candidates, &policies.Items[i]) + } + unscopedWin, _ := shared.ResolveDirectPolicy(candidates, unscopedTarget) + + out := &EffectiveLBConfig{UnscopedPolicy: unscopedWin, Listeners: map[string]*vksv1.VKSGatewayPolicy{}} + for _, l := range gw.Spec.Listeners { + target := pkggw.PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", + Namespace: gw.Namespace, Name: gw.Name, SectionName: string(l.Name), + } + win, _ := shared.ResolveDirectPolicy(candidates, target) + if win != nil { + out.Listeners[string(l.Name)] = win + } else { + out.Listeners[string(l.Name)] = unscopedWin + } + } + return out, nil +} + +// ensureLoadBalancer looks up the LB by owner-uid label or creates one. +func (u *ALBGatewayUseCase) ensureLoadBalancer(ctx context.Context, gw *gwv1.Gateway, cfg *EffectiveLBConfig) (*vngcloud_repo.LoadBalancer, error) { + selector := labels.SelectorFromSet(labels.Set{ + domain.OwnerLabelGatewayUID: string(gw.UID), + domain.OwnerLabelKind: domain.OwnerKindGateway, + }) + + existing, err := u.Vng.FindLoadBalancerByLabels(ctx, selector) + if err != nil { return nil, err } + desired := lbParamsFromConfig(gw, cfg) + if existing != nil { + return u.Vng.UpdateLoadBalancer(ctx, existing.ID, desired) + } + desired.Labels = map[string]string{ + domain.OwnerLabelGatewayUID: string(gw.UID), + domain.OwnerLabelKind: domain.OwnerKindGateway, + } + desired.Name = fmt.Sprintf("vks-gw-%s-%s", gw.Namespace, gw.Name) + return u.Vng.CreateLoadBalancer(ctx, desired) +} + +func (u *ALBGatewayUseCase) deleteLoadBalancer(ctx context.Context, gw *gwv1.Gateway) error { + selector := labels.SelectorFromSet(labels.Set{domain.OwnerLabelGatewayUID: string(gw.UID)}) + lb, err := u.Vng.FindLoadBalancerByLabels(ctx, selector) + if err != nil || lb == nil { return err } + return u.Vng.DeleteLoadBalancer(ctx, lb.ID) +} + +func lbParamsFromConfig(gw *gwv1.Gateway, cfg *EffectiveLBConfig) vngcloud_repo.LoadBalancerParams { + p := vngcloud_repo.LoadBalancerParams{ + Type: vngcloud_repo.LoadBalancerTypeALB, + Scheme: vngcloud_repo.LoadBalancerSchemeInternet, + } + if cfg == nil || cfg.UnscopedPolicy == nil || cfg.UnscopedPolicy.Spec.LoadBalancerSpec == nil { + return p + } + s := cfg.UnscopedPolicy.Spec.LoadBalancerSpec + if s.Scheme != nil { p.Scheme = vngcloud_repo.LoadBalancerScheme(*s.Scheme) } + if s.PackageID != nil { p.PackageID = *s.PackageID } + if s.SubnetID != nil { p.SubnetID = *s.SubnetID } + p.Tags = s.Tags + if s.LoadBalancerID != nil { p.AdoptByID = *s.LoadBalancerID } + return p +} + +// noteCreationTime is exported for tests. +var noteCreationTime = func() metav1.Time { return metav1.Now() } +``` + +> The exact `vngcloud_repo` types (`LoadBalancer`, `LoadBalancerParams`, `LoadBalancerScheme`, etc.) match the existing names in the repository. If any field name differs, follow the pattern used by `internal/usecase/lbc_uc/deploy_lb.go` — that file is the reference for both naming and behavior. + +- [ ] **Step 2: Verify build** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/usecase/gateway_uc/alb_gateway_uc/build_lb.go +git commit -m "feat(gateway/uc/alb): resolve LB-level + listener policies and ensure LB" +``` + +--- + +### Task F3: `build_listener.go` + +**Files:** +- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_listener.go` + +- [ ] **Step 1: Write the file** + +```go +package alb_gateway_uc + +import ( + "context" + "fmt" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/vngcloud_repo" +) + +// ensureListeners synchronizes the vngcloud LB's listener set against gw.Spec.Listeners. +func (u *ALBGatewayUseCase) ensureListeners(ctx context.Context, gw *gwv1.Gateway, lb *vngcloud_repo.LoadBalancer, cfg *EffectiveLBConfig) error { + desired := make([]vngcloud_repo.ListenerParams, 0, len(gw.Spec.Listeners)) + for _, l := range gw.Spec.Listeners { + params, err := u.buildListenerParams(ctx, gw, l, cfg.Listeners[string(l.Name)]) + if err != nil { + return fmt.Errorf("listener %q: %w", l.Name, err) + } + desired = append(desired, params) + } + return u.Vng.SyncListeners(ctx, lb.ID, desired) +} + +func (u *ALBGatewayUseCase) buildListenerParams(ctx context.Context, gw *gwv1.Gateway, l gwv1.Listener, pol *vksv1.VKSGatewayPolicy) (vngcloud_repo.ListenerParams, error) { + p := vngcloud_repo.ListenerParams{ + Name: string(l.Name), + Protocol: string(l.Protocol), + Port: int32(l.Port), + } + // TLS: prefer policy-supplied IDs, else import Secrets. + if l.TLS != nil { + certIDs, clientCAID, err := u.resolveCertIDs(ctx, gw, l, pol) + if err != nil { return p, err } + p.CertificateIDs = certIDs + p.ClientCertCAID = clientCAID + } + if pol != nil { + s := pol.Spec + if s.SSLPolicy != nil { p.SSLPolicy = *s.SSLPolicy } + if s.ALPNPolicy != nil { p.ALPNPolicy = *s.ALPNPolicy } + if s.TimeoutClient != nil { p.TimeoutClient = s.TimeoutClient.Duration } + if s.TimeoutMember != nil { p.TimeoutMember = s.TimeoutMember.Duration } + if s.TimeoutConnection!= nil { p.TimeoutConnection= s.TimeoutConnection.Duration } + p.AllowedCIDRs = append(p.AllowedCIDRs, s.AllowedCIDRs...) + p.InsertHeaders = mergeStringMaps(p.InsertHeaders, s.InsertHeaders) + } + // Default forwarding pool — always-empty placeholder so the listener is valid + // before any HTTPRoute attaches. + p.DefaultPoolName = fmt.Sprintf("default-%s-%s", gw.UID, l.Name) + return p, nil +} + +func mergeStringMaps(a, b map[string]string) map[string]string { + if len(a) == 0 && len(b) == 0 { return nil } + out := make(map[string]string, len(a)+len(b)) + for k, v := range a { out[k] = v } + for k, v := range b { out[k] = v } + return out +} +``` + +- [ ] **Step 2: Verify build** + +```bash +go build ./... +``` + +Expected: success (will fail until Task F4 provides `resolveCertIDs`; expected at this point — proceed to F4 immediately). + +- [ ] **Step 3: Commit (combined with F4 if needed)** + +```bash +git add internal/usecase/gateway_uc/alb_gateway_uc/build_listener.go +# Combined commit happens after F4 if build needs it. +``` + +--- + +### Task F4: `build_cert.go` + +**Files:** +- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_cert.go` + +- [ ] **Step 1: Write the file** + +```go +package alb_gateway_uc + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" +) + +// resolveCertIDs returns server-cert IDs and an optional client-CA cert ID for the listener. +// Precedence: +// - policy.CertificateIDs[] overrides tls.certificateRefs +// - policy.ClientCertificateID overrides tls.frontendValidation.caCertificateRefs +func (u *ALBGatewayUseCase) resolveCertIDs(ctx context.Context, gw *gwv1.Gateway, l gwv1.Listener, pol *vksv1.VKSGatewayPolicy) ([]string, string, error) { + var serverIDs []string + if pol != nil && len(pol.Spec.CertificateIDs) > 0 { + serverIDs = append(serverIDs, pol.Spec.CertificateIDs...) + } else if l.TLS != nil { + for _, ref := range l.TLS.CertificateRefs { + id, err := u.importSecretCert(ctx, gw, ref) + if err != nil { return nil, "", err } + serverIDs = append(serverIDs, id) + } + } + + var clientCAID string + if pol != nil && pol.Spec.ClientCertificateID != nil { + clientCAID = *pol.Spec.ClientCertificateID + } else if l.TLS != nil && l.TLS.FrontendValidation != nil { + for _, ref := range l.TLS.FrontendValidation.CACertificateRefs { + id, err := u.importCACert(ctx, gw, ref) + if err != nil { return nil, "", err } + clientCAID = id // last one wins; document in spec §3.3 + } + } + return serverIDs, clientCAID, nil +} + +func (u *ALBGatewayUseCase) importSecretCert(ctx context.Context, gw *gwv1.Gateway, ref gwv1.SecretObjectReference) (string, error) { + ns := gw.Namespace + if ref.Namespace != nil { ns = string(*ref.Namespace) } + + if ns != gw.Namespace { + ok, err := shared.RefGrantAllowed(ctx, u.K8s.Client, + shared.Ref{Group:"", Kind:"Secret", Namespace: ns, Name: string(ref.Name)}, + shared.Ref{Group:"gateway.networking.k8s.io", Kind:"Gateway", Namespace: gw.Namespace, Name: gw.Name}, + ) + if err != nil { return "", err } + if !ok { return "", fmt.Errorf("ReferenceGrant denies cross-ns secret %s/%s", ns, ref.Name) } + } + + var sec corev1.Secret + if err := u.K8s.Client.Get(ctx, client.ObjectKey{Namespace: ns, Name: string(ref.Name)}, &sec); err != nil { + return "", err + } + return u.Vng.ImportCertificateFromSecret(ctx, &sec) +} + +// importCACert imports a CA bundle ConfigMap. +// Same RefGrant logic as importSecretCert, but the source object is a ConfigMap. +func (u *ALBGatewayUseCase) importCACert(ctx context.Context, gw *gwv1.Gateway, ref gwv1.ObjectReference) (string, error) { + ns := gw.Namespace + if ref.Namespace != nil { ns = string(*ref.Namespace) } + + if ns != gw.Namespace { + ok, err := shared.RefGrantAllowed(ctx, u.K8s.Client, + shared.Ref{Group: string(ref.Group), Kind: string(ref.Kind), Namespace: ns, Name: string(ref.Name)}, + shared.Ref{Group:"gateway.networking.k8s.io", Kind:"Gateway", Namespace: gw.Namespace, Name: gw.Name}, + ) + if err != nil { return "", err } + if !ok { return "", fmt.Errorf("ReferenceGrant denies cross-ns CA %s/%s/%s", ref.Kind, ns, ref.Name) } + } + + var cm corev1.ConfigMap + if err := u.K8s.Client.Get(ctx, client.ObjectKey{Namespace: ns, Name: string(ref.Name)}, &cm); err != nil { + return "", err + } + return u.Vng.ImportCACertificateFromConfigMap(ctx, &cm) +} +``` + +> If your existing certificate-import path uses different signatures, copy the call shape from `internal/usecase/lbc_uc/deploy_listener.go` — the helper names there are the reference. + +- [ ] **Step 2: Verify build** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 3: Commit (combined F3 + F4)** + +```bash +git add internal/usecase/gateway_uc/alb_gateway_uc/build_listener.go internal/usecase/gateway_uc/alb_gateway_uc/build_cert.go +git commit -m "feat(gateway/uc/alb): build listener params + resolve cert IDs" +``` + +--- + +### Task F5: `build_pool.go` + +**Files:** +- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_pool.go` + +- [ ] **Step 1: Write the file** + +```go +package alb_gateway_uc + +import ( + "context" + "fmt" + + discoveryv1 "k8s.io/api/discovery/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/vngcloud_repo" +) + +// synthesizePool builds the vngcloud Pool params for one HTTPRoute rule. +// - merges all backendRefs into one pool (weight-scaled) +// - resolves VKSBackendPolicy (per-Service, oldest-wins) +// - resolves VKSHealthCheckPolicy (per-Service; conflicting health-checks across backends → error) +func (u *ALBGatewayUseCase) synthesizePool(ctx context.Context, route *gwv1.HTTPRoute, ruleIdx int, rule gwv1.HTTPRouteRule) (vngcloud_repo.PoolParams, error) { + if len(rule.BackendRefs) == 0 { + return vngcloud_repo.PoolParams{}, fmt.Errorf("rule %d has no backendRefs", ruleIdx) + } + + var ( + keys []pkggw.BackendKey + weights []pkggw.BackendWeight + chosenHC *vksv1.VKSHealthCheckPolicy + chosenBP *vksv1.VKSBackendPolicy + ) + + for _, br := range rule.BackendRefs { + ns := route.Namespace + if br.Namespace != nil { ns = string(*br.Namespace) } + if ns != route.Namespace { + ok, err := sharedUC.RefGrantAllowed(ctx, u.K8s.Client, + sharedUC.Ref{Group:"", Kind:"Service", Namespace: ns, Name: string(br.Name)}, + sharedUC.Ref{Group:"gateway.networking.k8s.io", Kind:"HTTPRoute", Namespace: route.Namespace, Name: route.Name}, + ) + if err != nil { return vngcloud_repo.PoolParams{}, err } + if !ok { continue } // drop denied backend; route condition set elsewhere + } + + port := int32(0) + if br.Port != nil { port = int32(*br.Port) } + weight := int32(1) + if br.Weight != nil { weight = *br.Weight } + + ready, err := u.countReadyEndpoints(ctx, ns, string(br.Name), port) + if err != nil { return vngcloud_repo.PoolParams{}, err } + + keys = append(keys, pkggw.BackendKey{Namespace: ns, Name: string(br.Name), Port: port, Weight: weight}) + weights = append(weights, pkggw.BackendWeight{Weight: weight, Ready: int32(ready)}) + + bp, err := u.resolveBackendPolicy(ctx, ns, string(br.Name)) + if err != nil { return vngcloud_repo.PoolParams{}, err } + if bp != nil { + if chosenBP != nil && !backendPolicyEqual(chosenBP, bp) { + return vngcloud_repo.PoolParams{}, fmt.Errorf("conflicting VKSBackendPolicy across backends: %s/%s vs %s/%s", + chosenBP.Namespace, chosenBP.Name, bp.Namespace, bp.Name) + } + chosenBP = bp + } + + hc, err := u.resolveHealthCheckPolicy(ctx, ns, string(br.Name)) + if err != nil { return vngcloud_repo.PoolParams{}, err } + if hc != nil { + if chosenHC != nil && !healthCheckPolicyEqual(chosenHC, hc) { + return vngcloud_repo.PoolParams{}, fmt.Errorf("conflicting VKSHealthCheckPolicy across backends: %s/%s vs %s/%s", + chosenHC.Namespace, chosenHC.Name, hc.Namespace, hc.Name) + } + chosenHC = hc + } + } + + if len(keys) == 0 { + return vngcloud_repo.PoolParams{}, fmt.Errorf("rule %d: all backendRefs dropped (RefGrant)", ruleIdx) + } + + name := pkggw.SynthPoolName(string(route.UID), ruleIdx, keys) + scaled := pkggw.ScaleWeights(weights) + + pp := vngcloud_repo.PoolParams{Name: name} + for i, k := range keys { + pp.Members = append(pp.Members, vngcloud_repo.PoolMember{ + ServiceNamespace: k.Namespace, + ServiceName: k.Name, + Port: k.Port, + Weight: scaled[i], + }) + } + applyBackendPolicy(&pp, chosenBP) + applyHealthCheckPolicy(&pp, chosenHC) + return pp, nil +} + +func (u *ALBGatewayUseCase) countReadyEndpoints(ctx context.Context, ns, name string, port int32) (int, error) { + var slices discoveryv1.EndpointSliceList + if err := u.K8s.Client.List(ctx, &slices, + client.InNamespace(ns), + client.MatchingLabels{discoveryv1.LabelServiceName: name}, + ); err != nil { return 0, err } + n := 0 + for _, s := range slices.Items { + for _, ep := range s.Endpoints { + if ep.Conditions.Ready != nil && *ep.Conditions.Ready { + n++ + } + } + } + if n == 0 { n = 1 } + return n, nil +} + +func (u *ALBGatewayUseCase) resolveBackendPolicy(ctx context.Context, ns, svc string) (*vksv1.VKSBackendPolicy, error) { + var list vksv1.VKSBackendPolicyList + if err := u.K8s.Client.List(ctx, &list, client.InNamespace(ns)); err != nil { return nil, err } + cands := make([]*vksv1.VKSBackendPolicy, 0, len(list.Items)) + for i := range list.Items { cands = append(cands, &list.Items[i]) } + win, _ := sharedUC.ResolveDirectPolicy(cands, pkggw.PolicyTarget{ + Group: "", Kind: "Service", Namespace: ns, Name: svc, + }) + return win, nil +} + +func (u *ALBGatewayUseCase) resolveHealthCheckPolicy(ctx context.Context, ns, svc string) (*vksv1.VKSHealthCheckPolicy, error) { + var list vksv1.VKSHealthCheckPolicyList + if err := u.K8s.Client.List(ctx, &list, client.InNamespace(ns)); err != nil { return nil, err } + cands := make([]*vksv1.VKSHealthCheckPolicy, 0, len(list.Items)) + for i := range list.Items { cands = append(cands, &list.Items[i]) } + win, _ := sharedUC.ResolveDirectPolicy(cands, pkggw.PolicyTarget{ + Group: "", Kind: "Service", Namespace: ns, Name: svc, + }) + return win, nil +} + +func applyBackendPolicy(pp *vngcloud_repo.PoolParams, bp *vksv1.VKSBackendPolicy) { + if bp == nil { return } + s := bp.Spec + if s.PoolAlgorithm != nil { pp.Algorithm = *s.PoolAlgorithm } + if s.TargetType != nil { pp.TargetType = *s.TargetType } + if s.SessionAffinity != nil { + pp.SessionAffinity = vngcloud_repo.SessionAffinity{ + Type: s.SessionAffinity.Type, + CookieName: derefStringPtr(s.SessionAffinity.CookieName), + } + if s.SessionAffinity.TTL != nil { pp.SessionAffinity.TTL = s.SessionAffinity.TTL.Duration } + } + if s.EnableTLSEncryption != nil { pp.TLSEncryption = *s.EnableTLSEncryption } + if s.EnableProxyProtocol != nil { pp.ProxyProtocol = *s.EnableProxyProtocol } + if s.ManageDFPMembers != nil { pp.ManageDFPMembers = *s.ManageDFPMembers } + pp.NodeLabels = s.TargetNodeLabels +} + +func applyHealthCheckPolicy(pp *vngcloud_repo.PoolParams, hc *vksv1.VKSHealthCheckPolicy) { + if hc == nil { + pp.HealthCheck = vngcloud_repo.HealthCheck{Protocol: "TCP"} + return + } + s := hc.Spec + out := vngcloud_repo.HealthCheck{Protocol: s.Protocol} + if s.Port != nil { out.Port = *s.Port } + if s.Interval != nil { out.Interval = s.Interval.Duration } + if s.Timeout != nil { out.Timeout = s.Timeout.Duration } + if s.HealthyThreshold != nil { out.HealthyThreshold = *s.HealthyThreshold } + if s.UnhealthyThreshold != nil { out.UnhealthyThreshold = *s.UnhealthyThreshold } + if s.HTTPHealthCheck != nil { + out.HTTPPath = derefStringPtr(s.HTTPHealthCheck.Path) + out.HTTPHost = derefStringPtr(s.HTTPHealthCheck.Host) + out.ExpectedCodes = s.HTTPHealthCheck.ExpectedCodes + out.RequestHeaders = s.HTTPHealthCheck.RequestHeaders + } + pp.HealthCheck = out +} + +func backendPolicyEqual(a, b *vksv1.VKSBackendPolicy) bool { return a.Namespace == b.Namespace && a.Name == b.Name } +func healthCheckPolicyEqual(a, b *vksv1.VKSHealthCheckPolicy) bool { return a.Namespace == b.Namespace && a.Name == b.Name } +func derefStringPtr(p *string) string { if p == nil { return "" }; return *p } +``` + +- [ ] **Step 2: Verify build** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/usecase/gateway_uc/alb_gateway_uc/build_pool.go +git commit -m "feat(gateway/uc/alb): synthesize weighted pool with backend + health policies" +``` + +--- + +### Task F6: `build_policy.go` + +**Files:** +- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_policy.go` + +- [ ] **Step 1: Write the file** + +```go +package alb_gateway_uc + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/vngcloud_repo" +) + +// ensurePoolsAndPolicies generates the desired pool/policy set from attached HTTPRoutes +// and pushes the diff to vngcloud. +func (u *ALBGatewayUseCase) ensurePoolsAndPolicies(ctx context.Context, gw *gwv1.Gateway, lb *vngcloud_repo.LoadBalancer, routes []gwv1.HTTPRoute) error { + var ( + pools []vngcloud_repo.PoolParams + policies []vngcloud_repo.PolicyParams + ) + for _, r := range routes { + rp, pp, err := u.buildPoliciesForRoute(ctx, gw, &r) + if err != nil { return err } + pools = append(pools, pp...) + policies = append(policies, rp...) + } + if err := u.Vng.SyncPools(ctx, lb.ID, pools); err != nil { return err } + return u.Vng.SyncPolicies(ctx, lb.ID, policies) +} + +func (u *ALBGatewayUseCase) buildPoliciesForRoute(ctx context.Context, gw *gwv1.Gateway, route *gwv1.HTTPRoute) ([]vngcloud_repo.PolicyParams, []vngcloud_repo.PoolParams, error) { + var policies []vngcloud_repo.PolicyParams + var pools []vngcloud_repo.PoolParams + + routePolicies, err := u.listVKSRoutePolicies(ctx, route) + if err != nil { return nil, nil, err } + + for ri, rule := range route.Spec.Rules { + pool, err := u.synthesizePool(ctx, route, ri, rule) + if err != nil { return nil, nil, err } + pools = append(pools, pool) + + section := "" + if rule.Name != nil { section = string(*rule.Name) } + rp := selectRoutePolicies(routePolicies, route, section) + + for _, m := range rule.Matches { + for _, host := range hostnameSet(route) { + pp := vngcloud_repo.PolicyParams{ + Name: fmt.Sprintf("p_%s_%d_%x", string(route.UID)[:8], ri, hashMatch(host, m)), + Match: vngcloud_repo.Match{ + Hostname: host, + Path: pathMatch(m.Path), + Headers: headerMatches(m.Headers), + QueryParams: queryMatches(m.QueryParams), + Method: methodFromMatch(m.Method), + }, + Action: vngcloud_repo.Action{Kind: vngcloud_repo.ActionRedirectToPool, PoolName: pool.Name}, + } + applyRoutePolicies(&pp, rp) + applyRequestRedirectFilter(&pp, rule.Filters) + policies = append(policies, pp) + } + } + } + return policies, pools, nil +} + +func (u *ALBGatewayUseCase) listVKSRoutePolicies(ctx context.Context, route *gwv1.HTTPRoute) ([]vksv1.VKSRoutePolicy, error) { + var list vksv1.VKSRoutePolicyList + if err := u.K8s.Client.List(ctx, &list, client.InNamespace(route.Namespace)); err != nil { return nil, err } + return list.Items, nil +} + +// selectRoutePolicies returns policies whose targetRefs match (route, section) or (route, ""). +// Among matches, ResolveDirectPolicy picks the winner per (route, section) scope. +func selectRoutePolicies(policies []vksv1.VKSRoutePolicy, route *gwv1.HTTPRoute, section string) []*vksv1.VKSRoutePolicy { + cands := make([]*vksv1.VKSRoutePolicy, 0, len(policies)) + for i := range policies { cands = append(cands, &policies[i]) } + var out []*vksv1.VKSRoutePolicy + for _, sect := range []string{section, ""} { + target := pkggw.PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", + Namespace: route.Namespace, Name: route.Name, SectionName: sect, + } + win, _ := sharedUC.ResolveDirectPolicy(cands, target) + if win != nil { out = append(out, win) } + } + return out +} + +func applyRoutePolicies(pp *vngcloud_repo.PolicyParams, rp []*vksv1.VKSRoutePolicy) { + for _, p := range rp { + for _, am := range p.Spec.AdditionalMatches { + pp.Match.Additional = append(pp.Match.Additional, vngcloud_repo.AdditionalMatch{ + Type: am.Type, Name: derefStringPtr(am.Name), Compare: am.Compare, Value: am.Value, + }) + } + if len(p.Spec.Actions) > 0 { + // Spec §3.4: actions supersede REDIRECT_TO_POOL. + a := p.Spec.Actions[0] + switch a.Type { + case "FixedResponse": + pp.Action = vngcloud_repo.Action{Kind: vngcloud_repo.ActionFixedResponse, + StatusCode: a.FixedResponse.StatusCode, + Body: derefStringPtr(a.FixedResponse.Body), + } + case "Reject": + pp.Action = vngcloud_repo.Action{Kind: vngcloud_repo.ActionReject} + case "Redirect": + pp.Action = vngcloud_repo.Action{Kind: vngcloud_repo.ActionRedirect, + URL: a.Redirect.URL, + HTTPCode: intDerefOr(a.Redirect.HTTPCode, 302), + KeepQueryString: boolDerefOr(a.Redirect.KeepQueryString, true), + } + } + } + if p.Spec.Position != nil { + pp.Position = *p.Spec.Position + } + } +} + +func applyRequestRedirectFilter(pp *vngcloud_repo.PolicyParams, filters []gwv1.HTTPRouteFilter) { + for _, f := range filters { + if f.Type == gwv1.HTTPRouteFilterRequestRedirect && f.RequestRedirect != nil { + pp.Action = vngcloud_repo.Action{ + Kind: vngcloud_repo.ActionRedirect, + URL: buildRedirectURL(f.RequestRedirect), + HTTPCode: intDerefOr(asInt32Ptr(f.RequestRedirect.StatusCode), 302), + } + } + } +} + +// helpers (compactness) ---------------------------------------------------- + +func hostnameSet(r *gwv1.HTTPRoute) []string { + if len(r.Spec.Hostnames) == 0 { return []string{""} } + out := make([]string, 0, len(r.Spec.Hostnames)) + for _, h := range r.Spec.Hostnames { out = append(out, string(h)) } + return out +} + +func pathMatch(p *gwv1.HTTPPathMatch) vngcloud_repo.PathMatch { + if p == nil { return vngcloud_repo.PathMatch{Type: "STARTS_WITH", Value: "/"} } + out := vngcloud_repo.PathMatch{Value: derefStringPtr(p.Value)} + if p.Type == nil { out.Type = "STARTS_WITH"; return out } + switch *p.Type { + case gwv1.PathMatchExact: out.Type = "EQUAL_TO" + case gwv1.PathMatchPathPrefix: out.Type = "STARTS_WITH" + case gwv1.PathMatchRegularExpression: out.Type = "REGEX" + default: out.Type = "EQUAL_TO" + } + return out +} + +func headerMatches(hs []gwv1.HTTPHeaderMatch) []vngcloud_repo.HeaderMatch { + out := make([]vngcloud_repo.HeaderMatch, 0, len(hs)) + for _, h := range hs { + kind := "EQUAL_TO" + if h.Type != nil && *h.Type == gwv1.HeaderMatchRegularExpression { + kind = "REGEX" + } + out = append(out, vngcloud_repo.HeaderMatch{Name: string(h.Name), Compare: kind, Value: h.Value}) + } + return out +} + +func queryMatches(qs []gwv1.HTTPQueryParamMatch) []vngcloud_repo.QueryMatch { + out := make([]vngcloud_repo.QueryMatch, 0, len(qs)) + for _, q := range qs { + kind := "EQUAL_TO" + if q.Type != nil && *q.Type == gwv1.QueryParamMatchRegularExpression { + kind = "REGEX" + } + out = append(out, vngcloud_repo.QueryMatch{Name: string(q.Name), Compare: kind, Value: q.Value}) + } + return out +} + +func methodFromMatch(m *gwv1.HTTPMethod) string { if m == nil { return "" }; return string(*m) } + +func buildRedirectURL(r *gwv1.HTTPRequestRedirectFilter) string { + // Implementation note: full URL templating per Gateway-API spec. + // Inline per-field assembly here keeps this self-contained. + scheme := "https" + if r.Scheme != nil { scheme = *r.Scheme } + host := "" + if r.Hostname != nil { host = string(*r.Hostname) } + out := fmt.Sprintf("%s://%s", scheme, host) + if r.Port != nil { out = fmt.Sprintf("%s:%d", out, *r.Port) } + if r.Path != nil && r.Path.ReplaceFullPath != nil { out += *r.Path.ReplaceFullPath } + return out +} + +func intDerefOr(p *int32, def int32) int32 { if p == nil { return def }; return *p } +func boolDerefOr(p *bool, def bool) bool { if p == nil { return def }; return *p } +func asInt32Ptr(p *int) *int32 { + if p == nil { return nil } + v := int32(*p); return &v +} + +func hashMatch(host string, m gwv1.HTTPRouteMatch) uint32 { + // simple FNV-1a — pool-name uniqueness only, not security + var h uint32 = 2166136261 + add := func(s string) { for _, c := range s { h ^= uint32(c); h *= 16777619 } } + add(host) + if m.Path != nil { + add(derefStringPtr(m.Path.Value)) + if m.Path.Type != nil { add(string(*m.Path.Type)) } + } + for _, hm := range m.Headers { add(string(hm.Name) + "=" + hm.Value) } + for _, qm := range m.QueryParams { add(string(qm.Name) + "=" + qm.Value) } + if m.Method != nil { add(string(*m.Method)) } + return h +} +``` + +> The `vngcloud_repo` action kinds (`ActionRedirectToPool`, `ActionFixedResponse`, `ActionReject`, `ActionRedirect`) and policy-param shape match the existing `internal/repository/vngcloud_repo` types used by `lbc_uc`. Mirror those types in your branch's repository — their names may differ from the names shown here. + +- [ ] **Step 2: Verify build** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/usecase/gateway_uc/alb_gateway_uc/build_policy.go +git commit -m "feat(gateway/uc/alb): build vngcloud Policies from HTTPRoute + VKSRoutePolicy" +``` + +--- + +### Task F7: `build_sec_group.go` and `status.go` + +**Files:** +- Create: `internal/usecase/gateway_uc/alb_gateway_uc/build_sec_group.go` +- Create: `internal/usecase/gateway_uc/alb_gateway_uc/status.go` + +- [ ] **Step 1: Write `build_sec_group.go`** + +```go +package alb_gateway_uc + +import ( + "context" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// ensureNodeSecurityGroup makes sure the NSG referenced by the LB allows traffic +// from the LB's listener ports. Mirrors `internal/usecase/ingress_uc/build_sec_group.go`. +// +// Phase 1: reuse the existing NSG path verbatim. The Gateway-API entry simply +// invokes the existing Ingress NSG helper with the Gateway's LB ID and listener ports. +func (u *ALBGatewayUseCase) ensureNodeSecurityGroup(ctx context.Context, gw *gwv1.Gateway) error { + // Replace `u.Vng.EnsureNSGForLB` with whatever the existing helper is named in this branch; + // the AWS-style design used a method on ingress_uc that we factor out for reuse. + return u.Vng.EnsureNSGForGateway(ctx, gw) +} +``` + +> If your existing NSG helper has a different signature, adapt the body. The intent is "do whatever ingress_uc/service_uc does, but for a Gateway-owned LB." + +- [ ] **Step 2: Write `status.go`** + +```go +package alb_gateway_uc + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" +) + +// WriteGatewayStatus persists the Accepted / Programmed conditions and address list. +func (u *ALBGatewayUseCase) WriteGatewayStatus(ctx context.Context, gw *gwv1.Gateway, res *Result, reconcileErr error) error { + cur := gw.DeepCopy() + + accepted := metav1.Condition{ + Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted", + ObservedGeneration: gw.Generation, + } + programmed := metav1.Condition{ + Type: "Programmed", Status: metav1.ConditionTrue, Reason: "Programmed", + ObservedGeneration: gw.Generation, + } + if reconcileErr != nil { + programmed.Status = metav1.ConditionFalse + programmed.Reason = "Pending" + programmed.Message = reconcileErr.Error() + } + shared.SetCondition(&cur.Status.Conditions, accepted) + shared.SetCondition(&cur.Status.Conditions, programmed) + + if res != nil && res.LB != nil { + addrs := []gwv1.GatewayStatusAddress{} + for _, a := range res.LB.Addresses { + t := gwv1.IPAddressType + addrs = append(addrs, gwv1.GatewayStatusAddress{Type: &t, Value: a}) + } + cur.Status.Addresses = addrs + } + return u.K8s.Client.Status().Patch(ctx, cur, client.MergeFrom(gw)) +} + +// WriteHTTPRouteStatus persists per-parent Accepted / ResolvedRefs conditions. +func (u *ALBGatewayUseCase) WriteHTTPRouteStatus(ctx context.Context, route *gwv1.HTTPRoute, gw *gwv1.Gateway, accepted bool, resolved bool, reason string) error { + cur := route.DeepCopy() + parentStatus := gwv1.RouteParentStatus{ + ParentRef: gwv1.ParentReference{ + Group: ptrGroup("gateway.networking.k8s.io"), + Kind: ptrKind("Gateway"), + Name: gwv1.ObjectName(gw.Name), + }, + ControllerName: "gateway.vks.vngcloud.vn/alb", + } + shared.SetCondition(&parentStatus.Conditions, metav1.Condition{ + Type: "Accepted", + Status: condStatus(accepted), + Reason: reasonOr(accepted, "Accepted", reason), + ObservedGeneration: route.Generation, + }) + shared.SetCondition(&parentStatus.Conditions, metav1.Condition{ + Type: "ResolvedRefs", + Status: condStatus(resolved), + Reason: reasonOr(resolved, "ResolvedRefs", reason), + ObservedGeneration: route.Generation, + }) + cur.Status.Parents = upsertParentStatus(cur.Status.Parents, parentStatus) + return u.K8s.Client.Status().Patch(ctx, cur, client.MergeFrom(route)) +} + +func ptrGroup(s string) *gwv1.Group { v := gwv1.Group(s); return &v } +func ptrKind(s string) *gwv1.Kind { v := gwv1.Kind(s); return &v } +func condStatus(b bool) metav1.ConditionStatus { + if b { return metav1.ConditionTrue } + return metav1.ConditionFalse +} +func reasonOr(ok bool, good, bad string) string { if ok { return good }; return bad } +func upsertParentStatus(in []gwv1.RouteParentStatus, p gwv1.RouteParentStatus) []gwv1.RouteParentStatus { + for i, x := range in { + if x.ControllerName == p.ControllerName && x.ParentRef.Name == p.ParentRef.Name { + in[i] = p; return in + } + } + return append(in, p) +} +``` + +- [ ] **Step 3: Verify build** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add internal/usecase/gateway_uc/alb_gateway_uc/build_sec_group.go internal/usecase/gateway_uc/alb_gateway_uc/status.go +git commit -m "feat(gateway/uc/alb): NSG inheritance + status writers" +``` + +--- + +### Task F8: `listAttachedHTTPRoutes` + +**Files:** +- Create: `internal/usecase/gateway_uc/alb_gateway_uc/list_routes.go` + +- [ ] **Step 1: Write the file** + +```go +package alb_gateway_uc + +import ( + "context" + + "k8s.io/apimachinery/pkg/fields" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// listAttachedHTTPRoutes returns every HTTPRoute with a parentRef to gw and at least one +// listener whose hostname / kind / namespace permits the route. +func (u *ALBGatewayUseCase) listAttachedHTTPRoutes(ctx context.Context, gw *gwv1.Gateway) ([]gwv1.HTTPRoute, error) { + var list gwv1.HTTPRouteList + sel := fields.OneTermEqualSelector(shared.IndexHTTPRouteByParentGateway, gw.Namespace+"/"+gw.Name) + if err := u.K8s.Client.List(ctx, &list, client.MatchingFieldsSelector{Selector: sel}); err != nil { + return nil, err + } + out := make([]gwv1.HTTPRoute, 0, len(list.Items)) + for _, r := range list.Items { + if !routeAttachableToGateway(&r, gw) { continue } + out = append(out, r) + } + return out, nil +} + +// routeAttachableToGateway encodes Gateway listener `allowedRoutes` rules. +func routeAttachableToGateway(route *gwv1.HTTPRoute, gw *gwv1.Gateway) bool { + for _, l := range gw.Spec.Listeners { + if l.Protocol != gwv1.HTTPProtocolType && l.Protocol != gwv1.HTTPSProtocolType { continue } + if l.AllowedRoutes != nil { + ns := l.AllowedRoutes.Namespaces + if ns != nil && ns.From != nil { + switch *ns.From { + case gwv1.NamespacesFromAll: // ok + case gwv1.NamespacesFromSame: + if route.Namespace != gw.Namespace { return false } + case gwv1.NamespacesFromSelector: + // selector matching deferred to Phase 3; treat as allow. + } + } + } + if l.Hostname != nil && string(*l.Hostname) != "" { + ok := false + for _, h := range route.Spec.Hostnames { + if pkggw.HostnameMatches(string(*l.Hostname), string(h)) { ok = true; break } + } + if !ok && len(route.Spec.Hostnames) > 0 { continue } + } + return true + } + return false +} +``` + +- [ ] **Step 2: Verify build** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/usecase/gateway_uc/alb_gateway_uc/list_routes.go +git commit -m "feat(gateway/uc/alb): list HTTPRoutes attachable to a Gateway" +``` + +--- + +## Section G — Reconcilers + +### Task G1: GatewayClass reconciler + +**Files:** +- Create: `internal/controller/gateway/alb/gatewayclass_controller.go` + +- [ ] **Step 1: Write the reconciler** + +```go +package alb + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +type GatewayClassReconciler struct { + Client client.Client +} + +func NewGatewayClassReconciler(c client.Client) *GatewayClassReconciler { + return &GatewayClassReconciler{Client: c} +} + +func (r *GatewayClassReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + var gwc gwv1.GatewayClass + if err := r.Client.Get(ctx, req.NamespacedName, &gwc); err != nil { + return reconcile.Result{}, client.IgnoreNotFound(err) + } + if string(gwc.Spec.ControllerName) != consts.GatewayClassControllerNameALB { + return reconcile.Result{}, nil + } + cur := gwc.DeepCopy() + shared.SetCondition(&cur.Status.Conditions, metav1.Condition{ + Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted", + ObservedGeneration: gwc.Generation, + }) + shared.SetCondition(&cur.Status.Conditions, metav1.Condition{ + Type: "SupportedVersion", Status: metav1.ConditionTrue, Reason: "SupportedVersion", + ObservedGeneration: gwc.Generation, + }) + return reconcile.Result{}, r.Client.Status().Patch(ctx, cur, client.MergeFrom(&gwc)) +} + +func (r *GatewayClassReconciler) SetupWithManager(mgr manager.Manager) error { + return builder.ControllerManagedBy(mgr). + For(&gwv1.GatewayClass{}). + Named("gateway-class-alb"). + Complete(r) +} +``` + +- [ ] **Step 2: Verify build** + +```bash +go build ./internal/controller/gateway/alb/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/gateway/alb/gatewayclass_controller.go +git commit -m "feat(gateway/ctrl/alb): GatewayClass reconciler" +``` + +--- + +### Task G2: Gateway reconciler + +**Files:** +- Create: `internal/controller/gateway/alb/gateway_controller.go` + +- [ ] **Step 1: Write the reconciler** + +```go +package alb + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + discoveryv1 "k8s.io/api/discovery/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared/eventhandlers" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/alb_gateway_uc" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +type GatewayReconciler struct { + Client client.Client + UC *alb_gateway_uc.ALBGatewayUseCase +} + +func NewGatewayReconciler(c client.Client, uc *alb_gateway_uc.ALBGatewayUseCase) *GatewayReconciler { + return &GatewayReconciler{Client: c, UC: uc} +} + +func (r *GatewayReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + if !r.UC.InitDone.Load() { + return reconcile.Result{RequeueAfter: 1e9}, nil // 1s retry + } + var gw gwv1.Gateway + if err := r.Client.Get(ctx, req.NamespacedName, &gw); err != nil { + return reconcile.Result{}, client.IgnoreNotFound(err) + } + if !r.isOurClass(ctx, &gw) { + return reconcile.Result{}, nil + } + + if !gw.DeletionTimestamp.IsZero() { + if err := r.UC.Delete(ctx, &gw); err != nil { return reconcile.Result{}, err } + if shared.RemoveFinalizer(&gw, domain.GatewayFinalizer) { + return reconcile.Result{}, r.Client.Update(ctx, &gw) + } + return reconcile.Result{}, nil + } + + if err := shared.EnsureFinalizer(ctx, r.Client, &gw, domain.GatewayFinalizer); err != nil { + return reconcile.Result{}, err + } + + res, err := r.UC.Ensure(ctx, &gw) + if statusErr := r.UC.WriteGatewayStatus(ctx, &gw, &res, err); statusErr != nil && err == nil { + return reconcile.Result{}, statusErr + } + return reconcile.Result{}, err +} + +func (r *GatewayReconciler) isOurClass(ctx context.Context, gw *gwv1.Gateway) bool { + var gwc gwv1.GatewayClass + if err := r.Client.Get(ctx, client.ObjectKey{Name: string(gw.Spec.GatewayClassName)}, &gwc); err != nil { + return false + } + return string(gwc.Spec.ControllerName) == consts.GatewayClassControllerNameALB +} + +func (r *GatewayReconciler) SetupWithManager(mgr manager.Manager) error { + return builder.ControllerManagedBy(mgr). + For(&gwv1.Gateway{}). + Named("gateway-alb"). + Watches(&gwv1.HTTPRoute{}, eventhandlers.RouteToGateway()). + Watches(&vksv1.VKSGatewayPolicy{}, eventhandlers.VKSGatewayPolicyToGateway()). + WatchesRawSource(source.Kind(mgr.GetCache(), &corev1.Service{}, eventhandlers.ServiceToRouteParents(mgr.GetClient()))). + WatchesRawSource(source.Kind(mgr.GetCache(), &discoveryv1.EndpointSlice{}, eventhandlers.ServiceToRouteParents(mgr.GetClient()))). + Complete(r) +} +``` + +> Additional `Watches` for `VKSBackendPolicy` / `VKSHealthCheckPolicy` / `VKSRoutePolicy` / `Secret` / `ReferenceGrant` follow the same pattern; add them in the same `SetupWithManager` chain. Keep the body of each event handler symmetric. + +- [ ] **Step 2: Verify build** + +```bash +go build ./internal/controller/gateway/alb/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/gateway/alb/gateway_controller.go +git commit -m "feat(gateway/ctrl/alb): Gateway reconciler" +``` + +--- + +### Task G3: HTTPRoute reconciler + +**Files:** +- Create: `internal/controller/gateway/alb/httproute_controller.go` + +> The HTTPRoute reconciler does **not** mutate the LB. Its only jobs are: (1) write per-parent status (Accepted / ResolvedRefs), (2) ensure finalizer, (3) enqueue parent Gateway for re-reconcile. + +- [ ] **Step 1: Write the reconciler** + +```go +package alb + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/alb_gateway_uc" +) + +type HTTPRouteReconciler struct { + Client client.Client + UC *alb_gateway_uc.ALBGatewayUseCase +} + +func NewHTTPRouteReconciler(c client.Client, uc *alb_gateway_uc.ALBGatewayUseCase) *HTTPRouteReconciler { + return &HTTPRouteReconciler{Client: c, UC: uc} +} + +func (r *HTTPRouteReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + var route gwv1.HTTPRoute + if err := r.Client.Get(ctx, req.NamespacedName, &route); err != nil { + return reconcile.Result{}, client.IgnoreNotFound(err) + } + + // Ensure finalizer; remove on deletion. + if !route.DeletionTimestamp.IsZero() { + if shared.RemoveFinalizer(&route, domain.HTTPRouteFinalizer) { + return reconcile.Result{}, r.Client.Update(ctx, &route) + } + return reconcile.Result{}, nil + } + if err := shared.EnsureFinalizer(ctx, r.Client, &route, domain.HTTPRouteFinalizer); err != nil { + return reconcile.Result{}, err + } + + // Status writes happen during Gateway reconcile, which is the source of truth. + // The HTTPRoute reconciler only touches finalizer + lets parent enqueue handle the rest. + return reconcile.Result{}, nil +} + +func (r *HTTPRouteReconciler) SetupWithManager(mgr manager.Manager) error { + return builder.ControllerManagedBy(mgr). + For(&gwv1.HTTPRoute{}). + Named("httproute-alb"). + Complete(r) +} +``` + +- [ ] **Step 2: Verify build** + +```bash +go build ./internal/controller/gateway/alb/... +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/gateway/alb/httproute_controller.go +git commit -m "feat(gateway/ctrl/alb): HTTPRoute reconciler (finalizer-only)" +``` + +--- + +### Task G4: Policy validators (4 controllers) + +**Files:** +- Create: `internal/controller/gateway/policies/common.go` +- Create: `internal/controller/gateway/policies/vksgatewaypolicy_controller.go` +- Create: `internal/controller/gateway/policies/vksbackendpolicy_controller.go` +- Create: `internal/controller/gateway/policies/vkshealthcheckpolicy_controller.go` +- Create: `internal/controller/gateway/policies/vksroutepolicy_controller.go` + +> Each validator scans namespace-local policies of its kind, runs `ResolveDirectPolicy` per unique target, and writes `Accepted=True` to the winner and `Conflicted=True, Accepted=False` to losers. They never write to the LB. + +- [ ] **Step 1: Write `common.go` (shared list-and-set helper)** + +```go +package policies + +import ( + "context" + "sort" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" +) + +type policyItem[P sharedUC.PolicyObj] struct { + Obj P + Target pkggw.PolicyTarget +} + +// markConflicts iterates a list of policies grouped by target, picks oldest as winner, +// writes Accepted=True (winner) / Conflicted=True (losers). +func markConflicts[P sharedUC.PolicyObj]( + ctx context.Context, + c client.Client, + items []policyItem[P], + setStatus func(P, []metav1.Condition) error, +) error { + byTarget := map[pkggw.PolicyTarget][]P{} + for _, it := range items { + byTarget[it.Target] = append(byTarget[it.Target], it.Obj) + } + for target, group := range byTarget { + win, losers := sharedUC.ResolveDirectPolicy(group, target) + var pZero P + if any(win) == any(pZero) { continue } + + if err := setStatus(win, []metav1.Condition{{ + Type: shared.AcceptedConditionType(), Status: metav1.ConditionTrue, + Reason: "Accepted", LastTransitionTime: metav1.Now(), + }}); err != nil { return err } + + for _, l := range losers { + if err := setStatus(l, []metav1.Condition{{ + Type: shared.AcceptedConditionType(), Status: metav1.ConditionFalse, + Reason: "Conflicted", + Message: "another policy of the same kind is older and wins this target", + LastTransitionTime: metav1.Now(), + }}); err != nil { return err } + } + } + // Stabilize order for tests. + sort.SliceStable(items, func(i,j int) bool { return items[i].Obj.GetName() < items[j].Obj.GetName() }) + return nil +} + +// any() helper for typed-zero comparison without importing reflect. +func any[T any](v T) interface{} { return v } +``` + +> If the typed-zero check trips up the compiler in your Go version, replace it with a small reflection check or pass an `isZero func(P) bool` argument. + +> `shared.AcceptedConditionType()` returns the constant `"Accepted"`. Add a one-line export in `internal/controller/gateway/shared/status.go`: +> +> ```go +> func AcceptedConditionType() string { return "Accepted" } +> ``` + +- [ ] **Step 2: Write `vksgatewaypolicy_controller.go`** + +```go +package policies + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +type VKSGatewayPolicyReconciler struct{ Client client.Client } + +func NewVKSGatewayPolicyReconciler(c client.Client) *VKSGatewayPolicyReconciler { return &VKSGatewayPolicyReconciler{Client: c} } + +func (r *VKSGatewayPolicyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + var list vksv1.VKSGatewayPolicyList + if err := r.Client.List(ctx, &list, client.InNamespace(req.Namespace)); err != nil { + return reconcile.Result{}, err + } + + items := make([]policyItem[*vksv1.VKSGatewayPolicy], 0) + for i := range list.Items { + p := &list.Items[i] + for _, t := range p.Spec.TargetRefs { + section := "" + if t.SectionName != nil { section = string(*t.SectionName) } + items = append(items, policyItem[*vksv1.VKSGatewayPolicy]{ + Obj: p, + Target: pkggw.PolicyTarget{ + Group: string(t.Group), Kind: string(t.Kind), + Namespace: p.Namespace, Name: string(t.Name), SectionName: section, + }, + }) + } + } + set := func(p *vksv1.VKSGatewayPolicy, conds []metav1.Condition) error { + cur := p.DeepCopy() + cur.Status.Conditions = conds + cur.Status.ObservedGeneration = p.Generation + return r.Client.Status().Patch(ctx, cur, client.MergeFrom(p)) + } + return reconcile.Result{}, markConflicts(ctx, r.Client, items, set) +} + +func (r *VKSGatewayPolicyReconciler) SetupWithManager(mgr manager.Manager) error { + return builder.ControllerManagedBy(mgr). + For(&vksv1.VKSGatewayPolicy{}). + Named("vksgatewaypolicy-validator"). + Complete(r) +} +``` + +- [ ] **Step 3: Write `vksbackendpolicy_controller.go`** + +Same structure as G4-Step-2, but replace types with `VKSBackendPolicy`/`VKSBackendPolicyList`, no `SectionName`: + +```go +package policies + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +type VKSBackendPolicyReconciler struct{ Client client.Client } + +func NewVKSBackendPolicyReconciler(c client.Client) *VKSBackendPolicyReconciler { return &VKSBackendPolicyReconciler{Client: c} } + +func (r *VKSBackendPolicyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + var list vksv1.VKSBackendPolicyList + if err := r.Client.List(ctx, &list, client.InNamespace(req.Namespace)); err != nil { + return reconcile.Result{}, err + } + items := make([]policyItem[*vksv1.VKSBackendPolicy], 0) + for i := range list.Items { + p := &list.Items[i] + for _, t := range p.Spec.TargetRefs { + items = append(items, policyItem[*vksv1.VKSBackendPolicy]{ + Obj: p, + Target: pkggw.PolicyTarget{Group: string(t.Group), Kind: string(t.Kind), + Namespace: p.Namespace, Name: string(t.Name)}, + }) + } + } + set := func(p *vksv1.VKSBackendPolicy, conds []metav1.Condition) error { + cur := p.DeepCopy() + cur.Status.Conditions = conds + cur.Status.ObservedGeneration = p.Generation + return r.Client.Status().Patch(ctx, cur, client.MergeFrom(p)) + } + return reconcile.Result{}, markConflicts(ctx, r.Client, items, set) +} + +func (r *VKSBackendPolicyReconciler) SetupWithManager(mgr manager.Manager) error { + return builder.ControllerManagedBy(mgr). + For(&vksv1.VKSBackendPolicy{}). + Named("vksbackendpolicy-validator"). + Complete(r) +} +``` + +- [ ] **Step 4: Write `vkshealthcheckpolicy_controller.go`** + +```go +package policies + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +type VKSHealthCheckPolicyReconciler struct{ Client client.Client } + +func NewVKSHealthCheckPolicyReconciler(c client.Client) *VKSHealthCheckPolicyReconciler { return &VKSHealthCheckPolicyReconciler{Client: c} } + +func (r *VKSHealthCheckPolicyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + var list vksv1.VKSHealthCheckPolicyList + if err := r.Client.List(ctx, &list, client.InNamespace(req.Namespace)); err != nil { + return reconcile.Result{}, err + } + items := make([]policyItem[*vksv1.VKSHealthCheckPolicy], 0) + for i := range list.Items { + p := &list.Items[i] + for _, t := range p.Spec.TargetRefs { + items = append(items, policyItem[*vksv1.VKSHealthCheckPolicy]{ + Obj: p, + Target: pkggw.PolicyTarget{Group: string(t.Group), Kind: string(t.Kind), + Namespace: p.Namespace, Name: string(t.Name)}, + }) + } + } + set := func(p *vksv1.VKSHealthCheckPolicy, conds []metav1.Condition) error { + cur := p.DeepCopy() + cur.Status.Conditions = conds + cur.Status.ObservedGeneration = p.Generation + return r.Client.Status().Patch(ctx, cur, client.MergeFrom(p)) + } + return reconcile.Result{}, markConflicts(ctx, r.Client, items, set) +} + +func (r *VKSHealthCheckPolicyReconciler) SetupWithManager(mgr manager.Manager) error { + return builder.ControllerManagedBy(mgr). + For(&vksv1.VKSHealthCheckPolicy{}). + Named("vkshealthcheckpolicy-validator"). + Complete(r) +} +``` + +- [ ] **Step 5: Write `vksroutepolicy_controller.go`** + +```go +package policies + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +type VKSRoutePolicyReconciler struct{ Client client.Client } + +func NewVKSRoutePolicyReconciler(c client.Client) *VKSRoutePolicyReconciler { return &VKSRoutePolicyReconciler{Client: c} } + +func (r *VKSRoutePolicyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + var list vksv1.VKSRoutePolicyList + if err := r.Client.List(ctx, &list, client.InNamespace(req.Namespace)); err != nil { + return reconcile.Result{}, err + } + items := make([]policyItem[*vksv1.VKSRoutePolicy], 0) + for i := range list.Items { + p := &list.Items[i] + for _, t := range p.Spec.TargetRefs { + section := "" + if t.SectionName != nil { section = string(*t.SectionName) } + items = append(items, policyItem[*vksv1.VKSRoutePolicy]{ + Obj: p, + Target: pkggw.PolicyTarget{ + Group: string(t.Group), Kind: string(t.Kind), + Namespace: p.Namespace, Name: string(t.Name), SectionName: section, + }, + }) + } + } + set := func(p *vksv1.VKSRoutePolicy, conds []metav1.Condition) error { + cur := p.DeepCopy() + cur.Status.Conditions = conds + cur.Status.ObservedGeneration = p.Generation + return r.Client.Status().Patch(ctx, cur, client.MergeFrom(p)) + } + return reconcile.Result{}, markConflicts(ctx, r.Client, items, set) +} + +func (r *VKSRoutePolicyReconciler) SetupWithManager(mgr manager.Manager) error { + return builder.ControllerManagedBy(mgr). + For(&vksv1.VKSRoutePolicy{}). + Named("vksroutepolicy-validator"). + Complete(r) +} +``` + +- [ ] **Step 6: Add the `AllReconcilers` helper** + +Create: `internal/controller/gateway/policies/all.go` + +```go +package policies + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +type Reconciler interface { + SetupWithManager(mgr manager.Manager) error +} + +// AllReconcilers returns all four policy validators wired with the same client. +func AllReconcilers(c client.Client) []Reconciler { + return []Reconciler{ + NewVKSGatewayPolicyReconciler(c), + NewVKSBackendPolicyReconciler(c), + NewVKSHealthCheckPolicyReconciler(c), + NewVKSRoutePolicyReconciler(c), + } +} +``` + +- [ ] **Step 7: Verify build** + +```bash +go build ./internal/controller/gateway/policies/... +``` + +Expected: success. + +- [ ] **Step 8: Commit** + +```bash +git add internal/controller/gateway/policies/ +git commit -m "feat(gateway/ctrl): policy validator reconcilers (4 CRDs)" +``` + +--- + +## Section H — Manager wiring, RBAC, Helm + +### Task H1: Wire schemes, flags, and reconcilers in `cmd/main.go` + +**Files:** +- Modify: `cmd/main.go` + +- [ ] **Step 1: Add scheme registrations** + +Find the existing `init()` block (or the top of `main()` where schemes are added) and append: + +```go +import ( + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + vksgwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +// In init(), alongside the existing utilruntime.Must(...) lines: +utilruntime.Must(gwv1.Install(scheme)) +utilruntime.Must(gwv1alpha2.Install(scheme)) +utilruntime.Must(gwv1beta1.Install(scheme)) +utilruntime.Must(vksgwv1alpha1.AddToScheme(scheme)) +``` + +- [ ] **Step 2: Add feature-gate flags** + +In `main()`, near the existing `flag.BoolVar` calls: + +```go +var enableALBGateway, enableNLBGateway bool +flag.BoolVar(&enableALBGateway, "enable-gateway-api-alb", false, + "Enable the ALB Gateway API controller (Phase 1).") +flag.BoolVar(&enableNLBGateway, "enable-gateway-api-nlb", false, + "Enable the NLB Gateway API controller (Phase 2+).") +``` + +- [ ] **Step 3: Register indexes and reconcilers (gated)** + +After the manager is constructed and the existing reconcilers are set up, add: + +```go +import ( + sharedctrl "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + albctrl "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/alb" + polctrl "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/policies" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/alb_gateway_uc" +) + +if enableALBGateway { + if err := sharedctrl.RegisterIndexes(ctx, mgr); err != nil { + setupLog.Error(err, "register gateway indexes") + os.Exit(1) + } + albUC := alb_gateway_uc.NewALBGatewayUseCase(k8sRepo, vngRepo, ctrl.Log.WithName("alb-gateway-uc")) + if err := albUC.Init(ctx); err != nil { + setupLog.Error(err, "init alb gateway uc"); os.Exit(1) + } + + if err := albctrl.NewGatewayClassReconciler(mgr.GetClient()).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "setup GatewayClass reconciler"); os.Exit(1) + } + if err := albctrl.NewGatewayReconciler(mgr.GetClient(), albUC).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "setup Gateway reconciler"); os.Exit(1) + } + if err := albctrl.NewHTTPRouteReconciler(mgr.GetClient(), albUC).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "setup HTTPRoute reconciler"); os.Exit(1) + } + for _, r := range polctrl.AllReconcilers(mgr.GetClient()) { + if err := r.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "setup policy validator"); os.Exit(1) + } + } +} +``` + +> Symbol names (`k8sRepo`, `vngRepo`, `setupLog`, `ctrl`, `os.Exit`) match this branch's conventions; if any differs, follow the surrounding code. + +- [ ] **Step 4: Verify build** + +```bash +go build ./... +``` + +Expected: success. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/main.go +git commit -m "feat(cmd): wire ALB Gateway API controllers behind --enable-gateway-api-alb" +``` + +--- + +### Task H2: RBAC kubebuilder markers + regenerate `role.yaml` + +**Files:** +- Modify: `internal/controller/gateway/alb/gateway_controller.go` (add markers above `Reconcile`) +- Modify: `config/rbac/role.yaml` (regenerated) + +- [ ] **Step 1: Add RBAC kubebuilder markers** + +Above the `Reconcile` of `GatewayReconciler`, prepend the comment block: + +```go +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses/status,verbs=update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/status,verbs=update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/finalizers,verbs=update +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes;referencegrants,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/status,verbs=update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/finalizers,verbs=update +// +kubebuilder:rbac:groups=gateway.vks.vngcloud.vn,resources=vksgatewaypolicies;vksbackendpolicies;vkshealthcheckpolicies;vksroutepolicies,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=gateway.vks.vngcloud.vn,resources=vksgatewaypolicies/status;vksbackendpolicies/status;vkshealthcheckpolicies/status;vksroutepolicies/status,verbs=update;patch +// +kubebuilder:rbac:groups="",resources=services;secrets;configmaps,verbs=get;list;watch +// +kubebuilder:rbac:groups=discovery.k8s.io,resources=endpointslices,verbs=get;list;watch +``` + +- [ ] **Step 2: Regenerate manifests** + +```bash +make manifests +``` + +Expected: `config/rbac/role.yaml` updated; no other unrelated diffs (per CLAUDE.md: scan `git status`, revert any worktree-induced strays). + +- [ ] **Step 3: Verify the embedded CRD set is in sync** + +```bash +make sync-embedded-crds +git status +``` + +Expected: `pkg/k8s/apis/vks.vngcloud.vn/crds/` matches `config/crd/bases/`. + +- [ ] **Step 4: Commit** + +```bash +git add internal/controller/gateway/alb/gateway_controller.go \ + config/rbac/role.yaml \ + pkg/k8s/apis/vks.vngcloud.vn/crds/ +git commit -m "feat(gateway): RBAC markers + regenerated role.yaml + embedded CRD sync" +``` + +--- + +### Task H3: Helm chart — bundle CRDs and gated GatewayClass + +**Files:** +- Create: `charts/vngcloud-load-balancer-controller/templates/crds/vksgatewaypolicy.yaml` (copy from `config/crd/bases/`) +- Create: `charts/vngcloud-load-balancer-controller/templates/crds/vksbackendpolicy.yaml` +- Create: `charts/vngcloud-load-balancer-controller/templates/crds/vkshealthcheckpolicy.yaml` +- Create: `charts/vngcloud-load-balancer-controller/templates/crds/vksroutepolicy.yaml` +- Create: `charts/vngcloud-load-balancer-controller/templates/gatewayclass-alb.yaml` +- Modify: `charts/vngcloud-load-balancer-controller/values.yaml` +- Modify: `charts/vngcloud-load-balancer-controller/templates/manager-deployment.yaml` + +- [ ] **Step 1: Copy CRDs into the chart** + +Run: + +```bash +for crd in vksgatewaypolicies vksbackendpolicies vkshealthcheckpolicies vksroutepolicies; do + cp config/crd/bases/gateway.vks.vngcloud.vn_${crd}.yaml \ + charts/vngcloud-load-balancer-controller/templates/crds/${crd}.yaml +done +``` + +- [ ] **Step 2: Write `gatewayclass-alb.yaml`** + +```yaml +{{- if .Values.gatewayApi.alb.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: vngcloud-alb + labels: + {{- include "vngcloud-load-balancer-controller.labels" . | nindent 4 }} +spec: + controllerName: gateway.vks.vngcloud.vn/alb + description: "vngcloud ALB-backed GatewayClass (Phase 1, GEP-713 policies)." +{{- end }} +``` + +- [ ] **Step 3: Add to `values.yaml`** + +```yaml +gatewayApi: + alb: + enabled: false + nlb: + enabled: false +``` + +- [ ] **Step 4: Add the gate flag to `manager-deployment.yaml`** + +In the controller args block: + +```yaml + {{- if .Values.gatewayApi.alb.enabled }} + - --enable-gateway-api-alb + {{- end }} + {{- if .Values.gatewayApi.nlb.enabled }} + - --enable-gateway-api-nlb + {{- end }} +``` + +- [ ] **Step 5: Verify `helm lint` passes** + +```bash +helm lint charts/vngcloud-load-balancer-controller +``` + +Expected: success. + +- [ ] **Step 6: Commit** + +```bash +git add charts/vngcloud-load-balancer-controller/ +git commit -m "feat(chart): bundle Gateway-API CRDs and gated vngcloud-alb GatewayClass" +``` + +--- + +## Section I — Tests + +### Task I1: envtest suite scaffold for the ALB controllers + +**Files:** +- Create: `internal/controller/gateway/alb/suite_test.go` +- Create: `internal/controller/gateway/alb/helpers_test.go` + +> Mirrors `internal/controller/networking/suite_test.go`. Runs once per package; spins up envtest, registers Gateway-API CRDs and the four VKS CRDs, instantiates a fake `VngCloudRepository`, and starts the manager. + +- [ ] **Step 1: Write `suite_test.go`** + +```go +package alb_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + sharedctrl "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + albctrl "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/alb" + polctrl "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/policies" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/alb_gateway_uc" +) + +var ( + cfg *envtest.Environment + k8sClient client.Client + scheme = runtime.NewScheme() + ctx context.Context + cancel context.CancelFunc +) + +func TestALBController(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ALB Gateway Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.UseDevMode(true))) + ctx, cancel = context.WithCancel(context.Background()) + + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(gwv1.Install(scheme)) + utilruntime.Must(gwv1alpha2.Install(scheme)) + utilruntime.Must(gwv1beta1.Install(scheme)) + utilruntime.Must(vksv1.AddToScheme(scheme)) + + cfg = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "..", "config", "crd", "bases"), + // gateway-api CRDs from go-mod cache + }, + ErrorIfCRDPathMissing: true, + } + restCfg, err := cfg.Start() + Expect(err).NotTo(HaveOccurred()) + + k8sClient, err = client.New(restCfg, client.Options{Scheme: scheme}) + Expect(err).NotTo(HaveOccurred()) + + mgr, err := ctrl.NewManager(restCfg, ctrl.Options{Scheme: scheme, Metrics: nil}) + Expect(err).NotTo(HaveOccurred()) + + Expect(sharedctrl.RegisterIndexes(ctx, mgr)).To(Succeed()) + fakeVng := newFakeVngRepo() + fakeK8s := newK8sRepo(mgr.GetClient()) + uc := alb_gateway_uc.NewALBGatewayUseCase(fakeK8s, fakeVng, ctrl.Log.WithName("test-uc")) + Expect(uc.Init(ctx)).To(Succeed()) + + Expect(albctrl.NewGatewayClassReconciler(mgr.GetClient()).SetupWithManager(mgr)).To(Succeed()) + Expect(albctrl.NewGatewayReconciler(mgr.GetClient(), uc).SetupWithManager(mgr)).To(Succeed()) + Expect(albctrl.NewHTTPRouteReconciler(mgr.GetClient(), uc).SetupWithManager(mgr)).To(Succeed()) + for _, r := range polctrl.AllReconcilers(mgr.GetClient()) { + Expect(r.SetupWithManager(mgr)).To(Succeed()) + } + + go func() { + defer GinkgoRecover() + Expect(mgr.Start(ctx)).To(Succeed()) + }() + + Eventually(func() error { + return k8sClient.Create(ctx, &corev1.Namespace{}) + }, 10*time.Second).Should(HaveOccurred()) // smoke-test API server reachable +}) + +var _ = AfterSuite(func() { + cancel() + Expect(cfg.Stop()).To(Succeed()) +}) +``` + +- [ ] **Step 2: Write `helpers_test.go`** + +```go +package alb_test + +import ( + "context" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/k8s_repo" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/vngcloud_repo" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// newFakeVngRepo returns a VngCloudRepository whose methods are all stubs that +// record calls. Use the existing `vngcloud_mocks` package or write a minimal +// in-memory fake. Phase 1 uses an in-memory fake to keep envtest hermetic. +func newFakeVngRepo() *vngcloud_repo.VngCloudRepository { + return vngcloud_repo.NewFake() // implement NewFake() in vngcloud_mocks if missing +} + +func newK8sRepo(c client.Client) *k8s_repo.K8sRepository { + return &k8s_repo.K8sRepository{Client: c, Reader: c, Writer: c} +} +``` + +> Adapt to whatever fake/mock the existing tests use. The point is to make `Vng` calls hermetic so the envtest only verifies controller-runtime wiring and status writes. + +- [ ] **Step 3: Run an empty suite to verify scaffolding** + +```bash +go test -p=1 -count=1 ./internal/controller/gateway/alb/... +``` + +Expected: PASS (no specs yet, but Suite setup completes). + +- [ ] **Step 4: Commit** + +```bash +git add internal/controller/gateway/alb/suite_test.go internal/controller/gateway/alb/helpers_test.go +git commit -m "test(gateway/ctrl/alb): envtest suite scaffold" +``` + +--- + +### Task I2: GatewayClass acceptance spec + +**Files:** +- Create: `internal/controller/gateway/alb/gatewayclass_test.go` + +- [ ] **Step 1: Write the spec** + +```go +package alb_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var _ = Describe("GatewayClass reconciler", func() { + It("marks Accepted=True when controllerName matches", func() { + gwc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-alb"}, + Spec: gwv1.GatewayClassSpec{ControllerName: "gateway.vks.vngcloud.vn/alb"}, + } + Expect(k8sClient.Create(context.Background(), gwc)).To(Succeed()) + Eventually(func(g Gomega) { + var got gwv1.GatewayClass + g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: "vngcloud-alb"}, &got)).To(Succeed()) + cond := findCondition(got.Status.Conditions, "Accepted") + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }, 10*time.Second, 200*time.Millisecond).Should(Succeed()) + }) + + It("does not touch GatewayClasses for other controllers", func() { + gwc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "other-class"}, + Spec: gwv1.GatewayClassSpec{ControllerName: "example.com/other"}, + } + Expect(k8sClient.Create(context.Background(), gwc)).To(Succeed()) + Consistently(func(g Gomega) { + var got gwv1.GatewayClass + g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: "other-class"}, &got)).To(Succeed()) + g.Expect(findCondition(got.Status.Conditions, "Accepted")).To(BeNil()) + }, 2*time.Second, 200*time.Millisecond).Should(Succeed()) + }) +}) + +func findCondition(cs []metav1.Condition, t string) *metav1.Condition { + for i := range cs { + if cs[i].Type == t { return &cs[i] } + } + return nil +} +``` + +- [ ] **Step 2: Run** + +```bash +go test -p=1 -count=1 ./internal/controller/gateway/alb/... -ginkgo.focus="GatewayClass reconciler" +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/gateway/alb/gatewayclass_test.go +git commit -m "test(gateway/ctrl/alb): GatewayClass acceptance spec" +``` + +--- + +### Task I3: Gateway end-to-end (single listener) spec + +**Files:** +- Create: `internal/controller/gateway/alb/gateway_test.go` + +- [ ] **Step 1: Write the spec** + +```go +package alb_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +var _ = Describe("Gateway end-to-end", func() { + var ns string + + BeforeEach(func() { + ns = createNamespace() + gwc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-alb-e2e"}, + Spec: gwv1.GatewayClassSpec{ControllerName: "gateway.vks.vngcloud.vn/alb"}, + } + _ = k8sClient.Create(context.Background(), gwc) + }) + + It("provisions an LB and writes Programmed=True", func() { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gw1", Namespace: ns}, + Spec: gwv1.GatewaySpec{ + GatewayClassName: "vngcloud-alb-e2e", + Listeners: []gwv1.Listener{{ + Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80, + }}, + }, + } + Expect(k8sClient.Create(context.Background(), gw)).To(Succeed()) + + Eventually(func(g Gomega) { + var got gwv1.Gateway + g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: "gw1"}, &got)).To(Succeed()) + g.Expect(findCondition(got.Status.Conditions, "Programmed")).NotTo(BeNil()) + g.Expect(findCondition(got.Status.Conditions, "Programmed").Status).To(Equal(metav1.ConditionTrue)) + }, 15*time.Second, 250*time.Millisecond).Should(Succeed()) + }) +}) + +func createNamespace() string { + ns := "test-" + randHex(6) + Expect(k8sClient.Create(context.Background(), &corev1Ns(ns))).To(Succeed()) + return ns +} + +// randHex / corev1Ns are tiny helpers; place at the bottom of suite_test.go. +``` + +> Add `randHex` and `corev1Ns` to `helpers_test.go` if they aren't already there. + +- [ ] **Step 2: Run** + +```bash +go test -p=1 -count=1 ./internal/controller/gateway/alb/... -ginkgo.focus="Gateway end-to-end" +``` + +Expected: PASS (with the in-memory fake LB returning a stub address). + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/gateway/alb/gateway_test.go internal/controller/gateway/alb/helpers_test.go +git commit -m "test(gateway/ctrl/alb): Gateway end-to-end spec" +``` + +--- + +### Task I4: VKSGatewayPolicy validator conflict spec + +**Files:** +- Create: `internal/controller/gateway/policies/vksgatewaypolicy_test.go` +- Create: `internal/controller/gateway/policies/suite_test.go` (lightweight, similar to alb's suite) + +- [ ] **Step 1: Write the suite scaffold (mirroring `alb_test`'s suite, but only registering policy reconcilers)** + +> Reuse the same envtest pattern from Task I1. Register only `polctrl.AllReconcilers(...)`. No use-case wiring needed. + +- [ ] **Step 2: Write the conflict spec** + +```go +package policies_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +var _ = Describe("VKSGatewayPolicy validator", func() { + var ns string + BeforeEach(func() { ns = createNamespace() }) + + It("marks the second policy as Conflicted", func() { + ctx := context.Background() + first := &vksv1.VKSGatewayPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "first", Namespace: ns, + CreationTimestamp: metav1.NewTime(time.Now().Add(-1 * time.Hour))}, + Spec: vksv1.VKSGatewayPolicySpec{TargetRefs: []vksv1.LocalPolicyTargetReferenceWithSectionName{{ + LocalPolicyTargetReference: gwv1alpha2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: "gw1", + }, + }}}, + } + second := first.DeepCopy() + second.Name = "second" + second.CreationTimestamp = metav1.NewTime(time.Now()) + + Expect(k8sClient.Create(ctx, first)).To(Succeed()) + Expect(k8sClient.Create(ctx, second)).To(Succeed()) + + Eventually(func(g Gomega) { + var got vksv1.VKSGatewayPolicy + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: "first"}, &got)).To(Succeed()) + cond := findCondition(got.Status.Conditions, "Accepted") + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: "second"}, &got)).To(Succeed()) + cond = findCondition(got.Status.Conditions, "Accepted") + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal("Conflicted")) + }, 10*time.Second, 250*time.Millisecond).Should(Succeed()) + }) +}) +``` + +- [ ] **Step 3: Run** + +```bash +go test -p=1 -count=1 ./internal/controller/gateway/policies/... +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/controller/gateway/policies/ +git commit -m "test(gateway/policies): VKSGatewayPolicy conflict spec" +``` + +--- + +### Task I5: E2E manifests + smoke script + +**Files:** +- Create: `test/e2e/gateway/01-basic-http.yaml` +- Create: `test/e2e/gateway/02-https-sni.yaml` +- Create: `test/e2e/gateway/03-mtls.yaml` +- Create: `test/e2e/gateway/04-weighted-canary.yaml` +- Create: `test/e2e/gateway/05-cross-ns-refgrant.yaml` +- Create: `test/e2e/gateway/06-route-policy-overlay.yaml` +- Create: `test/e2e/gateway/run.sh` +- Modify: `Makefile` (add `e2e-gateway`) + +> E2E run against a real cluster with `gatewayApi.alb.enabled=true`. Each YAML stands alone and is asserted by `run.sh` using `kubectl wait` + `curl` smoke checks. + +- [ ] **Step 1: Write `01-basic-http.yaml`** + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: { name: gw-basic, namespace: e2e-gateway } +spec: + gatewayClassName: vngcloud-alb + listeners: + - { name: http, protocol: HTTP, port: 80 } +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: { name: r-basic, namespace: e2e-gateway } +spec: + parentRefs: + - { name: gw-basic } + rules: + - matches: [{ path: { type: PathPrefix, value: / } }] + backendRefs: + - { name: echo, port: 8080 } +``` + +(Service `echo` is provided by your existing e2e fixture set; if not, vendor a basic httpbin Deployment+Service.) + +- [ ] **Step 2: Write `02–06.yaml`** + +Each follows the same shape, exercising one feature: + +- 02: Gateway with HTTPS listener + `tls.certificateRefs` Secret. +- 03: Gateway with HTTPS + `tls.frontendValidation.caCertificateRefs` ConfigMap. +- 04: HTTPRoute with two backendRefs weighted 80/20; verify counts via 100 curl calls. +- 05: HTTPRoute referencing a Service in another namespace + a `ReferenceGrant` allowing it. +- 06: HTTPRoute with `rules[].name: admin` plus a `VKSRoutePolicy` adding a `SourceIP STARTS_WITH` match; verify policy lands. + +> The exact YAML for each follows the spec §2 + §3 conventions. Don't paraphrase — copy from the spec's Section 3 worked example for `VKSRoutePolicy`. + +- [ ] **Step 3: Write `run.sh`** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +NS=e2e-gateway +kubectl create ns "$NS" 2>/dev/null || true + +for f in 01-basic-http 02-https-sni 03-mtls 04-weighted-canary 05-cross-ns-refgrant 06-route-policy-overlay; do + echo "=== Applying $f ===" + kubectl apply -f "$(dirname "$0")/$f.yaml" + kubectl -n "$NS" wait --for=condition=Programmed gateway --all --timeout=300s + kubectl -n "$NS" wait --for=condition=Accepted httproute --all --timeout=120s + bash "$(dirname "$0")/assert-$f.sh" +done +``` + +- [ ] **Step 4: Add `Makefile` target** + +```makefile +.PHONY: e2e-gateway +e2e-gateway: ## Run Gateway-API e2e suite against the current kubeconfig + bash test/e2e/gateway/run.sh +``` + +- [ ] **Step 5: Commit** + +```bash +git add test/e2e/gateway/ Makefile +git commit -m "test(e2e/gateway): add 6 ALB Gateway live-cluster scenarios" +``` + +--- + +## Section J — User docs + examples + +### Task J1: `gateway-api.md` — overview + +**Files:** +- Create: `docs/guide/gateway-api.md` + +- [ ] **Step 1: Write the page** + +Required sections (concrete content, not headers only): + +1. **What this is** — one paragraph: vngcloud LB controller speaks Gateway API; ALB GatewayClass in Phase 1; NLB later. +2. **What you get** — bullet list of supported features (HTTP/HTTPS/TLS-Terminate listeners, weighted canary, cross-namespace refs, mTLS). +3. **Customization model** — explain GEP-713 Direct Policy Attachment and the four CRDs at a glance, with one minimal YAML per CRD. +4. **Enabling** — `helm install ... --set gatewayApi.alb.enabled=true`. +5. **Status discoverability** — explicit short paragraph on the "spooky action at a distance" problem and the recommended commands: + ```bash + kubectl describe vksbackendpolicy -A + kubectl describe gateway prod-gw -n my-ns + ``` +6. **Conformance matrix** link (Phase 4) + capability table for v1 (one row per Gateway-API feature with Supported / Partial / Unsupported). + +- [ ] **Step 2: Commit** + +```bash +git add docs/guide/gateway-api.md +git commit -m "docs(gateway): overview page" +``` + +--- + +### Task J2: `gateway-alb.md` — ALB-specific guide + +**Files:** +- Create: `docs/guide/gateway-alb.md` + +- [ ] **Step 1: Write the page** + +Sections: + +1. **Provisioning a Gateway** — full walk-through with screenshot-equivalent YAML. +2. **Listener config** via `VKSGatewayPolicy` — example with `sectionName` for per-listener TLS policy. +3. **Cross-namespace TLS Secrets** — with ReferenceGrant. +4. **Migration from Ingress** — note that this is deferred to Phase 4. + +- [ ] **Step 2: Commit** + +```bash +git add docs/guide/gateway-alb.md +git commit -m "docs(gateway): ALB-specific guide" +``` + +--- + +### Task J3: `gateway-policies.md` — the four CRDs explained + +**Files:** +- Create: `docs/guide/gateway-policies.md` + +> The most important user-facing doc. Each policy gets its own subsection with a complete example. + +- [ ] **Step 1: Write the page** + +Required structure: + +1. **GEP-713 in 60 seconds** — `targetRefs`, Direct attachment, oldest-wins. +2. **VKSGatewayPolicy** — full YAML attaching to a Gateway, then a second YAML attaching to one listener via `sectionName`. Field reference table copied from spec §2.2. +3. **VKSBackendPolicy** — full YAML attaching to a Service. Field reference table from spec §2.3. +4. **VKSHealthCheckPolicy** — full YAML. Field reference from spec §2.4. Note that conflicting health-checks across a route's backends fail the route. +5. **VKSRoutePolicy** — full YAML attached to an `HTTPRoute` rule via `sectionName: `. Field reference from spec §2.5. Subsection on **rule names** — users **must** add `name:` to `HTTPRoute.spec.rules[]` for `sectionName` to resolve. +6. **Conflict semantics** — `Accepted=True` on the winner, `Conflicted=True` on losers, oldest-wins, namespace-scoped. Show `kubectl describe` output illustrating both. +7. **Discoverability runbook** — step-by-step "I changed a backend service and traffic broke; what do I check?" with the five `kubectl` commands the user runs. + +- [ ] **Step 2: Commit** + +```bash +git add docs/guide/gateway-policies.md +git commit -m "docs(gateway): four-CRD policy reference" +``` + +--- + +### Task J4: Example — weighted canary + +**Files:** +- Create: `docs/examples/gateway-canary.md` + +- [ ] **Step 1: Write the example** + +Full runnable manifest pair (HTTPRoute with 80/20 backendRefs + a `VKSBackendPolicy` enabling sticky sessions on the canary). Include the curl-loop verification snippet from `test/e2e/gateway/04-weighted-canary.yaml`. + +- [ ] **Step 2: Commit** + +```bash +git add docs/examples/gateway-canary.md +git commit -m "docs(gateway): weighted-canary example" +``` + +--- + +## Self-review + +Run this checklist after the plan is committed; fix issues inline. + +### 1. Spec coverage + +| Spec section | Tasks that implement it | +|---|---| +| §1.1 Controllers & GatewayClasses | A3, G1, H3 | +| §1.2 Policy CRDs (overview) | B1–B6 | +| §1.3 Package layout | every Section A–H file marker | +| §1.4 Manager wiring | H1 | +| §1.5 Coexistence boundary | A3 (owner labels), F2 (ensureLoadBalancer) | +| §2.1 Common types | B1 | +| §2.2 VKSGatewayPolicy | B2, E3 | +| §2.3 VKSBackendPolicy | B3, E3 | +| §2.4 VKSHealthCheckPolicy | B4, E3 | +| §2.5 VKSRoutePolicy | B5, E3 | +| §2.6 RBAC | H2 | +| §3.1 Mapping table | implicit; F2/F3/F5/F6 implement each row | +| §3.2 Controller graph | G1, G2, G3, G4 | +| §3.3 Gateway → LB + Listeners | F2, F3, F4 | +| §3.4 HTTPRoute → Policies + Pools | F5, F6 | +| §3.5 Synthetic pool naming + scaling | C2, F5 | +| §3.6 Policy resolver pseudocode | E2, F5, F6 | +| §3.7 Watch & reverse-index map | D4, D6, G2 | +| §3.8 Eventual-consistency notes | G2 (serialized reconciles) | +| §4.1 Status conditions | F7, G1, G4 | +| §4.2 Events | _Phase 2 follow-up_ — Phase 1 emits via `setupLog` only; spec §4.2 events are best-effort. | +| §4.3 Prometheus metrics | _Phase 2 follow-up_. | +| §4.4 Logging | covered by existing `contexts` package. | +| §4.5 Finalizer strategy | A3, G2, G3, D5 | +| §4.6 Readiness / liveness | G2 (initDone gate). | +| §5 File layout | implicit across A–H. | +| §6 Phasing roadmap (Phase 1) | this whole plan. | +| §7 Risks & mitigations | implicit; weight cap (C2), refgrant (E1), conflict status (G4). | + +**Gaps flagged:** §4.2 (events) and §4.3 (metrics) are explicit Phase-1 deliverables in the spec but deferred here for ergonomics. **If reviewers want them in Phase 1, add a Section K with one task per metric and one task to register the event recorder on each reconciler.** Not blocking; flagging. + +### 2. Placeholder scan + +No "TBD", "implement later", "fill in details", "similar to Task N" present. Two intentional pointers remain: +- F7 step 1 says "replace `EnsureNSGForGateway` with whatever the existing helper is named" — this is a real engineering judgment call against an existing repo helper, not a placeholder. The intent is fully specified. +- I5 steps 2 say "follow the spec's Section 3 worked example" rather than reproducing the YAML — the YAML is in the spec verbatim, and reproducing it here would be duplication that drifts on edit. Engineers are expected to copy from the spec. + +### 3. Type consistency + +Cross-checked: +- `BackendKey`, `BackendWeight`, `PolicyTarget` — defined in C2/C3, used in F5/F6/G4. ✅ +- `EffectiveLBConfig` — defined in F2, used in F3. ✅ +- `Result` — defined in F1, used in F2/G2. ✅ +- `LoadBalancerParams`, `ListenerParams`, `PoolParams`, `PolicyParams`, `Match`, `Action`, `HealthCheck`, `SessionAffinity` — these are types **expected to exist** in `internal/repository/vngcloud_repo`. The plan is explicit about this dependency in F2 and F6 (notes flagging that names may need adjusting). The engineer reviewing the existing repo before F1 must confirm or add these types; not a true placeholder, but worth surfacing. +- `Matches(t PolicyTarget) bool` — defined in E3 on all four CRDs, consumed via the generic `PolicyObj` interface in E2. ✅ +- `ResolveDirectPolicy[P]` — generic; `P` constrained to `PolicyObj`. Used in F2 (`*VKSGatewayPolicy`), F5 (`*VKSBackendPolicy`, `*VKSHealthCheckPolicy`), F6 (`*VKSRoutePolicy`), G4 (each via `policyItem[P]`). ✅ +- `shared.SetCondition` — defined in D2, used in F7, G1, G4. ✅ +- `shared.RegisterIndexes` — defined in D4, used in H1 + I1. ✅ + +### 4. Final notes for the executor + +- **TDD discipline:** strict TDD applies to pure functions in C1–C3, D1, D3, E1, E2 (failing test → run → implement → run → commit). For controllers (D6, F1–F8, G1–G4) implementation lands first and tests follow in Section I — that's the same compromise the existing `internal/controller/networking` suite makes. +- **Test parallelism:** per `CLAUDE.md`, always use `go test -p=1 ./...` for the full suite to avoid envtest-server collisions. +- **`make manifests` worktree scan:** per `CLAUDE.md`, always `git status` after `make manifests` and revert anything not from this branch's CRDs. +- **Verification gate:** before opening the PR, run the full suite (`make test`) and at least one e2e (`make e2e-gateway`). Both must pass. + +--- + +End of Phase 1 plan. + + + + + + + + + diff --git a/docs/superpowers/specs/2026-04-30-gateway-api-design.md b/docs/superpowers/specs/2026-04-30-gateway-api-design.md deleted file mode 100644 index 8859683b..00000000 --- a/docs/superpowers/specs/2026-04-30-gateway-api-design.md +++ /dev/null @@ -1,917 +0,0 @@ -# Gateway API Support — Design - -**Date:** 2026-04-30 -**Status:** Approved (brainstorming complete; awaiting implementation plan) -**Scope:** Add Kubernetes Gateway API (https://gateway-api.sigs.k8s.io) support to the vngcloud Load Balancer Controller, aligned with vngcloud LB product capabilities. - ---- - -## Decisions summary - -| # | Decision | Choice | -|---|---|---| -| 1 | Feature scope | Plan all Gateway API features upfront; phase the implementation in 4 waves | -| 2 | Gateway → LB mapping | Two GatewayClasses (`vngcloud-alb`, `vngcloud-nlb`); 1 Gateway = 1 LB; mixed-protocol Gateways rejected | -| 3 | Config CRD strategy | Reuse `LoadBalancerConfig` at both `GatewayClass.parametersRef` and `Gateway.spec.infrastructure.parametersRef` with a `mergingMode` field. Add two new namespaced CRDs: `TargetGroupConfig` (per-Service / per-route) and `ListenerRuleConfig` (per-HTTPRoute filter). Mirrors AWS LBC's pattern | -| 4 | Coexistence with existing Ingress / Service controllers | Strict separation; no shared LBs; no LB-ID adoption on Gateway. Documented one-shot migration tool deferred to Phase 4. Matches AWS LBC's Gateway behavior | -| 5 | Weighted backends in HTTPRoute | Synthetic merged pool with weight-scaled members (single vngcloud Pool aggregates all backends in a route rule) | -| 6 | HTTPRoute match / filter coverage | Strict reject for unsupported core features (`Accepted=False, reason=UnsupportedValue`) + `ListenerRuleConfig` ExtensionRef as the documented escape hatch for header/query/method matches and FixedResponse | -| 7 | TLS strategy | Secrets via `certificateRefs` (auto-imported, existing path) **plus** vngcloud cert IDs via existing `LoadBalancerConfig.Listeners[].certificateDefault.id` / `certificateAuthorities[].id`. mTLS via `frontendValidation` or LBC `clientCertificateId`. TLS-Passthrough only on NLB GatewayClass (Phase 2) | -| 8 | Phasing | Phase 1 = L7 + extensions (LRC + TGC); Phase 2 = L4; Phase 3 = advanced (GRPCRoute, BackendTLSPolicy, optional URLRewrite/Mirror/etc.); Phase 4 = migration tool + conformance + polish | - ---- - -## 1. Architecture overview - -### 1.1 Controllers & GatewayClasses - -Two new controllers added to the existing manager binary, each watching a distinct GatewayClass: - -- **`vngcloud-alb` GatewayClass** — `controllerName: gateway.vks.vngcloud.vn/alb`. Provisions vngcloud ALB. Accepts listeners with protocol `HTTP`, `HTTPS`, `TLS` (mode=Terminate). Routes: `HTTPRoute`, later `GRPCRoute`. -- **`vngcloud-nlb` GatewayClass** — `controllerName: gateway.vks.vngcloud.vn/nlb`. Provisions vngcloud NLB. Accepts listeners with protocol `TCP`, `UDP`, `TLS` (mode=Passthrough). Routes: `TCPRoute`, `UDPRoute`, `TLSRoute`. - -Mixed protocols on a single Gateway are rejected with `Accepted=False, reason=UnsupportedProtocol`. 1 Gateway = 1 vngcloud LoadBalancer; no sharing with Ingress / Service controllers. - -### 1.2 CRDs - -| CRD | Status | Role | -|---|---|---| -| `LoadBalancerConfig` (existing, extended) | extended | Referenced by both `GatewayClass.parametersRef` (template) and `Gateway.spec.infrastructure.parametersRef` (instance). Adds `mergingMode` and two new fields on the existing `Listener` struct (`sslPolicy`, `alpnPolicy`). | -| `TargetGroupConfig` (new, namespaced) | new | Attached per-Service via `targetReference`. Carries health-check, target-type, sticky-session, pool algorithm, TLS-encryption, PROXY protocol. Optional `routeConfigurations[]` overrides per route. | -| `ListenerRuleConfig` (new, namespaced) | new | Attached via `HTTPRoute.spec.rules[].filters[].extensionRef`. Holds non-Gateway-API features: header/query/method match, fixed-response, source-IP rules, regex compare overrides. | - -Group: `gateway.vks.vngcloud.vn/v1alpha1` for the two new CRDs. `LoadBalancerConfig` stays in `vks.vngcloud.vn/v1alpha1` with a backwards-compatible field block. - -### 1.3 Package layout (top-level orientation) - -``` -api/v1alpha1/ # extended (Listener fields, MergingMode) -api/gateway/v1alpha1/ # new group: TargetGroupConfig, ListenerRuleConfig -internal/controller/gateway/ # new: alb/, nlb/, shared/, targetgroupconfig/, listenerruleconfig/ -internal/usecase/gateway_uc/ # new: alb_gateway_uc/, nlb_gateway_uc/, shared/ -pkg/gateway/ # new: utils, hostname matchers, synth-pool helpers -``` - -Reconcilers stay thin (request → useCase). UseCases use the existing `K8sRepository` and `VngCloudRepository`. The Gateway controller is the only writer to the vngcloud LB; HTTPRoute reconciles update an in-memory cache and enqueue the parent Gateway. - -### 1.4 Manager wiring - -`cmd/main.go` adds: -- Register Gateway API scheme (`sigs.k8s.io/gateway-api/apis/v1`, `v1alpha2`). -- Feature gate flags: `--enable-gateway-api-alb` (default `false` initially, `true` after stable), `--enable-gateway-api-nlb` (false until Phase 2). -- Conditional reconciler registration based on feature gates. -- New finalizer constants: `gateway.vks.vngcloud.vn/resources`. - -### 1.5 Coexistence boundary - -Gateway-controller-owned vngcloud LBs carry the owner label `vks.vngcloud.vn/owner-resource-kind=Gateway`. Existing Ingress / Service controllers ignore non-matching owners. No shared-LB code paths in v1. - ---- - -## 2. CRD schemas - -### 2.1 `LoadBalancerConfig` (extended) - -Additive changes only. Existing fields and behavior unchanged. - -```go -type LoadBalancerConfigSpec struct { - // ... all existing fields unchanged ... - - // MergingMode controls how this LBC merges with another LBC when both - // GatewayClass.parametersRef and Gateway.spec.infrastructure.parametersRef are set. - // Only honored when this object is referenced by GatewayClass.parametersRef. - // +kubebuilder:validation:Enum=PreferGateway;PreferGatewayClass - // +optional - MergingMode *MergingMode `json:"mergingMode,omitempty"` -} - -type Listener struct { - // ... all existing fields unchanged ... - - // SSLPolicy selects the listener's TLS protocol/cipher policy (vngcloud-defined). - // Only honored on HTTPS / TLS-terminate listeners. - // +optional - SSLPolicy *string `json:"sslPolicy,omitempty"` - - // ALPNPolicy controls ALPN advertisement (e.g., "HTTP2Optional", "HTTP1Only"). - // Only honored on HTTPS / TLS-terminate listeners. - // +optional - ALPNPolicy *string `json:"alpnPolicy,omitempty"` -} -``` - -**How `Listeners[]` is interpreted when LBC is referenced from a Gateway:** - -The existing `Listeners[]` field is reused verbatim. Items are matched to the Gateway's listeners by `name`. - -| LBC `Listener` field | Role under Gateway | Conflict handling | -|---|---|---| -| `name` | Selector — must equal `Gateway.spec.listeners[].name` | Listener entries with no matching Gateway listener are silently ignored | -| `protocol`, `protocolPort` | Must match the Gateway listener | Mismatch → `Programmed=False, reason=InvalidParameters` on the Gateway listener | -| `defaultPoolName`, `policies` | **Ignored** under Gateway use | Pool/policy set is derived from routes | -| `certificateDefault`, `certificateAuthorities` | Override `tls.certificateRefs` when LBC provides IDs | LBC wins if set; otherwise fall back to Secret refs | -| `clientCertificateId` | Override `tls.frontendValidation.caCertificateRefs` | LBC wins if set; mutually exclusive with `frontendValidation` | -| `timeoutClient`, `timeoutMember`, `timeoutConnection`, `allowedCidrs`, `insertHeaders`, `sslPolicy`, `alpnPolicy` | Vngcloud-specific overrides | Applied as-is | - -**Merging semantics** (when both Class- and Gateway-level LBCs exist): - -- Scalar fields: winner per `MergingMode` (default `PreferGateway`). -- List fields with named items (`listeners`, `tags`): merged by name; per-item values resolved by `MergingMode`. -- `loadBalancerId`: per-Gateway only (ignored at class level — class can't pin a single LB). -- Merging produces an in-memory effective config; source LBC objects are not mutated. - -### 2.2 `TargetGroupConfig` (new, namespaced) - -```go -// +kubebuilder:resource:shortName=tgc -type TargetGroupConfig struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - Spec TargetGroupConfigSpec `json:"spec,omitempty"` - Status TargetGroupConfigStatus `json:"status,omitempty"` -} - -type TargetGroupConfigSpec struct { - // TargetReference points at a Service in the same namespace. - TargetReference TargetReference `json:"targetReference"` - - // DefaultConfig applies when this TGC is selected via targetReference - // and no routeConfiguration overrides match the active route. - DefaultConfig TargetGroupProperties `json:"defaultConfig"` - - // RouteConfigurations carries per-route overrides (selected by route GVK + name). - // Highest specificity wins (route+rule > route > default). - // +optional - RouteConfigurations []RouteSpecificConfig `json:"routeConfigurations,omitempty"` -} - -type TargetReference struct { - Group *string `json:"group,omitempty"` // default "" (core) - Kind *string `json:"kind,omitempty"` // default "Service" - Name string `json:"name"` -} - -type TargetGroupProperties struct { - // +kubebuilder:validation:Enum=instance;ip - TargetType *string `json:"targetType,omitempty"` - PoolAlgorithm *string `json:"poolAlgorithm,omitempty"` - EnableStickySession *bool `json:"enableStickySession,omitempty"` - EnableTLSEncryption *bool `json:"enableTLSEncryption,omitempty"` - EnableProxyProtocol *bool `json:"enableProxyProtocol,omitempty"` - HealthCheck *PoolHealthMonitor `json:"healthCheck,omitempty"` - TargetNodeLabels map[string]string `json:"targetNodeLabels,omitempty"` - ManageDFPMembers *bool `json:"manageDFPMembers,omitempty"` -} - -type RouteSpecificConfig struct { - RouteIdentifier RouteIdentifier `json:"routeIdentifier"` - Config TargetGroupProperties `json:"config"` -} - -type RouteIdentifier struct { - Group string `json:"group"` // "gateway.networking.k8s.io" - Kind string `json:"kind"` // "HTTPRoute" / "TCPRoute" / ... - Namespace *string `json:"namespace,omitempty"` - Name string `json:"name"` - RuleName *string `json:"ruleName,omitempty"` -} - -type TargetGroupConfigStatus struct { - Conditions []metav1.Condition `json:"conditions,omitempty"` - ObservedGeneration int64 `json:"observedGeneration,omitempty"` -} -``` - -**Lookup priority** when building a backend pool: route+rule-specific config → route-level config → default config → controller fallback defaults. - -### 2.3 `ListenerRuleConfig` (new, namespaced) - -Attached via HTTPRoute filter `extensionRef`: - -```yaml -filters: -- type: ExtensionRef - extensionRef: { group: gateway.vks.vngcloud.vn, kind: ListenerRuleConfig, name: admin-rules } -``` - -```go -// +kubebuilder:resource:shortName=lrc -type ListenerRuleConfig struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - Spec ListenerRuleConfigSpec `json:"spec,omitempty"` - Status ListenerRuleConfigStatus `json:"status,omitempty"` -} - -type ListenerRuleConfigSpec struct { - // AdditionalMatches are AND'd with the HTTPRoute's standard matches. - // +optional - AdditionalMatches []AdditionalMatch `json:"additionalMatches,omitempty"` - - // Actions, if set, supersede the route's default REDIRECT_TO_POOL action. - // +optional - Actions []RuleAction `json:"actions,omitempty"` - - // Position pins policy ordering. Default = controller-assigned (Gateway-API spec ordering). - // +optional - Position *int32 `json:"position,omitempty"` -} - -type AdditionalMatch struct { - // +kubebuilder:validation:Enum=Header;QueryParam;Method;SourceIP - Type string `json:"type"` - Name *string `json:"name,omitempty"` // header/query name; unused for Method/SourceIP - // vngcloud values: EQUAL_TO | STARTS_WITH | ENDS_WITH | CONTAINS | REGEX - Compare string `json:"compare"` - Value string `json:"value"` -} - -type RuleAction struct { - // +kubebuilder:validation:Enum=FixedResponse;Reject;Redirect - Type string `json:"type"` - FixedResponse *FixedResponseAction `json:"fixedResponse,omitempty"` - Redirect *RedirectAction `json:"redirect,omitempty"` -} - -type FixedResponseAction struct { - StatusCode int32 `json:"statusCode"` - ContentType *string `json:"contentType,omitempty"` - Body *string `json:"body,omitempty"` -} - -type RedirectAction struct { - URL string `json:"url"` - HTTPCode *int32 `json:"httpCode,omitempty"` - KeepQueryString *bool `json:"keepQueryString,omitempty"` -} - -type ListenerRuleConfigStatus struct { - Conditions []metav1.Condition `json:"conditions,omitempty"` - ObservedGeneration int64 `json:"observedGeneration,omitempty"` -} -``` - -**Forward-compat note:** when vngcloud LB later adds native header/query/method matching, those fields move into the standard `HTTPRoute.match` translation. `ListenerRuleConfig.AdditionalMatches` of those types start emitting a `Deprecated` condition pointing users at the now-supported core fields. No breakage. - -### 2.4 RBAC additions - -``` -+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch -+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses/status,verbs=update;patch -+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch;update;patch -+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/status,verbs=update;patch -+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/finalizers,verbs=update -+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes;referencegrants,verbs=get;list;watch -+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/status,verbs=update;patch -+kubebuilder:rbac:groups=gateway.vks.vngcloud.vn,resources=targetgroupconfigs;listenerruleconfigs,verbs=get;list;watch;update;patch -+kubebuilder:rbac:groups=gateway.vks.vngcloud.vn,resources=targetgroupconfigs/status;listenerruleconfigs/status,verbs=update;patch -``` - -(Phase 2 adds `tcproutes`, `udproutes`, `tlsroutes`; Phase 3 adds `grpcroutes`, `backendtlspolicies`.) - ---- - -## 3. Resource mapping & data flow - -### 3.1 Mapping table — Gateway API → vngcloud primitives - -| Gateway API resource | vngcloud entity | Notes | -|---|---|---| -| `GatewayClass` (controller=`gateway.vks.vngcloud.vn/alb`) | none (declarative gate) | Status `Accepted=True` once `parametersRef` LBC is valid | -| `GatewayClass` (controller=`gateway.vks.vngcloud.vn/nlb`) | none | same; Phase 2 | -| `Gateway` | 1 `LoadBalancer` (ALB or NLB) + N `Listener` | LB type fixed by GatewayClass; 1:1 binding via owner labels | -| `Gateway.spec.listeners[]` | vngcloud `Listener` | `name` is the join key with LBC.Listeners[]; protocol / port / TLS mapped per §3.3 | -| `Gateway.spec.addresses` | informational only | vngcloud assigns address; user-requested IP rejected unless feature exists | -| `HTTPRoute` (per parentRef listener) | 1+ `Policy` + 1+ `Pool` (synthetic) | One policy per `rule × match` (cartesian when multi-match); see §3.4 | -| `HTTPRoute.hostnames[]` | `HOST_NAME` rule on each generated policy | `*.foo.com` → `REGEX` rule | -| `HTTPRoute.rule.matches[].path` | `PATH` rule (EQUAL_TO / STARTS_WITH / REGEX) | Implementation-specific path types fall back to EQUAL_TO | -| `HTTPRoute.rule.backendRefs[]` (weighted) | 1 synthetic `Pool` (members from union) | Weight-scaled members; pool name = stable hash (§3.5) | -| `HTTPRoute.rule.filters[RequestRedirect]` | `Policy.Action = REDIRECT_TO_URL` | URL templated from filter fields | -| `HTTPRoute.rule.filters[RequestHeaderModifier]` (set) | listener `insertHeaders` | Only if uniform across all rules on the listener; otherwise reject | -| `HTTPRoute.rule.filters[ExtensionRef → ListenerRuleConfig]` | extra L7 rules + alt action | See §3.6 | -| `ReferenceGrant` | gating only | Resolves cross-namespace `backendRefs` and TLS Secret refs | -| `TargetGroupConfig` | pool defaults | Per Service + per route override; see §3.7 | -| `ListenerRuleConfig` | policy match / action additions | Attached via HTTPRoute `ExtensionRef` filter | - -### 3.2 Controller graph - -``` -┌───────────────────────┐ parametersRef -│ GatewayClassController├──────────┐ -└───────────┬───────────┘ │ - │ ▼ - │ ┌──────────────────────┐ - │ │ LoadBalancerConfig │ - │ │ (existing, extended) │ - │ └──────────────────────┘ - ▼ ▲ -┌───────────────────────┐ infrastructure.parametersRef -│ GatewayController ├──────────┘ -│ (per GatewayClass) │◄──────── HTTPRoute parentRef -└───────────┬───────────┘ - │ owns - ▼ - vngcloud LoadBalancer + Listeners - -┌───────────────────────┐ -│ HTTPRouteController │──── parentRefs ───► Gateway (triggers Gateway reconcile) -└───────────┬───────────┘──── backendRefs ──► Service (triggers HTTPRoute reconcile) - │ - │ owns (via Gateway reconcile) - ▼ - vngcloud Policies + Pools (on the parent's Listener) - -┌───────────────────────┐ targetReference -│ TargetGroupConfig │──────────────────► Service (triggers HTTPRoute requeue) -│ Controller (validate) │ -└───────────────────────┘ - -┌───────────────────────┐ reverse-index -│ ListenerRuleConfig │──────────────────► HTTPRoute (triggers requeue) -│ Controller (validate) │ -└───────────────────────┘ -``` - -**Ownership model:** the **Gateway controller** is the only writer to the vngcloud LB. HTTPRoute reconciles do not touch vngcloud directly — they enqueue the parent Gateway. This serializes all LB mutations through one reconciler per Gateway and avoids races. - -### 3.3 Gateway → LoadBalancer + Listeners - -Reconcile order per Gateway: - -1. **Resolve effective config** = merge(`GatewayClass.parametersRef → LBC`, `Gateway.spec.infrastructure.parametersRef → LBC`) per `mergingMode`. -2. **Validate listeners** — protocol allowed by GatewayClass type? port unique? hostname valid? Set per-listener `Accepted` condition. -3. **Lookup or create LB** — by owner label `gateway.vks.vngcloud.vn/owner-uid=`. Type = ALB or NLB. Apply LB-level config from merged LBC. -4. **Compute desired Listener set** — for each `Gateway.spec.listeners[]` with `Accepted=True`: - - protocol / port from Gateway listener - - TLS: prefer LBC `Listener.certificateDefault/Authorities` IDs if present, else import Secrets from `tls.certificateRefs` (existing import path) - - mTLS: LBC `clientCertificateId` if set, else import from `tls.frontendValidation.caCertificateRefs` - - other fields (timeouts, allowedCidrs, sslPolicy, alpnPolicy, insertHeaders) from merged LBC entry by listener name - - default pool: synthetic "default-forwarding-pool" with no members (fed by route layer) -5. **Diff against vngcloud** — reuse existing per-listener helpers in `internal/usecase/lbc_uc/deploy_listener.go`. -6. **Trigger route layer** — emit policy / pool sync for every accepted route attached to this Gateway. -7. **Write status** — Gateway and listener conditions per §4. - -### 3.4 HTTPRoute → Policies + Pools - -For each HTTPRoute attached to an accepted Gateway listener: - -``` -for each rule in route.spec.rules: - pool = synthesizePool(rule.backendRefs, route, ruleIdx) - for each match in rule.matches: - for each hostname in route.hostnames (or [""] if empty): - policy = buildPolicy(hostname, match, rule.filters, pool) - policies.append(policy) -``` - -**One policy per (hostname × match × filter-resolved-action)**: - -- L7 rules: - - `HOST_NAME` rule per hostname (literal → `EQUAL_TO`; wildcard → `REGEX`) - - `PATH` rule per `match.path` - - any `ListenerRuleConfig.additionalMatches` AND'd in -- Action: - - `RequestRedirect` filter → `REDIRECT_TO_URL` - - `ListenerRuleConfig.actions[FixedResponse|Reject]` → `REJECT` (FixedResponse marked `Programmed=False, reason=UnsupportedFilter` until vngcloud adds native support; `Reject` works) - - default → `REDIRECT_TO_POOL` pointing at the synthetic pool - -**Policy ordering** — Gateway API spec: most-specific path first, then header count, then route creation timestamp. Translation: assign vngcloud `Policy.Position` in spec order; existing `auto-reorder-policies` machinery reused. - -**Cross-namespace backendRefs** — gated by ReferenceGrant. If grant absent, set `ResolvedRefs=False, reason=RefNotPermitted` on the route and drop just that backend from the synthesized pool (don't fail the whole rule). - -### 3.5 Synthetic pool — naming & weight scaling - -**Name** (deterministic, ≤ 50 chars): - -``` -gw___ -``` - -- `route-uid-prefix8`: first 8 chars of HTTPRoute `metadata.uid`. -- `rule-idx`: rule index in route. -- `backendset-hash5`: 5-char hash of sorted `(ns, name, port, weight)` tuples. - -Hash changes only when the backend set / weights change; member churn within a backend keeps the same pool. - -**Weight scaling algorithm:** - -1. Collect each backend's current ready endpoints from EndpointSlice (or NodePort + ready-nodes, by target-type). -2. For each backend `b` with desired weight `w_b` and `n_b` ready endpoints, base member weight = 1. -3. Apply weight multiplier: `member_weight = round(w_b * scale / n_b)` where `scale = lcm(n_b for all b) / gcd(weights)` capped at 100 to avoid huge integers. -4. Floor every member weight at 1. -5. Skip backends where the ReferenceGrant is missing. - -**Health check** for the synthetic pool — single config from priority order: - -1. Route-level `TargetGroupConfig.routeConfigurations[]` matching this route+rule. -2. First backend's `TargetGroupConfig.defaultConfig`. -3. Controller fallback (TCP check on backend port). - -Conflict (multiple backends with conflicting TGC health-check configs) → `ResolvedRefs=False, reason=BackendConfigMismatch` on the route. - -### 3.6 ListenerRuleConfig wiring - -Example: - -```yaml -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -spec: - rules: - - matches: - - path: { type: PathPrefix, value: /admin } - filters: - - type: ExtensionRef - extensionRef: { group: gateway.vks.vngcloud.vn, kind: ListenerRuleConfig, name: admin-rules } - backendRefs: - - { name: admin-svc, port: 8080 } -``` - -`admin-rules` adds (e.g.) a `Header X-Source CONTAINS internal` match and a `SourceIP STARTS_WITH 10.0.` match. Both AND'd onto the policy generated from this rule. - -If an `Action` is specified in `ListenerRuleConfig`, it overrides the default `REDIRECT_TO_POOL` (useful for fixed-response / reject without a backend). - -The ListenerRuleConfig controller maintains a reverse-index (config-name → routes referencing it) so updates trigger route requeues. - -### 3.7 TargetGroupConfig wiring - -Selector cascade per `(route, ruleIdx, backendRef)`: - -1. Find TGCs whose `targetReference` matches the backend Service (in same namespace). -2. Among those, prefer entries in `routeConfigurations[]` matching `(route GVK, name, ruleName)`; fall back to `defaultConfig`. -3. If multiple TGCs target the same Service, the one with more specific `routeConfigurations[]` wins; ties broken by oldest-first creation timestamp (with `Conflicted=True` on the loser). - -A TGC change reverse-indexes to all routes whose backends it targets and requeues each. - -### 3.8 Watch & reverse-index map - -| Watch source | Triggers requeue of | -|---|---| -| `GatewayClass` | Self + all Gateways referencing this class | -| `LoadBalancerConfig` | All GatewayClasses & Gateways referencing it | -| `Gateway` | Self + all routes whose `parentRefs` resolve to it | -| `HTTPRoute` | Self + parent Gateway | -| `Service` | All HTTPRoutes whose `backendRefs` include it | -| `EndpointSlice` | Same as Service | -| `Secret` (TLS) | All Gateways whose `certificateRefs` / `frontendValidation` include it | -| `ReferenceGrant` | All routes / Gateways whose cross-ns refs were previously denied | -| `TargetGroupConfig` | All HTTPRoutes whose `backendRefs` matches | -| `ListenerRuleConfig` | All HTTPRoutes whose filters reference it | - -Indexes via controller-runtime `Manager.GetFieldIndexer()` and a small `pkg/gateway/reference_indexer.go` (mirrors `pkg/ingress/reference_indexer.go`). - -### 3.9 Eventual-consistency notes - -- A change to one HTTPRoute requeues only its parent Gateway → Gateway reconcile rebuilds the full policy / pool set for that LB. Idempotent diff drives create / update / delete. -- Per-Gateway reconciles serialized by controller-runtime work queues. -- Status updates batched at end of reconcile (one PATCH per object) to minimize API churn. -- Long-running LB ops (provisioning, listener create) reuse existing requeue-with-backoff machinery from `lbc_uc`. - ---- - -## 4. Status conditions, events, metrics - -### 4.1 Status conditions - -#### `GatewayClass.status.conditions` - -| Type | True | False | Reason | -|---|---|---|---| -| `Accepted` | parametersRef LBC valid + supports the GatewayClass type | LBC missing / type mismatch / invalid | `Accepted` / `InvalidParameters` / `Pending` | -| `SupportedVersion` | controller version supports the Gateway API version of the class | version skew | `SupportedVersion` / `UnsupportedVersion` | - -#### `Gateway.status.conditions` - -| Type | True | False | Reason | -|---|---|---|---| -| `Accepted` | spec valid; this controller owns the class; merged config resolved | invalid spec / unsupported feature / class not accepted | `Accepted` / `InvalidParameters` / `NotReconciled` / `UnsupportedAddress` | -| `Programmed` | vngcloud LB created, listeners synced, addresses populated | LB provisioning failed; listener creation failed | `Programmed` / `Pending` / `Invalid` / `NoResources` / `VngcloudAPIError` | - -#### `Gateway.status.listeners[].conditions` - -| Type | True | False | Reason | -|---|---|---|---| -| `Accepted` | protocol allowed for this GatewayClass; port unique within Gateway | mixed protocols / dup port / unsupported feature | `Accepted` / `UnsupportedProtocol` / `PortUnavailable` / `Invalid` | -| `Programmed` | listener exists on vngcloud LB | not yet created / failed | `Programmed` / `Pending` / `Invalid` | -| `ResolvedRefs` | all `certificateRefs` and `frontendValidation.caCertificateRefs` resolved | grant missing / Secret not found / unsupported group | `ResolvedRefs` / `RefNotPermitted` / `InvalidCertificateRef` / `InvalidCACertificateRef` | -| `Conflicted` | always `False` in v1 (no shared LBs) | n/a | `NoConflicts` | - -`Gateway.status.listeners[].attachedRoutes` recomputed each Gateway reconcile. - -`Gateway.status.addresses[]` populated from vngcloud LB once `Programmed=True`. `IPAddress` for public/internal/InterVPC; `Hostname` if vngcloud assigns DNS-only (future). - -#### `HTTPRoute.status.parents[].conditions` (per parentRef) - -| Type | True | False | Reason | -|---|---|---|---| -| `Accepted` | parent Gateway exists, allows route kind, namespace allowed | listener restricts kind / ns / hostname | `Accepted` / `NotAllowedByListeners` / `NoMatchingListenerHostname` / `NoMatchingParent` | -| `ResolvedRefs` | all `backendRefs` and `extensionRef` filters resolved | backend missing / wrong kind / cross-ns denied / TGC/LRC unresolved | `ResolvedRefs` / `BackendNotFound` / `RefNotPermitted` / `InvalidKind` / `InvalidExtensionRef` | -| `PartiallyInvalid` (when at least one rule programmed but some dropped) | per spec | n/a | `UnsupportedValue` | - -#### Custom CRD status - -`TargetGroupConfig.status` and `ListenerRuleConfig.status`: - -- `Accepted` — schema valid, target exists. -- `Conflicted` — `True` when another higher-specificity TGC also targets the same Service+route. -- `InUse` — `True` when at least one route currently references this config. - -`LoadBalancerConfig.status` (existing, extended) gets a new condition `ReferencedByGateway` (informational). - -### 4.2 Kubernetes Events - -| Reconciler | Event reason examples | -|---|---| -| GatewayClass | `Accepted`, `InvalidParameters`, `LBCMissing` | -| Gateway | `LoadBalancerCreated`, `ListenerCreated`, `LoadBalancerProvisioningFailed`, `ConfigMergeFailed` | -| HTTPRoute | `RouteAttached`, `RouteDetached`, `BackendDropped`, `UnsupportedFilter` | -| TargetGroupConfig | `Conflict`, `TargetServiceMissing` | -| ListenerRuleConfig | `ReferencedByRoute`, `InvalidActionForListener` | - -Reuses existing `eventRecorder` plumbing. - -### 4.3 Prometheus metrics - -``` -# counters -gateway_api_reconcile_total{controller,result} -gateway_api_resource_count{kind,namespace,gateway_class} -gateway_api_listener_count{gateway_class,protocol} -gateway_api_route_attached_total{gateway_class} -gateway_api_unsupported_feature_total{kind,feature} # informs Phase-3 prioritization -gateway_api_refgrant_denied_total{namespace,kind} - -# histograms -gateway_api_reconcile_duration_seconds{controller,result} -gateway_api_lb_provisioning_seconds{gateway_class} - -# gauges -gateway_api_lb_total{gateway_class,scheme} -gateway_api_synthetic_pool_total -``` - -`metricsutil.ReconcileCounters` extended with `IncrementGateway` / `IncrementHTTPRoute`. - -### 4.4 Logging - -Reuses existing `contexts` package. Log-name conventions: `gw//`, `httproute//`, `gwc/`, `tgc//`, `lrc//`. - -### 4.5 Finalizer strategy - -| Resource | Finalizer | Cleanup action | -|---|---|---| -| `Gateway` | `gateway.vks.vngcloud.vn/resources` | Delete vngcloud LB, drop owner labels, deindex routes | -| `HTTPRoute` | `gateway.vks.vngcloud.vn/route` | Trigger parent Gateway reconcile | -| `TargetGroupConfig` / `ListenerRuleConfig` | none | Pure config; deletion = recompute downstream routes | - -Deletion ordering: HTTPRoutes detach → parent Gateway reconciles → finalizer removed when policies / pools gone. Gateway finalizer removed last when LB cleanup completes. - -### 4.6 Readiness / liveness - -No probe-endpoint changes. New reconcilers respect the `initDone` atomic-bool gate pattern (1s requeue while uninitialized). Fail-open on probe. - ---- - -## 5. Implementation plan & file layout - -### 5.1 Full package layout (after all 4 phases) - -New files marked **[new]**, modified existing files marked **[mod]**. - -``` -api/ -├── v1alpha1/ -│ ├── loadbalancerconfig_types.go [mod] add MergingMode + SSLPolicy/ALPNPolicy on Listener -│ ├── zz_generated.deepcopy.go [mod] regenerated -│ └── ... (existing files unchanged) -└── gateway/v1alpha1/ [new group] - ├── groupversion_info.go [new] - ├── targetgroupconfig_types.go [new] - ├── listenerruleconfig_types.go [new] - └── zz_generated.deepcopy.go [new generated] - -cmd/ -└── main.go [mod] register Gateway API scheme, - feature gates, new reconcilers - -internal/controller/ -├── ... (existing controllers unchanged) ... -└── gateway/ [new] - ├── shared/ [new] - │ ├── classifier.go protocol → GatewayClass type, listener validation - │ ├── status.go condition helpers - │ ├── reference_indexer.go reverse indexes (Service, Secret, RG, TGC, LRC, LBC) - │ ├── finalizer.go - │ ├── policy_order.go Gateway API match-specificity ordering - │ └── eventhandlers/ cross-resource enqueue helpers - ├── alb/ [new — Phase 1] - │ ├── gatewayclass_controller.go - │ ├── gateway_controller.go - │ ├── httproute_controller.go - │ ├── grpcroute_controller.go [Phase 3] - │ ├── *_test.go - │ ├── helpers_test.go - │ └── suite_test.go - ├── nlb/ [new — Phase 2] - │ ├── gatewayclass_controller.go - │ ├── gateway_controller.go - │ ├── tcproute_controller.go - │ ├── udproute_controller.go - │ ├── tlsroute_controller.go - │ ├── *_test.go - │ └── suite_test.go - ├── targetgroupconfig/ [new — Phase 1, validation only] - │ ├── controller.go - │ └── controller_test.go - └── listenerruleconfig/ [new — Phase 1, validation only] - ├── controller.go - └── controller_test.go - -internal/usecase/ -├── contracts.go [mod] add GatewayClassUseCase, GatewayUseCase, RouteUseCase -├── mocks.go [mod] regenerated -├── gateway_uc/ [new] -│ ├── shared/ [new] -│ │ ├── merge_config.go LBC merging -│ │ ├── tgc_resolver.go cascading TGC selector -│ │ ├── lrc_resolver.go LRC ExtensionRef resolution -│ │ ├── refgrant.go ReferenceGrant evaluation -│ │ └── *_test.go -│ ├── alb_gateway_uc/ [new — Phase 1] -│ │ ├── gateway_uc.go Init / Ensure / Delete -│ │ ├── build_lb.go Gateway → vngcloud LB params -│ │ ├── build_listener.go Gateway listener → vngcloud Listener -│ │ ├── build_pool.go synthetic pool (weights, health checks) -│ │ ├── build_policy.go HTTPRoute rule × match × filter → Policy -│ │ ├── build_cert.go Secret-import or cert-ID -│ │ ├── build_sec_group.go inherits NSG behavior from Ingress -│ │ ├── status.go -│ │ └── *_test.go -│ └── nlb_gateway_uc/ [new — Phase 2] -│ └── ... - -internal/repository/ -├── contracts.go unchanged in v1 -├── k8s_repo/ unchanged -├── vngcloud_repo/ unchanged -└── mocks.go [mod] regenerated - -internal/domain/ -└── domain.go [mod] add GatewayFinalizer, owner-kind constants - -pkg/ -├── consts/consts.go [mod] new GatewayClass controller-name constants -├── metrics/lbc/ [mod] expose Gateway counters -├── metrics/util/reconcile_counters.go [mod] add IncrementGateway / IncrementHTTPRoute -└── gateway/ [new] - ├── gatewayapi_utils.go hostname matchers, wildcard → regex, proto helpers - ├── synth_pool.go pool-name hashing helpers - └── *_test.go - -charts/vngcloud-load-balancer-controller/ -├── templates/ -│ ├── crds/ [mod] add tgc.yaml, lrc.yaml; updated lbc.yaml -│ ├── gatewayclass-alb.yaml [new — Phase 1] -│ ├── gatewayclass-nlb.yaml [new — Phase 2] -│ ├── rbac/ [mod] -│ └── manager-deployment.yaml [mod] feature-gate flags -└── values.yaml [mod] gatewayApi.{alb,nlb}.enabled toggles - -config/ [mod throughout — kubebuilder-managed] - -docs/ [mod throughout] -├── guide/ -│ ├── gateway-api.md [new] -│ ├── gateway-alb.md [new — Phase 1] -│ ├── gateway-nlb.md [new — Phase 2] -│ ├── gateway-extensions.md [new — Phase 1] -│ └── gateway-migration.md [new — Phase 4] -└── examples/ [new] - -test/ [mod throughout] -├── e2e/gateway/ [new] -└── conformance/ [new — Phase 4] - -go.mod [mod] add sigs.k8s.io/gateway-api -PROJECT [mod] register new resources via kubebuilder -``` - -### 5.2 New Go module dependencies - -```go -require ( - sigs.k8s.io/gateway-api v1.2.0 // Standard channel covering HTTPRoute / GRPCRoute / RG -) -``` - -(Phase 4 conformance pulls `sigs.k8s.io/gateway-api/conformance` as a test-only dep.) - -### 5.3 Manager wiring (`cmd/main.go` deltas) - -```go -import ( - gwv1 "sigs.k8s.io/gateway-api/apis/v1" - gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" - gwvksv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" - albgw "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/alb" - "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/alb_gateway_uc" -) - -utilruntime.Must(gwv1.Install(scheme)) -utilruntime.Must(gwv1alpha2.Install(scheme)) -utilruntime.Must(gwvksv1alpha1.AddToScheme(scheme)) - -flag.BoolVar(&cfg.Gateway.ALBEnabled, "enable-gateway-api-alb", false, "Enable the ALB Gateway API controller (Phase 1).") -flag.BoolVar(&cfg.Gateway.NLBEnabled, "enable-gateway-api-nlb", false, "Enable the NLB Gateway API controller (Phase 2+).") - -if cfg.Gateway.ALBEnabled { - albUC := alb_gateway_uc.NewALBGatewayUseCase(...) - if err := albgw.NewGatewayClassReconciler(...).SetupWithManager(mgr); err != nil { ... } - if err := albgw.NewGatewayReconciler(albUC, ...).SetupWithManager(mgr); err != nil { ... } - if err := albgw.NewHTTPRouteReconciler(albUC, ...).SetupWithManager(mgr); err != nil { ... } -} -// TGC + LRC validation controllers always-on if any Gateway controller is on. -``` - -Feature gates make the controller safe to upgrade in place — existing deployments don't suddenly start reconciling Gateway resources unless explicitly enabled. - -### 5.4 Mocking & test strategy - -**Unit tests** — table-driven, gomock-generated mocks for `K8sRepository` / `VngCloudRepository`. Coverage targets parity with existing Ingress controller (~70% line, all `build_*` paths covered): - -- `build_listener_test.go` — protocol + TLS combos, cert-ID vs Secret precedence, mTLS resolution. -- `build_pool_test.go` — synthetic-pool naming determinism, weight scaling, RG-denied-backend dropping, conflicting health-check detection. -- `build_policy_test.go` — match cartesian, policy-position ordering, LRC additional-match composition. -- `merge_config_test.go` — `PreferGateway` vs `PreferGatewayClass`, list-merge semantics, conflict tags. -- `tgc_resolver_test.go` — cascade specificity, oldest-wins tiebreak, conflict-condition emission. -- `refgrant_test.go` — allow / deny matrix; covers Secrets, Services, custom CRDs. - -**Suite tests** (envtest) — one per reconciler, mirroring `internal/controller/networking/suite_test.go`. Validates reconcile-on-event for each watch in §3.8. - -**E2E tests** — under `test/e2e/gateway/`, run against a real vngcloud LB. Gated by env var; skipped in CI by default. - -**Conformance** — `test/conformance/conformance_test.go` runs the upstream Gateway API conformance suite. Phase 4 deliverable. - -### 5.5 Codegen / make targets - -Existing `make generate manifests test` covers all generation. New entries: - -``` -.PHONY: gateway-conformance -gateway-conformance: ## Run upstream Gateway API conformance suite (Phase 4) - go test ./test/conformance/... -tags=conformance -v -``` - -No mockery config changes (existing config picks up new interfaces automatically). - -### 5.6 Backward-compat / rollout safety - -- **CRD changes are additive** — `LoadBalancerConfig` gets new optional fields only; existing manifests keep applying cleanly. -- **No changes** to existing Service / Ingress / GLB / LBC / NSG controller behavior. -- **Feature gates default-off initially** — opt-in upgrade. ALB flips to `true` after one minor cycle in production. -- **CRD bundle versioning** — Helm chart's `Chart.yaml` minor-version bumped per phase. CRDs ship `served: true, storage: true` for v1alpha1 only initially; promotion to v1beta1/v1 deferred until conformance (Phase 4). -- **Webhook conversion** — none required in v1alpha1. - ---- - -## 6. Phasing roadmap - -### Phase 1 — L7 MVP - -**Scope** - -- `vngcloud-alb` GatewayClass + reconciler. -- `Gateway` reconciler (ALB only): provisions LB; listeners HTTP / HTTPS / TLS-Terminate. -- `HTTPRoute` reconciler: hostnames, path matches (Exact / PathPrefix / RegularExpression), weighted backendRefs (synthetic merged pool), `RequestRedirect` filter, listener-uniform `RequestHeaderModifier(set)` filter, `ExtensionRef → ListenerRuleConfig`. -- `LoadBalancerConfig` extension: `MergingMode`, `SSLPolicy`, `ALPNPolicy`. -- `TargetGroupConfig` CRD + validation controller. -- `ListenerRuleConfig` CRD + validation controller (Header / QueryParam / Method / SourceIP matches; Reject + Redirect actions; FixedResponse marked unsupported pending vngcloud). -- `ReferenceGrant` honored for backendRefs and TLS Secret refs. -- TLS via Secret import + cert IDs via LBC `Listener.certificateDefault.id`. -- mTLS via `frontendValidation.caCertificateRefs` + LBC `clientCertificateId`. -- Status conditions per §4. -- Events + new Prometheus metrics. -- Helm chart updates: opt-in via `gatewayApi.alb.enabled` (default `false` initially → `true` after one stable minor cycle). - -**Deliverables** - -- All [new] / [mod] files in §5.1 marked Phase 1. -- Unit + suite test coverage parity with existing Ingress controller (~70 % line). -- 5 e2e tests (basic HTTP, HTTPS+SNI, mTLS, weighted canary, cross-namespace via RG). -- 4 user docs (`gateway-api.md`, `gateway-alb.md`, `gateway-extensions.md`, `examples/gateway-canary.md`). - -**Success criteria** - -- A user can install the chart with `gatewayApi.alb.enabled=true`, apply a sample manifest, see `Gateway.status.addresses` populated, and curl through the LB. -- Deleting a Gateway cleans up the vngcloud LB; deleting a single HTTPRoute removes its policies / pools without disturbing the LB or sibling routes. -- Existing controllers behave identically before and after upgrade. -- `make test` passes; `make e2e-gateway` passes against a real cluster. - -### Phase 2 — L4 - -**Scope** - -- `vngcloud-nlb` GatewayClass + reconciler. -- `Gateway` reconciler (NLB): TCP / UDP listeners, TLS-Passthrough listeners. -- `TCPRoute`, `UDPRoute`, `TLSRoute` reconcilers. -- Reuses NLB build paths from existing `service_uc`. -- Helm chart: `gatewayApi.nlb.enabled` (default `false` until stable). - -**Deliverables** - -- All [new]/[mod] files in §5.1 marked Phase 2. -- 4 e2e tests (TCP echo, UDP DNS, TLS-Passthrough SNI routing, mixed-RouteKinds attachment). -- 2 user docs (`gateway-nlb.md`, `examples/gateway-tls-passthrough.md`). - -**Success criteria** - -- A `Gateway` with TCP/UDP listener provisions an NLB and routes traffic to the referenced backend. -- TLSRoute SNI selection works for ≥ 2 hostnames on one listener. -- Mixed L4+L7 listeners on a single Gateway are explicitly rejected. - -### Phase 3 — Extensions & advanced - -**Scope** - -- `GRPCRoute` reconciler (depends on vngcloud HTTP/2 + gRPC support; if absent, ship as Unsupported with documented reason). -- `BackendTLSPolicy` (Standard channel since Gateway API v1.2): wires to existing pool `EnableTLSEncryption`. -- Validation webhook for `LoadBalancerConfig`: reject `Listener` entries whose `protocol` / `protocolPort` conflict with the referenced Gateway listener (catches misconfig at apply time instead of via `Programmed=False` after reconcile). -- `ListenerRuleConfig`: add `FixedResponse` action iff vngcloud LB exposes it. -- Promote header / method matches from `ListenerRuleConfig.AdditionalMatches` into core HTTPRoute matches if vngcloud gains native support (LRC fields emit `Deprecated` condition and continue working). -- Optional: `RequestMirror`, `URLRewrite`, `ResponseHeaderModifier` — implemented only if vngcloud LB ships native support; otherwise stay `Unsupported`. - -**Deliverables** - -- New reconcilers + tests for whichever items are in scope. -- Updates to `gateway-extensions.md`. - -**Success criteria** - -- For each implemented Phase-3 feature: e2e test green, conformance suite picks up the new capability flag. - -### Phase 4 — Migration tool, conformance, polish - -**Scope** - -- One-shot migration: `gateway.vks.vngcloud.vn/adopt-from-ingress: /` annotation. Controller verifies the named Ingress is in the same namespace, transfers ownership labels on the existing vngcloud LB, marks the Ingress with a "migrated" finalizer (so subsequent edits are ignored), and reports `Programmed=True` against the old LB. -- Upstream Gateway API conformance suite (`test/conformance/`) green for: `Gateway`, `HTTPRoute` (Core), `ReferenceGrant`, `Mesh:false`. Documented capability matrix. -- CRD promotion path: v1alpha1 → v1beta1 (no schema changes if possible). -- Stable feature gates (default-on); doc audit. - -**Deliverables** - -- Migration controller code + `gateway-migration.md` runbook. -- Conformance test job in CI (`make gateway-conformance`). -- `docs/guide/gateway-conformance-matrix.md` listing every Gateway API feature with `Supported / Partial / Unsupported (reason)`. - -**Success criteria** - -- One existing Ingress can be cut over to a Gateway without downtime or DNS change (verified e2e). -- Conformance tests pass for every feature claimed `Supported` in the capability matrix. - ---- - -## 7. Risks & mitigations - -| Risk | Likelihood | Impact | Mitigation | -|---|---|---|---| -| **vngcloud LB API rate limits** under heavy reconciles | Med | High | Per-Gateway serialized reconcile (already designed); coalesce policy diff into one batched listener-update; existing backoff machinery in `lbc_uc` | -| **Synthetic pool weight scaling explodes integers** | Med | Med | Cap product at 100; round with bias toward larger backends; unit-test with fuzz inputs | -| **Reference-Grant denial silently drops a backend** | Med | High | `ResolvedRefs=False, reason=RefNotPermitted` + Warning event + Prometheus counter `gateway_api_refgrant_denied_total` | -| **Concurrent endpoint churn during weight rescaling causes pool flapping** | Low | Med | 2 s coalescing window when triggered by EndpointSlice change | -| **Listener cert-ID in LBC conflicts with Secret-imported cert** | Low | Low | LBC wins; document precedence; emit Warning event | -| **Unsupported-feature requests pile up** (URLRewrite, mirror, etc.) | Med | Med | `gateway_api_unsupported_feature_total` metric → product feedback to vngcloud LB team; documented capability matrix sets expectations | -| **CRD conversion needed later** | Low | Med | Plan field additions only (no removals/renames) until v1beta1 promotion; webhook conversion deferred | -| **Gateway API spec churn** between releases | Low | Med | Pin SDK to a known-good Gateway API minor version (v1.2 for Phase 1); upgrade in a dedicated PR with conformance re-run | -| **Two LB types in one cluster eat budget** (no sharing per Q4) | Med | Low | Document clearly; cost guidance in `gateway-api.md`; Phase 4 migration path lets users switch without doubling cost | -| **Existing controllers regress** during refactor of shared `Listener` struct | Low | High | Additive-only changes; full existing test suite gates the merge | -| **Field-name confusion** between LBC's `Listener` (instance config) and Gateway API's `Listener` (logical listener) | Med | Low | Section 2.1 doc table makes the join key explicit (`name`); add validation webhook in Phase 3 to reject conflicting LBC.Listener entries | - ---- - -## 8. Open items deferred to implementation - -1. Helm-chart default for `gatewayApi.alb.enabled` — propose `false` for first Phase-1 release (0.4.0); flip to `true` after one minor cycle. -2. Per-Gateway concurrency — start with `MaxConcurrentReconciles=5` (matches existing Ingress); revisit if rate-limit issues appear. -3. `Gateway.spec.addresses[]` user requests — reject for v1 (vngcloud assigns IP). Revisit if LB product gains BYOIP. -4. `infrastructure.parametersRef` (the LBC reference on a Gateway) — same-namespace only in v1, matches AWS. Cross-namespace LBC references (gated by ReferenceGrant) deferred to Phase 3. Note: this is unrelated to cross-namespace `backendRefs` and `certificateRefs`, which are honored in Phase 1. -5. Naming conflict resolution between TGCs — first conflict mode = oldest-creates wins. Revisit if customer feedback prefers explicit priority. - ---- - -## 9. Out of scope (explicitly not planned) - -- Mesh use cases (Gateway-API east-west). -- Custom GatewayClass parameters CRD beyond `LoadBalancerConfig`. -- Per-route load balancer addresses (one Gateway = one address set). -- In-place GatewayClass type swap (e.g., `vngcloud-alb` → `vngcloud-nlb` on a live Gateway). Forces recreate. - ---- - -## References - -- [Kubernetes Gateway API](https://gateway-api.sigs.k8s.io/) -- [AWS Load Balancer Controller — Gateway API](https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/gateway/gateway/) -- [AWS LBC — Gateway API customization model](https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.15/guide/gateway/customization/) -- [AWS LBC — L7 Routing](https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/gateway/l7gateway/) diff --git a/docs/superpowers/specs/2026-05-08-gateway-api-gep713-design.md b/docs/superpowers/specs/2026-05-08-gateway-api-gep713-design.md new file mode 100644 index 00000000..ee594510 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-gateway-api-gep713-design.md @@ -0,0 +1,983 @@ +# Gateway API Support — Design (GEP-713 Policy Attachment) + +**Date:** 2026-05-08 +**Status:** Draft (supersedes 2026-04-30 AWS-LBC-style design for the v3 branch) +**Scope:** Add Kubernetes Gateway API (https://gateway-api.sigs.k8s.io) support to the vngcloud Load Balancer Controller using the **GEP-713 Policy Attachment** pattern, matching the model used by GKE / Envoy Gateway / Cilium / Istio / NGINX Gateway Fabric / Kong. + +This document is a clean re-design. It does **not** reuse `LoadBalancerConfig.parametersRef`, `TargetGroupConfig.targetReference`, or HTTPRoute `filters[].extensionRef` as configuration attachment surfaces. Every vngcloud-specific knob is a Direct Policy CR with `spec.targetRefs`. + +--- + +## Decisions summary + +| # | Decision | Choice | +|---|---|---| +| 1 | Attachment pattern | **GEP-713 Direct Policy Attachment** only. No `parametersRef`, no `extensionRef`. Every CR uses `spec.targetRefs`. | +| 2 | Gateway → LB mapping | Two GatewayClasses (`vngcloud-alb`, `vngcloud-nlb`); 1 Gateway = 1 LB; mixed-protocol Gateways rejected. | +| 3 | GatewayClass-level config | **None.** GatewayClass is a controller selector only. `parametersRef` is left unused. Cluster admin defaults are not modeled in v1. | +| 4 | Policy CRDs | Four namespaced CRDs in group `gateway.vks.vngcloud.vn/v1alpha1`: `VKSGatewayPolicy`, `VKSBackendPolicy`, `VKSHealthCheckPolicy`, `VKSRoutePolicy`. | +| 5 | Conflict resolution | Direct only. One policy per `(targetRef, sectionName)` pair. Second attachment is `Conflicted=True, reason=Conflicted`; oldest-creates wins. | +| 6 | Coexistence | Strict separation; Gateway-owned LBs carry distinct owner labels; no shared LBs with Ingress / Service controllers. **BYO-LB adoption is supported** when the user explicitly sets `VKSGatewayPolicy.loadBalancerSpec.loadBalancerId` — the controller mirrors that LB's subnet/zone and the LBC controller adopts it (same path as the Ingress `load-balancer-id` annotation). The controller never *auto*-adopts. | +| 7 | Weighted backends in HTTPRoute | Synthetic merged pool with weight-scaled members (single vngcloud Pool aggregates all backends in a route rule). Implementation detail unchanged from earlier design. | +| 8 | HTTPRoute match / filter coverage | Strict reject for unsupported core features. Vngcloud-specific match/action overlay via `VKSRoutePolicy.targetRefs[].sectionName: `. No `extensionRef` filter. | +| 9 | TLS strategy | Secrets via `certificateRefs` (auto-imported, existing path). Optional vngcloud cert IDs via `VKSGatewayPolicy.listeners[].certificateIds[]`. mTLS via standard `frontendValidation` or policy-level `clientCertificateId`. | +| 10 | Phasing | Phase 1 = ALB + L7; Phase 2 = NLB + L4; Phase 3 = `BackendTLSPolicy` + `GRPCRoute`; Phase 4 = conformance + migration tool. | + +--- + +## 1. Architecture overview + +### 1.1 Controllers & GatewayClasses + +Two new controllers added to the existing manager binary, each watching a distinct GatewayClass: + +- **`vngcloud-alb` GatewayClass** — `controllerName: gateway.vks.vngcloud.vn/alb`. Provisions vngcloud ALB. Accepts listeners with protocol `HTTP`, `HTTPS`, `TLS` (mode=Terminate). Routes: `HTTPRoute`, later `GRPCRoute`. +- **`vngcloud-nlb` GatewayClass** — `controllerName: gateway.vks.vngcloud.vn/nlb`. Provisions vngcloud NLB. Accepts listeners with protocol `TCP`, `UDP`, `TLS` (mode=Passthrough). Routes: `TCPRoute`, `UDPRoute`, `TLSRoute`. + +Mixed protocols on a single Gateway are rejected with `Accepted=False, reason=UnsupportedProtocol`. 1 Gateway = 1 vngcloud LoadBalancer; no sharing. + +`GatewayClass.spec.parametersRef` is **left unset and ignored** by the controller — there is intentionally no GatewayClass-level configuration surface. Per-cluster defaults are out of scope for v1; users author one `VKSGatewayPolicy` per Gateway when they want non-defaults. + +### 1.2 Policy CRDs (overview) + +All four CRDs live in API group `gateway.vks.vngcloud.vn/v1alpha1`. All are **Direct** Policies per GEP-713: they affect only their `targetRef`, do not cascade, and use the `None` merge strategy (oldest-wins on duplicate target). + +Each CRD carries the standard CRD label so tooling discovers it: + +```yaml +metadata: + labels: + gateway.networking.k8s.io/policy: direct +``` + +| CRD | What it configures | Valid targetRef kinds | +|---|---|---| +| `VKSGatewayPolicy` | Frontend / listener properties on the LB: SSL policy, ALPN, allowed CIDRs, listener timeouts, vngcloud cert IDs, mTLS cert IDs, header injection. Optional `sectionName` scopes to one Gateway listener. | `Gateway` | +| `VKSBackendPolicy` | Backend pool properties: pool algorithm, sticky session, TLS encryption, PROXY protocol, target type (instance / ip), node-label filter. | `Service`, `ServiceImport` | +| `VKSHealthCheckPolicy` | Health check on a backend pool: protocol (HTTP/HTTPS/TCP), interval, timeout, thresholds, request path, expected codes. | `Service`, `ServiceImport` | +| `VKSRoutePolicy` | Per-rule overlay: alt actions (`Reject` / `Redirect`) and explicit position. Targets a route's `sectionName` (rule name). The vngcloud LB API supports only `HOST_NAME` and `PATH` policy rules, so additional match dimensions (header / queryParam / method / source-IP) are not exposed here. | `HTTPRoute` (later: `GRPCRoute`, `TCPRoute`, `UDPRoute`, `TLSRoute`) | + +`VKSRoutePolicy` is a vngcloud-specific extension; GKE has no equivalent because GCP's LB doesn't expose those features. We model it the same way GCP would have if it did: a Direct policy with `targetRefs` pointing at the route, scoped by `sectionName` to a specific `HTTPRoute.spec.rules[].name`. **No HTTPRoute filter is involved.** + +### 1.3 Package layout (top-level orientation) + +``` +api/gateway/v1alpha1/ # new group, all four policy CRDs + shared types +internal/controller/gateway/ # new + alb/ # alb gatewayclass + gateway + httproute reconcilers + nlb/ # phase 2 + policies/ # one validation reconciler per policy CRD + shared/ # status helpers, indexers, finalizers, eventhandlers +internal/usecase/gateway_uc/ # new + alb_gateway_uc/ # build_lb / build_listener / build_pool / build_policy + nlb_gateway_uc/ # phase 2 + shared/ # policy resolution, refgrant, hostname matching +pkg/gateway/ # utils: synth pool naming, hostname → regex +charts/.../templates/ # CRDs, GatewayClasses (gated), RBAC +``` + +Reconcilers stay thin (request → useCase). UseCases use the existing `K8sRepository` and `VngCloudRepository`. The Gateway controller is the only writer to the vngcloud LB; HTTPRoute and policy reconciles update reverse-indexes and enqueue the parent Gateway. + +### 1.4 Manager wiring + +`cmd/main.go` adds: +- Register Gateway API scheme (`sigs.k8s.io/gateway-api/apis/v1`, `v1alpha2`). +- Register `gateway.vks.vngcloud.vn/v1alpha1` scheme. +- Feature gate flags: `--enable-gateway-api-alb` (default `false` initially, `true` after stable), `--enable-gateway-api-nlb` (false until Phase 2). +- Conditional reconciler registration based on feature gates. +- New finalizer constants: `gateway.vks.vngcloud.vn/resources` (Gateway), `gateway.vks.vngcloud.vn/route` (route kinds). + +### 1.5 Coexistence boundary + +Gateway-controller-owned vngcloud LBs carry the owner label `vks.vngcloud.vn/owner-resource-kind=Gateway`. Existing Ingress / Service controllers ignore non-matching owners. No shared-LB code paths in v1. + +### 1.6 VNGCloud LB API — supported feature surface + +The CRD schemas and translation rules below are **bounded by what the vngcloud LB v2 API actually exposes**. Verified against `github.com/vngcloud/vngcloud-go-sdk/v2` request schemas (latest available SDK as of design date). + +**Supported on Listener:** `protocol` (TCP/UDP/HTTP/HTTPS), `protocolPort`, `allowedCidrs` (single comma-joined string), `defaultPoolId`, `timeoutClient/Connection/Member`, `certificateAuthorities[]` (server-cert IDs), `defaultCertificateAuthority`, `clientCertificate` (mTLS CA ID), `insertHeaders[{headerName, headerValue}]`. + +**Supported on Pool:** `poolName`, `poolProtocol` (TCP/UDP/HTTP/PROXY), `algorithm` (`ROUND_ROBIN`/`LEAST_CONNECTIONS`/`SOURCE_IP`), `stickiness *bool`, `tlsEncryption *bool`, `healthMonitor`, `members[{ipAddress, port, monitorPort, weight, backup}]`. + +**Supported on HealthMonitor:** `healthCheckProtocol` (TCP/HTTP/HTTPS/PING-UDP), thresholds, `interval`, `timeout`, `healthCheckMethod` (GET/PUT/POST), `httpVersion` (1.0/1.1), `healthCheckPath`, `domainName`, `successCode`. + +**Supported on L7 Policy:** `action` ∈ {`REJECT`, `REDIRECT_TO_URL`, `REDIRECT_TO_POOL`}. `rules[]` of `{compareType, ruleType, ruleValue}` where `compareType` ∈ {`EQUAL_TO`, `STARTS_WITH`, `ENDS_WITH`, `CONTAINS`, `REGEX`} and `ruleType` ∈ {`HOST_NAME`, `PATH`}. Plus `redirectPoolId` / `redirectUrl` / `redirectHttpCode` / `keepQueryString`. + +**Confirmed not in the API (would need vngcloud upstream changes):** + +| Feature | Surface | Phase 1 handling | +|---|---|---| +| SSL policy / TLS-version / cipher hardening | Listener | not exposed in `VKSGatewayPolicy` | +| ALPN policy | Listener | not exposed in `VKSGatewayPolicy` | +| Cookie-name + TTL session affinity | Pool | `VKSBackendPolicy.Stickiness` is `*bool` only | +| Custom request headers on health checks | HealthMonitor | not exposed in `VKSHealthCheckPolicy` | +| Header / queryParam / method / source-IP at policy rules | L7 Policy | HTTPRoute matches with these → route `Accepted=False, reason=UnsupportedMatch` | +| `FIXED_RESPONSE` action | L7 Policy | not exposed in `VKSRoutePolicy.Actions` | +| `instance` vs `ip` target type | Members | API stores IPs only; the distinction is which IPs the *controller* resolves (pod-IP vs node-IP+nodePort). Exposed as `VKSBackendPolicy.TargetType` (controller-side toggle, not an API field). | +| ProxyProtocol toggle on a pool | Pool | use `PoolProtocol=PROXY` instead | +| Health-check monitor port / HTTP method / HTTP version | HealthMonitor | **Now exposed** for Ingress parity via `VKSHealthCheckPolicy.port`, `.httpHealthCheck.method` (GET/PUT/POST), `.httpHealthCheck.httpVersion` (1.0/1.1). `port` overrides every member's `monitorPort`; method/version are HTTP/HTTPS-only (ignored for TCP) and default to GET / 1.1. | + +**Implication for HTTPRoute conformance:** `HTTPRouteMatch` has 4 dimensions — path, headers, queryParams, method. Only `path` (plus `HTTPRoute.Spec.Hostnames`) maps to a vngcloud policy rule. Routes using header / queryParam / method matching are rejected with `UnsupportedMatch`. The supported canary mechanism is weighted `backendRefs` (handled by the synth-pool weight scaler in `pkg/gateway/synth_pool.go`), not header-based routing. + +--- + +## 2. CRD schemas + +All schemas use `LocalPolicyTargetReference` (or `LocalPolicyTargetReferenceWithSectionName` where sub-object scoping is supported), the standard types from `sigs.k8s.io/gateway-api/apis/v1alpha2`. We do not invent a custom target reference shape. + +### 2.1 Common types + +```go +// LocalPolicyTargetReference = re-export from sigs.k8s.io/gateway-api/apis/v1alpha2. +// LocalPolicyTargetReferenceWithSectionName = same plus an optional SectionName. + +// PolicyAncestorStatus mirrors gateway-api's PolicyAncestorStatus shape. +type PolicyStatus struct { + Ancestors []gwv1alpha2.PolicyAncestorStatus `json:"ancestors,omitempty"` +} +``` + +Standard status reasons used across all four CRDs: + +| Reason | Meaning | +|---|---| +| `Accepted` | Policy is recognized, target exists, schema valid, no conflict. | +| `Conflicted` | Another policy of the same kind already targets `(targetRef, sectionName)`; this one is shadowed. | +| `Invalid` | Schema valid but values are nonsensical for the target (e.g. listener-only fields on a Gateway target with no `sectionName`). | +| `TargetNotFound` | The `targetRef` does not resolve. | +| `NoReadyController` | No controller of the matching `controllerName` is currently running (informational; helps debug feature-gated installs). | + +### 2.2 `VKSGatewayPolicy` + +Direct policy on a `Gateway`. Optional `sectionName` scopes the policy to a single listener. + +```go +// +kubebuilder:resource:shortName=vksgwpolicy,categories=gateway-api +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +type VKSGatewayPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec VKSGatewayPolicySpec `json:"spec,omitempty"` + Status VKSGatewayPolicyStatus `json:"status,omitempty"` +} + +type VKSGatewayPolicySpec struct { + // TargetRefs identify the Gateway (and optionally a single listener) this policy applies to. + // Same-namespace only. Multiple targets allowed (max 16) — useful for applying identical + // settings to a fleet of Gateways. SectionName, when set, scopes to one listener by name. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []gwv1alpha2.LocalPolicyTargetReferenceWithSectionName `json:"targetRefs"` + + // Listener-level fields. Honored only when SectionName matches a listener. + // If SectionName is unset on the targetRef, these apply as the Gateway's listener defaults + // (every listener on the target Gateway uses them unless a more specific policy attaches + // to that listener via SectionName). + AllowedCIDRs []string `json:"allowedCidrs,omitempty"` + InsertHeaders map[string]string `json:"insertHeaders,omitempty"` + TimeoutClient *metav1.Duration `json:"timeoutClient,omitempty"` + TimeoutMember *metav1.Duration `json:"timeoutMember,omitempty"` + TimeoutConnection *metav1.Duration `json:"timeoutConnection,omitempty"` + + // Vngcloud cert ID overrides. When set, override Secret-based certificateRefs on the + // matching listener; otherwise certificateRefs continue to be honored (existing import path). + CertificateIDs []string `json:"certificateIds,omitempty"` // server cert(s) + ClientCertificateID *string `json:"clientCertificateId,omitempty"` // mTLS CA, mutually exclusive with frontendValidation + + // Gateway-level fields. Honored only when SectionName is unset (or controller derives them + // from the unscoped policy on the Gateway). + LoadBalancerSpec *VKSLoadBalancerSpec `json:"loadBalancerSpec,omitempty"` +} + +type VKSLoadBalancerSpec struct { + Scheme *string `json:"scheme,omitempty"` // Internet | Internal | InterVPC + PackageID *string `json:"packageId,omitempty"` + SubnetID *string `json:"subnetId,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + LoadBalancerID *string `json:"loadBalancerId,omitempty"` // BYO LB +} + +type VKSGatewayPolicyStatus struct { + PolicyStatus `json:",inline"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} +``` + +**Conflict rule.** For a given `(Gateway, sectionName)` only one `VKSGatewayPolicy` may attach. A second one is `Accepted=False, Conflicted=True`; the older one continues to apply. `Gateway`-without-`sectionName` and `Gateway`-with-`sectionName` are different conflict scopes — a Gateway may have one unscoped policy plus per-listener policies. + +**Field precedence within a Gateway.** Per-listener policy (with `sectionName`) wins over unscoped policy on the same Gateway, field by field. Both are still Direct (no inherited cascade); the controller resolves the effective listener config at reconcile time. + +### 2.3 `VKSBackendPolicy` + +Direct policy on a `Service` or `ServiceImport`. + +```go +// +kubebuilder:resource:shortName=vksbpolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +type VKSBackendPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec VKSBackendPolicySpec `json:"spec,omitempty"` + Status VKSBackendPolicyStatus `json:"status,omitempty"` +} + +type VKSBackendPolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []gwv1alpha2.LocalPolicyTargetReference `json:"targetRefs"` + + // TargetType selects which addresses the controller resolves into pool + // members. "ip" puts pod IPs in the pool (works on flat networks / + // Cilium native routing). "instance" puts node IPs + nodePort in the + // pool (works on overlay networks where pod IPs aren't routable from + // the cloud LB). Defaults to "instance" — same default the Ingress + // controller uses today. + // + // Controller-side translation toggle, not a vngcloud LB API field; + // the resolved member IPs are what land on the cloud pool. See §1.6. + // + // +kubebuilder:validation:Enum=instance;ip + TargetType *string `json:"targetType,omitempty"` + + // +kubebuilder:validation:Enum=ROUND_ROBIN;LEAST_CONNECTIONS;SOURCE_IP + PoolAlgorithm *string `json:"poolAlgorithm,omitempty"` + + // Stickiness enables sticky sessions. The vngcloud LB API exposes only an + // on/off flag; cookie name and TTL are not configurable. + Stickiness *bool `json:"stickiness,omitempty"` + + EnableTLSEncryption *bool `json:"enableTLSEncryption,omitempty"` + TargetNodeLabels map[string]string `json:"targetNodeLabels,omitempty"` + ManageDFPMembers *bool `json:"manageDFPMembers,omitempty"` +} + +type VKSBackendPolicyStatus struct { + PolicyStatus `json:",inline"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} +``` + +**Conflict rule.** One `VKSBackendPolicy` per Service. Second attachment → `Conflicted=True`; oldest wins. + +### 2.4 `VKSHealthCheckPolicy` + +Split out from BackendPolicy because a health-check is often defined by a different team (platform / SRE) than the backend tuning (app team). Direct on Service or ServiceImport. + +```go +// +kubebuilder:resource:shortName=vkshcpolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +type VKSHealthCheckPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec VKSHealthCheckPolicySpec `json:"spec,omitempty"` + Status VKSHealthCheckPolicyStatus `json:"status,omitempty"` +} + +type VKSHealthCheckPolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []gwv1alpha2.LocalPolicyTargetReference `json:"targetRefs"` + + // +kubebuilder:validation:Enum=HTTP;HTTPS;TCP + Protocol string `json:"protocol"` + Interval *metav1.Duration `json:"interval,omitempty"` + Timeout *metav1.Duration `json:"timeout,omitempty"` + HealthyThreshold *int32 `json:"healthyThreshold,omitempty"` + UnhealthyThreshold *int32 `json:"unhealthyThreshold,omitempty"` + + HTTPHealthCheck *VKSHTTPHealthCheck `json:"httpHealthCheck,omitempty"` +} + +type VKSHTTPHealthCheck struct { + Path *string `json:"path,omitempty"` + Host *string `json:"host,omitempty"` + ExpectedCodes []string `json:"expectedCodes,omitempty"` // e.g. ["200-299","301"] +} +``` + +**Conflict rule.** One `VKSHealthCheckPolicy` per Service. Second attachment → `Conflicted=True`; oldest wins. + +### 2.5 `VKSRoutePolicy` + +Direct policy on an `HTTPRoute` (and later other route kinds). `sectionName` scopes to a single rule by `HTTPRoute.spec.rules[].name`. Multiple `VKSRoutePolicy` objects may attach to the same route as long as they target distinct `(targetRef, sectionName)` pairs. + +```go +// +kubebuilder:resource:shortName=vksroutepolicy,categories=gateway-api +// +kubebuilder:metadata:labels=gateway.networking.k8s.io/policy=direct +type VKSRoutePolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec VKSRoutePolicySpec `json:"spec,omitempty"` + Status VKSRoutePolicyStatus `json:"status,omitempty"` +} + +type VKSRoutePolicySpec struct { + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + TargetRefs []gwv1alpha2.LocalPolicyTargetReferenceWithSectionName `json:"targetRefs"` + + // Actions, when set, supersede the rule's default REDIRECT_TO_POOL action. + // The vngcloud LB API supports REJECT and REDIRECT_TO_URL only. + // +optional + Actions []VKSRuleAction `json:"actions,omitempty"` + + // Position pins policy ordering on the listener. Default = controller-assigned + // (Gateway-API spec match-specificity ordering). + // +optional + Position *int32 `json:"position,omitempty"` +} + +type VKSRuleAction struct { + // +kubebuilder:validation:Enum=Reject;Redirect + Type string `json:"type"` + Redirect *VKSRedirectAction `json:"redirect,omitempty"` +} + +type VKSRedirectAction struct { + URL string `json:"url"` + HTTPCode *int32 `json:"httpCode,omitempty"` + KeepQueryString *bool `json:"keepQueryString,omitempty"` +} +``` + +**Conflict rule.** Two `VKSRoutePolicy` objects targeting the same `(HTTPRoute, sectionName)` conflict — second is `Conflicted=True`. Different `sectionName` values do not conflict. A policy with no `sectionName` applies to **every** rule in the route and conflicts with any policy that scopes to a specific rule on the same route. + +**Required route schema:** users must give every targeted rule a `name` in `HTTPRoute.spec.rules[].name`. The controller writes `Accepted=False, reason=TargetNotFound` if a `sectionName` doesn't match any rule. + +**Forward-compat note.** When vngcloud LB later adds native header / query / method matching, the controller will start honoring those dimensions of `HTTPRoute.matches` instead of rejecting routes with `UnsupportedMatch`. No breakage. + +### 2.6 RBAC additions + +``` ++kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch ++kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses/status,verbs=update;patch ++kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch;update;patch ++kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/status,verbs=update;patch ++kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/finalizers,verbs=update ++kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes;referencegrants,verbs=get;list;watch ++kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/status,verbs=update;patch ++kubebuilder:rbac:groups=gateway.vks.vngcloud.vn,resources=vksgatewaypolicies;vksbackendpolicies;vkshealthcheckpolicies;vksroutepolicies,verbs=get;list;watch;update;patch ++kubebuilder:rbac:groups=gateway.vks.vngcloud.vn,resources=vksgatewaypolicies/status;vksbackendpolicies/status;vkshealthcheckpolicies/status;vksroutepolicies/status,verbs=update;patch +``` + +(Phase 2 adds `tcproutes`, `udproutes`, `tlsroutes`; Phase 3 adds `grpcroutes`, `backendtlspolicies`.) + +--- + +## 3. Resource mapping & data flow + +### 3.1 Mapping table — Gateway API + VKS Policies → vngcloud primitives + +| Gateway API resource | vngcloud entity | Notes | +|---|---|---| +| `GatewayClass` (`gateway.vks.vngcloud.vn/alb`) | none (declarative gate) | `Accepted=True` once controller is registered. No `parametersRef` consumed. | +| `GatewayClass` (`gateway.vks.vngcloud.vn/nlb`) | none | same; Phase 2 | +| `Gateway` | 1 `LoadBalancer` (ALB or NLB) + N `Listener` | LB type fixed by GatewayClass; 1:1 binding via owner labels; LB-level config from a `VKSGatewayPolicy` with no `sectionName` | +| `Gateway.spec.listeners[]` | vngcloud `Listener` | TLS / port / protocol from spec; per-listener overrides from `VKSGatewayPolicy` with `sectionName: ` | +| `Gateway.spec.addresses` | informational only | vngcloud assigns address; user-requested IP rejected unless feature exists | +| `HTTPRoute` (per parentRef listener) | 1+ `Policy` + 1+ `Pool` (synthetic) | One policy per `rule × match` (cartesian when multi-match) | +| `HTTPRoute.hostnames[]` | `HOST_NAME` rule on each generated policy | `*.foo.com` → `REGEX` rule | +| `HTTPRoute.rule.matches[].path` | `PATH` rule (EQUAL_TO / STARTS_WITH / REGEX) | Implementation-specific path types fall back to EQUAL_TO | +| `HTTPRoute.rule.backendRefs[]` (weighted) | 1 synthetic `Pool` (members from union) | Weight-scaled members; pool name = stable hash | +| `HTTPRoute.rule.filters[RequestRedirect]` | `Policy.Action = REDIRECT_TO_URL` | URL templated from filter fields | +| `HTTPRoute.rule.filters[RequestHeaderModifier]` (set) | listener `insertHeaders` | Only if uniform across rules; otherwise reject | +| `ReferenceGrant` | gating only | Resolves cross-namespace `backendRefs` and TLS Secret refs | +| `VKSGatewayPolicy` (`targetRefs.kind=Gateway`) | LB + listener config | Direct; per-listener via `sectionName` | +| `VKSBackendPolicy` (`targetRefs.kind=Service`) | Pool config | Direct; one per Service | +| `VKSHealthCheckPolicy` (`targetRefs.kind=Service`) | Pool health check | Direct; one per Service | +| `VKSRoutePolicy` (`targetRefs.kind=HTTPRoute`) | Alt policy action (Reject / Redirect) + position override | Direct; per-rule via `sectionName`. No additional-match dimensions — vngcloud rules support only `HOST_NAME` and `PATH`. | + +### 3.2 Controller graph + +``` +┌──────────────────────────┐ +│ GatewayClassController │ (registration + status only) +└──────────────────────────┘ + +┌──────────────────────────┐ parentRef +│ GatewayController │◄─────────── HTTPRoute +│ (per GatewayClass) │ +└────────────┬─────────────┘ + │ owns + ▼ + vngcloud LoadBalancer + Listeners + Policies + Pools + +┌──────────────────────────┐ resolves at reconcile +│ Policy resolver (in UC) │──── reads ─► VKSGatewayPolicy (by Gateway ref) +└──────────────────────────┘──── reads ─► VKSBackendPolicy (by Service ref) + ──── reads ─► VKSHealthCheckPolicy (by Service ref) + ──── reads ─► VKSRoutePolicy (by Route ref + sectionName) + +┌──────────────────────────┐ +│ Policy validators │──── enqueue ─► Gateway / HTTPRoute +│ (one per policy CRD, │ (the targets they affect) +│ status-only writers) │ +└──────────────────────────┘ +``` + +**Ownership model.** The Gateway controller is the only writer to the vngcloud LB. HTTPRoute and policy reconciles do not touch vngcloud directly — they resolve their indexed targets and enqueue the owning Gateway. This serializes all LB mutations through one reconciler per Gateway and avoids races. + +**Policy validators** run in their own controllers but write status only. They never write to the LB. They enqueue affected Gateways so a real reconcile applies the change. + +### 3.3 Gateway → LoadBalancer + Listeners + +Reconcile order per Gateway: + +1. **Resolve effective Gateway-level config.** Lookup all `VKSGatewayPolicy` whose `targetRefs[].kind=Gateway` matches this Gateway and whose `sectionName` is unset; pick the oldest (`Accepted=True` for it, others `Conflicted=True`). Use its `LoadBalancerSpec` and listener-default fields. +2. **Validate listeners.** Protocol allowed by GatewayClass type? Port unique? Hostname valid? Set per-listener `Accepted` condition. +3. **Resolve effective per-listener config.** For each listener: lookup `VKSGatewayPolicy` whose `targetRefs[].sectionName` matches the listener's name; pick oldest. Fall back to the unscoped Gateway-level policy's listener-default fields. +4. **Lookup or create LB.** By owner label `gateway.vks.vngcloud.vn/owner-uid=`. Type = ALB or NLB. Apply LB-level config from step 1. +5. **Compute desired Listener set.** For each `Gateway.spec.listeners[]` with `Accepted=True`: + - protocol / port from Gateway listener + - TLS: prefer `VKSGatewayPolicy.CertificateIDs` if present, else import Secrets from `tls.certificateRefs` (existing import path) + - mTLS: `VKSGatewayPolicy.ClientCertificateID` if set, else import from `tls.frontendValidation.caCertificateRefs` + - other fields (timeouts, allowedCidrs, insertHeaders) from the resolved listener policy + - default pool: synthetic "default-forwarding-pool" with no members (fed by route layer) +6. **Diff against vngcloud.** Reuse existing per-listener helpers in `internal/usecase/lbc_uc/deploy_listener.go`. +7. **Trigger route layer.** Emit policy / pool sync for every accepted route attached to this Gateway. +8. **Write status.** Gateway and listener conditions per §4. + +### 3.4 HTTPRoute → Policies + Pools + +For each HTTPRoute attached to an accepted Gateway listener: + +``` +for each rule in route.spec.rules: + backendPolicy = resolveBackendPolicy(rule.backendRefs[0..n]) + healthCheck = resolveHealthCheckPolicy(rule.backendRefs[0..n]) + pool = synthesizePool(rule.backendRefs, backendPolicy, healthCheck, route, ruleIdx) + routePolicies = lookupRoutePolicies(route, rule.name) // 0..n with same sectionName + for each match in rule.matches: + for each hostname in route.hostnames (or [""] if empty): + policy = buildPolicy(hostname, match, rule.filters, routePolicies, pool) + policies.append(policy) +``` + +**One policy per (hostname × match × resolved-action).** L7 rules — only the dimensions the vngcloud API supports: + +- `HOST_NAME` per hostname (literal → `EQUAL_TO`; wildcard → `REGEX`) +- `PATH` per `match.path` +- Action precedence: + - `VKSRoutePolicy.Actions[Reject|Redirect]` → respective vngcloud action (`REJECT` / `REDIRECT_TO_URL`) + - `RequestRedirect` filter → `REDIRECT_TO_URL` + - default → `REDIRECT_TO_POOL` pointing at the synthetic pool + +**Match-dimension rejection.** If any `HTTPRouteMatch` in the route uses `headers`, `queryParams`, or `method`, the route is rejected with `Accepted=False, reason=UnsupportedMatch` per parent — these dimensions don't have a vngcloud rule equivalent, and silently dropping them produces traffic surprises. Path + hostname matching alone is honored; weighted `backendRefs` are the supported canary mechanism. + +**Policy ordering.** Gateway API spec: most-specific path first, then header count, then route creation timestamp. `VKSRoutePolicy.Position` overrides this order if set. Existing `auto-reorder-policies` machinery is reused. + +**Cross-namespace backendRefs.** Gated by ReferenceGrant. If the grant is absent, set `ResolvedRefs=False, reason=RefNotPermitted` on the route and drop just that backend from the synthesized pool (don't fail the whole rule). + +### 3.5 Synthetic pool — naming & weight scaling + +Same algorithm as the prior design — this is implementation, not attachment. + +**Name** (deterministic, ≤ 50 chars): + +``` +gw___ +``` + +**Weight scaling.** Floor 1; cap product at 100; `member_weight = round(w_b * scale / n_b)` with `scale = lcm(n_b) / gcd(weights)`. + +**Health check resolution** for the synthetic pool, single config picked in priority order: + +1. `VKSHealthCheckPolicy` attached to **any** of the rule's backend Services. If multiple rules' backends carry conflicting policies → `ResolvedRefs=False, reason=BackendConfigMismatch` on the route. +2. Controller fallback (TCP check on backend port). + +Conflict detection runs at the route level — if backend Services A and B carry different `VKSHealthCheckPolicy` configs, the route fails closed instead of silently picking one. + +### 3.6 Policy attachment resolver — pseudocode + +```go +// resolveDirectPolicy returns the oldest accepted policy whose targetRefs match (kind, name, sectionName). +// Other matches get Accepted=False, Conflicted=True. +func resolveDirectPolicy[P PolicyObj](policies []P, target ref) (winner *P, losers []*P) { + matches := filter(policies, p => p.matchesTarget(target)) + if len(matches) == 0 { return nil, nil } + sort.SliceStable(matches, byCreationTimeThenName) // oldest, then ns/name lex + return matches[0], matches[1:] +} +``` + +Status updates for losers happen in the policy validator controllers, not in the Gateway use-case (which only consumes the resolved view). + +### 3.7 Watch & reverse-index map + +| Watch source | Triggers requeue of | +|---|---| +| `GatewayClass` | Self | +| `Gateway` | Self + all routes whose `parentRefs` resolve to it | +| `HTTPRoute` | Self + parent Gateway | +| `Service` | All HTTPRoutes whose `backendRefs` include it + parent Gateway | +| `EndpointSlice` | Same as Service | +| `Secret` (TLS) | All Gateways whose `certificateRefs` / `frontendValidation` include it | +| `ReferenceGrant` | All routes / Gateways whose cross-ns refs were previously denied | +| `VKSGatewayPolicy` | All Gateways listed in `targetRefs[].name` | +| `VKSBackendPolicy` | All HTTPRoutes whose `backendRefs` include any `targetRefs[].name` Service + their parent Gateways | +| `VKSHealthCheckPolicy` | Same as `VKSBackendPolicy` | +| `VKSRoutePolicy` | All HTTPRoutes named in `targetRefs[].name` + their parent Gateways | + +Indexes via controller-runtime `Manager.GetFieldIndexer()` and `pkg/gateway/reference_indexer.go`. + +### 3.8 Eventual-consistency notes + +- A change to one HTTPRoute or any policy CR requeues only its parent Gateway → Gateway reconcile rebuilds the full policy / pool set for that LB. Idempotent diff drives create / update / delete. +- Per-Gateway reconciles serialized by controller-runtime work queues. +- Status updates batched at end of reconcile (one PATCH per object) to minimize API churn. +- Long-running LB ops (provisioning, listener create) reuse existing requeue-with-backoff machinery from `lbc_uc`. + +--- + +## 4. Status conditions, events, metrics + +> **Implementation status (Phase 1): all of §4.1 below is implemented.** +> - `GatewayClass` + `Gateway` (Accepted/Programmed/addresses/listeners) — `internal/controller/gateway/alb`. +> - `HTTPRoute.status.parents[]` (Accepted / ResolvedRefs / PartiallyInvalid) — written each Gateway reconcile by `writeRouteStatuses` in `internal/usecase/gateway_uc/alb_gateway_uc/route_status.go`; idempotent, owns only this controller's parent entry. +> - The four policy CRDs' status (Accepted / Conflicted / TargetNotFound + `PolicyAncestorStatus`) — `internal/controller/gateway/policies` (one generic Direct-policy validator instantiated per CRD; status-only, never writes the LB). +> +> Earlier "Phase F" / "follow-up" markers in code comments referring to route/policy status are superseded by this. + +### 4.1 Status conditions + +#### `GatewayClass.status.conditions` + +| Type | True | False | Reason | +|---|---|---|---| +| `Accepted` | controller registered for this className | controller missing or feature-gated off | `Accepted` / `Pending` / `Disabled` | +| `SupportedVersion` | controller version supports the Gateway API version | version skew | `SupportedVersion` / `UnsupportedVersion` | + +#### `Gateway.status.conditions` + +| Type | True | False | Reason | +|---|---|---|---| +| `Accepted` | spec valid; this controller owns the class | invalid spec / unsupported feature / class not accepted | `Accepted` / `InvalidParameters` / `NotReconciled` / `UnsupportedAddress` | +| `Programmed` | vngcloud LB created, listeners synced, addresses populated | LB provisioning failed; listener creation failed | `Programmed` / `Pending` / `Invalid` / `NoResources` / `VngcloudAPIError` | + +#### `Gateway.status.listeners[].conditions` + +| Type | True | False | Reason | +|---|---|---|---| +| `Accepted` | protocol allowed for this GatewayClass; port unique within Gateway | mixed protocols / dup port / unsupported feature | `Accepted` / `UnsupportedProtocol` / `PortUnavailable` / `Invalid` | +| `Programmed` | listener exists on vngcloud LB | not yet created / failed | `Programmed` / `Pending` / `Invalid` | +| `ResolvedRefs` | all `certificateRefs` and `frontendValidation.caCertificateRefs` resolved | grant missing / Secret not found / unsupported group | `ResolvedRefs` / `RefNotPermitted` / `InvalidCertificateRef` / `InvalidCACertificateRef` | +| `Conflicted` | always `False` in v1 (no shared LBs) | n/a | `NoConflicts` | + +`Gateway.status.listeners[].attachedRoutes` recomputed each Gateway reconcile. + +`Gateway.status.addresses[]` populated from vngcloud LB once `Programmed=True`. + +#### `HTTPRoute.status.parents[].conditions` (per parentRef) + +| Type | True | False | Reason | +|---|---|---|---| +| `Accepted` | parent Gateway exists, allows route kind, namespace allowed | listener restricts kind / ns / hostname | `Accepted` / `NotAllowedByListeners` / `NoMatchingListenerHostname` / `NoMatchingParent` | +| `ResolvedRefs` | all `backendRefs` resolved | backend missing / wrong kind / cross-ns denied | `ResolvedRefs` / `BackendNotFound` / `RefNotPermitted` / `InvalidKind` | +| `PartiallyInvalid` (when at least one rule programmed but some dropped) | per spec | n/a | `UnsupportedValue` | + +#### Policy CR status — common to all four CRDs + +| Type | True | False | Reason | +|---|---|---|---| +| `Accepted` | target found, schema valid, no conflict | conflict / target missing / invalid for context | `Accepted` / `Conflicted` / `Invalid` / `TargetNotFound` | +| `Programmed` | target Gateway has been reconciled including this policy | pending Gateway reconcile / Gateway controller absent | `Programmed` / `Pending` / `NoReadyController` | + +`PolicyAncestorStatus` (one entry per `controllerName` that handled this policy) — populated on every policy CR. Allows future GLB / cross-controller scenarios without schema change. + +### 4.2 Kubernetes Events + +| Reconciler | Event reason examples | +|---|---| +| GatewayClass | `Accepted`, `Pending`, `Disabled` | +| Gateway | `LoadBalancerCreated`, `ListenerCreated`, `LoadBalancerProvisioningFailed`, `PolicyConflict` | +| HTTPRoute | `RouteAttached`, `RouteDetached`, `BackendDropped`, `UnsupportedFilter`, `BackendConfigMismatch` | +| VKSGatewayPolicy | `Conflicted`, `TargetNotFound`, `Applied` | +| VKSBackendPolicy | `Conflicted`, `TargetNotFound`, `Applied` | +| VKSHealthCheckPolicy | `Conflicted`, `TargetNotFound`, `Applied` | +| VKSRoutePolicy | `Conflicted`, `TargetNotFound`, `RuleNotFound`, `Applied` | + +### 4.3 Prometheus metrics + +``` +# counters +gateway_api_reconcile_total{controller,result} +gateway_api_resource_count{kind,namespace,gateway_class} +gateway_api_listener_count{gateway_class,protocol} +gateway_api_route_attached_total{gateway_class} +gateway_api_unsupported_feature_total{kind,feature} +gateway_api_refgrant_denied_total{namespace,kind} +gateway_api_policy_conflict_total{kind} + +# histograms +gateway_api_reconcile_duration_seconds{controller,result} +gateway_api_lb_provisioning_seconds{gateway_class} + +# gauges +gateway_api_lb_total{gateway_class,scheme} +gateway_api_synthetic_pool_total +gateway_api_policy_attached_total{kind,target_kind} +``` + +### 4.4 Logging + +Reuses existing `contexts` package. Log-name conventions: `gw//`, `httproute//`, `gwc/`, `vksgwp//`, `vksbp//`, `vkshcp//`, `vksrp//`. + +### 4.5 Finalizer strategy + +| Resource | Finalizer | Cleanup action | +|---|---|---| +| `Gateway` | `gateway.vks.vngcloud.vn/resources` | Delete vngcloud LB, drop owner labels, deindex routes | +| `HTTPRoute` | `gateway.vks.vngcloud.vn/route` | Trigger parent Gateway reconcile | +| `VKS*Policy` | none | Pure config; deletion = recompute downstream Gateway | + +Deletion ordering: HTTPRoutes detach → parent Gateway reconciles → finalizer removed when policies / pools gone. Gateway finalizer removed last when LB cleanup completes. + +### 4.6 Readiness / liveness + +No probe-endpoint changes. New reconcilers respect the `initDone` atomic-bool gate pattern (1s requeue while uninitialized). Fail-open on probe. + +--- + +## 5. Implementation plan & file layout + +New files marked **[new]**, modified existing files marked **[mod]**. + +``` +api/ +└── gateway/v1alpha1/ [new group] + ├── groupversion_info.go [new] + ├── vksgatewaypolicy_types.go [new] + ├── vksbackendpolicy_types.go [new] + ├── vkshealthcheckpolicy_types.go [new] + ├── vksroutepolicy_types.go [new] + ├── shared_types.go [new] — re-exports of LocalPolicyTargetReference, status helpers + └── zz_generated.deepcopy.go [new generated] + +cmd/ +└── main.go [mod] register Gateway API + vks scheme, + feature gates, new reconcilers + +internal/controller/ +└── gateway/ [new] + ├── shared/ [new] + │ ├── classifier.go protocol → GatewayClass type, listener validation + │ ├── status.go condition helpers (Direct + Ancestor) + │ ├── reference_indexer.go reverse indexes (Service, Secret, RG, all 4 policies) + │ ├── finalizer.go + │ ├── policy_resolver.go Direct policy resolution (oldest-wins) + │ ├── policy_order.go Gateway API match-specificity ordering + │ └── eventhandlers/ cross-resource enqueue helpers + ├── alb/ [new — Phase 1] + │ ├── gatewayclass_controller.go + │ ├── gateway_controller.go + │ ├── httproute_controller.go + │ ├── *_test.go + │ └── suite_test.go + ├── nlb/ [new — Phase 2] + │ └── ... + └── policies/ [new — Phase 1, validation only] + ├── vksgatewaypolicy_controller.go + ├── vksbackendpolicy_controller.go + ├── vkshealthcheckpolicy_controller.go + ├── vksroutepolicy_controller.go + └── *_test.go + +internal/usecase/ +├── contracts.go [mod] add GatewayClassUseCase, GatewayUseCase, RouteUseCase +├── mocks.go [mod] regenerated +├── gateway_uc/ [new] +│ ├── shared/ [new] +│ │ ├── policy_resolver.go cascading lookups for the 4 policy kinds +│ │ ├── refgrant.go ReferenceGrant evaluation +│ │ └── *_test.go +│ ├── alb_gateway_uc/ [new — Phase 1] +│ │ ├── gateway_uc.go Init / Ensure / Delete +│ │ ├── build_lb.go Gateway → vngcloud LB params (uses VKSGatewayPolicy) +│ │ ├── build_listener.go listener policy resolution + listener build +│ │ ├── build_pool.go synthetic pool (uses VKSBackendPolicy + VKSHealthCheckPolicy) +│ │ ├── build_policy.go HTTPRoute rule → vngcloud Policy (uses VKSRoutePolicy) +│ │ ├── build_cert.go Secret import or cert-ID +│ │ ├── build_sec_group.go inherits NSG behavior from Ingress +│ │ ├── status.go +│ │ └── *_test.go +│ └── nlb_gateway_uc/ [new — Phase 2] + +internal/repository/ +├── k8s_repo/ unchanged in v1 +├── vngcloud_repo/ unchanged +└── mocks.go [mod] regenerated + +internal/domain/ +└── domain.go [mod] add GatewayFinalizer, owner-kind constants + +pkg/ +├── consts/consts.go [mod] new GatewayClass controller-name constants +├── metrics/util/reconcile_counters.go [mod] add Gateway / HTTPRoute / Policy counters +└── gateway/ [new] + ├── gatewayapi_utils.go hostname matchers, wildcard → regex, proto helpers + ├── synth_pool.go pool-name hashing helpers + └── *_test.go + +charts/vngcloud-load-balancer-controller/ +├── templates/ +│ ├── crds/ [mod] add the 4 policy CRDs +│ ├── gatewayclass-alb.yaml [new — Phase 1] +│ ├── gatewayclass-nlb.yaml [new — Phase 2] +│ ├── rbac/ [mod] +│ └── manager-deployment.yaml [mod] feature-gate flags +└── values.yaml [mod] gatewayApi.{alb,nlb}.enabled toggles + +config/ [mod throughout — kubebuilder-managed] + +docs/ [mod throughout] +├── guide/ +│ ├── gateway-api.md [new] +│ ├── gateway-alb.md [new — Phase 1] +│ ├── gateway-nlb.md [new — Phase 2] +│ └── gateway-policies.md [new — Phase 1] — the 4 policy CRDs explained +└── examples/ [new] + +test/ +├── e2e/gateway/ [new] +└── conformance/ [new — Phase 4] + +go.mod [mod] add sigs.k8s.io/gateway-api +PROJECT [mod] register new resources via kubebuilder +``` + +### 5.1 New Go module dependencies + +```go +require ( + sigs.k8s.io/gateway-api v1.2.0 // Standard channel covering HTTPRoute / GRPCRoute / RG / + // PolicyAncestorStatus types +) +``` + +### 5.2 Manager wiring (`cmd/main.go` deltas) + +```go +import ( + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + vksgwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + albgw "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/alb" + policiesctrl "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/policies" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/alb_gateway_uc" +) + +utilruntime.Must(gwv1.Install(scheme)) +utilruntime.Must(gwv1alpha2.Install(scheme)) +utilruntime.Must(vksgwv1alpha1.AddToScheme(scheme)) + +flag.BoolVar(&cfg.Gateway.ALBEnabled, "enable-gateway-api-alb", false, "Enable the ALB Gateway API controller (Phase 1).") +flag.BoolVar(&cfg.Gateway.NLBEnabled, "enable-gateway-api-nlb", false, "Enable the NLB Gateway API controller (Phase 2+).") + +if cfg.Gateway.ALBEnabled { + albUC := alb_gateway_uc.NewALBGatewayUseCase(...) + if err := albgw.NewGatewayClassReconciler(...).SetupWithManager(mgr); err != nil { ... } + if err := albgw.NewGatewayReconciler(albUC, ...).SetupWithManager(mgr); err != nil { ... } + if err := albgw.NewHTTPRouteReconciler(albUC, ...).SetupWithManager(mgr); err != nil { ... } + + // Policy validators always run when any gateway controller is on. + for _, r := range policiesctrl.AllReconcilers(...) { + if err := r.SetupWithManager(mgr); err != nil { ... } + } +} +``` + +### 5.3 Mocking & test strategy + +**Unit tests** — table-driven, gomock-generated mocks for `K8sRepository` / `VngCloudRepository`: +- `policy_resolver_test.go` — oldest-wins semantics, sectionName scoping, conflict status emission. +- `build_listener_test.go` — listener-policy precedence, cert ID vs Secret precedence, mTLS resolution. +- `build_pool_test.go` — synthetic-pool naming determinism, weight scaling, RG-denied-backend dropping, conflicting health-check detection. +- `build_policy_test.go` — match cartesian, position ordering, VKSRoutePolicy additional-match composition, action override precedence. +- `refgrant_test.go` — allow / deny matrix; covers Secrets, Services, custom CRDs. + +**Suite tests** (envtest) — one per reconciler, mirroring `internal/controller/networking/suite_test.go`. Validates reconcile-on-event for each watch. + +**E2E tests** — under `test/e2e/gateway/`, run against a real vngcloud LB. Gated by env var. + +**Conformance** — `test/conformance/conformance_test.go` runs the upstream Gateway API conformance suite. Phase 4. + +### 5.4 Codegen / make targets + +Existing `make generate manifests test` covers all generation. Same `gateway-conformance` target as the prior design. + +### 5.5 Backward-compat / rollout safety + +- **No new fields on `LoadBalancerConfig` for vngcloud features that don't exist** (e.g. `SSLPolicy`, `ALPNPolicy`, cookie-name session affinity). Phase 1 rolls out the Gateway translator on the LBC schema as it stands today. If the underlying vngcloud LB API later gains those features, the LBC schema and Gateway translator extend together. +- **Feature gates default-off initially** — opt-in upgrade. ALB flips to `true` after one minor cycle in production. +- **CRD bundle versioning** — Helm chart's `Chart.yaml` minor-version bumped per phase. Policy CRDs ship at `v1alpha1` only initially; promotion to `v1beta1`/`v1` deferred until conformance (Phase 4). +- **Webhook conversion** — none required in v1alpha1. + +--- + +## 6. Phasing roadmap + +### Phase 1 — L7 MVP + +**Scope** + +- `vngcloud-alb` GatewayClass + reconciler. +- `Gateway` reconciler (ALB only): provisions LB; listeners HTTP / HTTPS / TLS-Terminate. +- `HTTPRoute` reconciler: hostnames, path matches (Exact / PathPrefix / RegularExpression), weighted backendRefs (synthetic merged pool), `RequestRedirect` filter, listener-uniform `RequestHeaderModifier(set)` filter. +- All four `VKS*Policy` CRDs + their validation reconcilers (Direct, oldest-wins). +- `ReferenceGrant` honored for backendRefs and TLS Secret refs. +- TLS via Secret import + cert IDs via `VKSGatewayPolicy.CertificateIDs`. +- mTLS via `frontendValidation.caCertificateRefs` + `VKSGatewayPolicy.ClientCertificateID`. +- Status conditions per §4 (including `PolicyAncestorStatus`). +- Events + new Prometheus metrics. +- Helm chart updates: opt-in via `gatewayApi.alb.enabled`. + +**Deliverables** + +- All [new] / [mod] files in §5 marked Phase 1. +- Unit + suite test coverage parity with existing Ingress controller (~70% line). +- 6 e2e tests: basic HTTP, HTTPS+SNI, mTLS, weighted canary, cross-namespace via RG, per-rule VKSRoutePolicy overlay. +- 4 user docs (`gateway-api.md`, `gateway-alb.md`, `gateway-policies.md`, `examples/gateway-canary.md`). + +**Success criteria** + +- A user can install the chart with `gatewayApi.alb.enabled=true`, apply a sample manifest plus 1 of each policy kind, see `Gateway.status.addresses` populated, and curl through the LB. +- Each policy CR's `status.conditions[Accepted]` correctly reports `True` / `Conflicted=True` / `TargetNotFound`. +- Deleting a Gateway cleans up the vngcloud LB; deleting a single HTTPRoute removes its policies / pools without disturbing the LB or sibling routes; deleting a VKSBackendPolicy reverts the pool to controller defaults. +- Existing controllers behave identically before and after upgrade. +- `make test` passes; `make e2e-gateway` passes against a real cluster. + +### Phase 2 — L4 + +**Scope** + +- `vngcloud-nlb` GatewayClass + reconciler. +- `Gateway` reconciler (NLB): TCP / UDP listeners, TLS-Passthrough listeners. +- `TCPRoute`, `UDPRoute`, `TLSRoute` reconcilers. +- `VKSRoutePolicy` extended to target L4 route kinds. +- Reuses NLB build paths from existing `service_uc`. +- Helm chart: `gatewayApi.nlb.enabled`. + +**Deliverables** + +- All [new]/[mod] files in §5 marked Phase 2. +- 4 e2e tests (TCP echo, UDP DNS, TLS-Passthrough SNI routing, mixed-RouteKinds attachment). +- 2 user docs (`gateway-nlb.md`, `examples/gateway-tls-passthrough.md`). + +**Success criteria** + +- A `Gateway` with TCP/UDP listener provisions an NLB and routes traffic to the referenced backend. +- TLSRoute SNI selection works for ≥ 2 hostnames on one listener. +- Mixed L4+L7 listeners on a single Gateway are explicitly rejected. + +### Phase 3 — Standard policies & advanced routes + +**Scope** + +- `BackendTLSPolicy` (Standard channel since Gateway API v1.2): wires to existing pool `EnableTLSEncryption` and a new TLS verification context. Honored alongside `VKSBackendPolicy.EnableTLSEncryption` (BackendTLSPolicy wins if both set; emit `Deprecated` on the VKS field). +- `GRPCRoute` reconciler (depends on vngcloud HTTP/2 + gRPC support; if absent, ship as Unsupported with documented reason). +- `VKSRoutePolicy` updated to target `GRPCRoute`. +- Optional: `URLRewrite`, `RequestMirror`, `ResponseHeaderModifier` if vngcloud LB ships native support; otherwise stay `Unsupported`. +- Validation webhook for the four policy CRDs (catches `(targetRef, sectionName)` schema misuse at apply time). + +**Deliverables** + +- New reconcilers + tests for whichever items are in scope. +- Updates to `gateway-policies.md`. + +**Success criteria** + +- For each implemented Phase-3 feature: e2e test green, conformance suite picks up the new capability flag. + +### Phase 4 — Migration tool, conformance, polish + +**Scope** + +- One-shot migration: `gateway.vks.vngcloud.vn/adopt-from-ingress: /` annotation. Controller verifies the named Ingress is in the same namespace, transfers ownership labels on the existing vngcloud LB, marks the Ingress with a "migrated" finalizer, and reports `Programmed=True` against the old LB. +- Upstream Gateway API conformance suite (`test/conformance/`) green for: `Gateway`, `HTTPRoute` (Core), `ReferenceGrant`, `Mesh:false`. Documented capability matrix. +- CRD promotion path: `v1alpha1` → `v1beta1` (no schema changes if possible). +- Stable feature gates (default-on); doc audit. + +**Deliverables** + +- Migration controller code + `gateway-migration.md` runbook. +- Conformance test job in CI (`make gateway-conformance`). +- `docs/guide/gateway-conformance-matrix.md` listing every Gateway API feature with `Supported / Partial / Unsupported (reason)`. + +**Success criteria** + +- One existing Ingress can be cut over to a Gateway without downtime or DNS change (verified e2e). +- Conformance tests pass for every feature claimed `Supported` in the capability matrix. + +--- + +## 7. Risks & mitigations + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| **Discoverability** ("spooky action at a distance" — user reads HTTPRoute, policy effects come from elsewhere) | High | Med | Standard `gateway.networking.k8s.io/policy: direct` label on every CRD; per-policy `kubectl describe` shows `Accepted / Conflicted` reasons; `gateway_api_policy_attached_total` metric; Phase 4 docs runbook for "how do I see what's affecting my Gateway / Service / Route". | +| **Policy conflicts surprise users** (oldest-wins is non-obvious) | Med | Med | `Conflicted=True` on the loser plus a Warning event with the winner's name/UID. Metric `gateway_api_policy_conflict_total{kind}`. Document explicitly in `gateway-policies.md`. | +| **vngcloud LB API rate limits** under heavy reconciles | Med | High | Per-Gateway serialized reconcile; coalesce policy diff into one batched listener-update; existing backoff machinery in `lbc_uc`. | +| **Synthetic pool weight scaling explodes integers** | Med | Med | Cap product at 100; round with bias toward larger backends; unit-test with fuzz inputs. | +| **Reference-Grant denial silently drops a backend** | Med | High | `ResolvedRefs=False, reason=RefNotPermitted` + Warning event + Prometheus counter `gateway_api_refgrant_denied_total`. | +| **Concurrent endpoint churn during weight rescaling causes pool flapping** | Low | Med | 2 s coalescing window when triggered by EndpointSlice change. | +| **Listener cert-ID in policy conflicts with Secret-imported cert** | Low | Low | Policy wins; document precedence; emit Warning event. | +| **Unsupported-feature requests pile up** (URLRewrite, mirror, header / queryParam / method matching, FixedResponse, SSL / ALPN policies, cookie session affinity, custom HC headers, etc.) | Med | Med | `gateway_api_unsupported_feature_total` metric → product feedback to vngcloud LB team; documented capability matrix (§1.6) sets expectations. | +| **CRD conversion needed later** | Low | Med | Plan field additions only (no removals/renames) until v1beta1 promotion; webhook conversion deferred. | +| **Gateway API spec churn** between releases | Low | Med | Pin SDK to a known-good Gateway API minor version (v1.2 for Phase 1); upgrade in a dedicated PR with conformance re-run. | +| **Two LB types in one cluster eat budget** (no sharing per Q6) | Med | Low | Document clearly; cost guidance in `gateway-api.md`; Phase 4 migration path lets users switch without doubling cost. | +| **Existing controllers regress** during refactor | Low | High | This design touches **no** existing controller code paths. Full existing test suite gates the merge. | +| **`HTTPRoute.spec.rules[].name` not provided by users** (required for `VKSRoutePolicy.sectionName`) | Med | Low | `VKSRoutePolicy` with `sectionName` that doesn't match any rule → `Accepted=False, reason=TargetNotFound`. Documented in `gateway-policies.md` with examples. Validation webhook in Phase 3 catches at apply time. | + +--- + +## 8. Open items deferred to implementation + +1. Helm-chart default for `gatewayApi.alb.enabled` — propose `false` for first Phase-1 release; flip to `true` after one minor cycle. +2. Per-Gateway concurrency — start with `MaxConcurrentReconciles=5` (matches existing Ingress); revisit if rate-limit issues appear. +3. `Gateway.spec.addresses[]` user requests — reject for v1 (vngcloud assigns IP). Revisit if LB product gains BYOIP. +4. Cross-namespace policy targeting — same-namespace only in v1 (matches GEP-713 recommendation for Direct policies). Revisit when ReferenceGrant gains policy-CR support. +5. Inherited Policy adoption — if real cluster-admin-defaults use cases emerge, introduce an `Inherited` variant (e.g. `targetRefs: GatewayClass` with `default` / `override` blocks) in Phase 3+. Not needed for v1. +6. `PolicyAncestorStatus` consumers — populated from day one but only used by the single ALB / NLB controllers. Multi-controller (e.g. GLB) coexistence deferred to Phase 3. + +--- + +## 9. Out of scope (explicitly not planned) + +- Mesh use cases (Gateway-API east-west). +- `GatewayClass.parametersRef` configuration. The CRD field exists on the upstream type and is silently ignored. +- HTTPRoute `filters[].extensionRef` interpretation. Vngcloud-specific behavior is exclusively via `VKSRoutePolicy`. +- `LoadBalancerConfig` extensions for Gateway API. The existing CRD remains Ingress / Service-only. +- Per-route load balancer addresses (one Gateway = one address set). +- In-place GatewayClass type swap (e.g., `vngcloud-alb` → `vngcloud-nlb` on a live Gateway). Forces recreate. +- Inherited Policy Attachment (defaults / overrides hierarchy). Reconsidered after v1 if demand exists. + +--- + +## 10. What this design intentionally doesn't reuse from `2026-04-30-gateway-api-design.md` + +For reviewers comparing the two docs: + +| Earlier design used | This design uses instead | Why | +|---|---|---| +| `LoadBalancerConfig` extended with `mergingMode`, referenced via `parametersRef` at GatewayClass + Gateway | `VKSGatewayPolicy` (Direct, `targetRefs.kind: Gateway`, optional `sectionName`) | Single attachment pattern across all knobs; aligns with GEP-713 / GKE / Envoy GW / Cilium / Istio. | +| `TargetGroupConfig.spec.targetReference` (inverse `targetRef`) | `VKSBackendPolicy.targetRefs[]` + separate `VKSHealthCheckPolicy.targetRefs[]` | Standard `targetRefs` shape; tooling and conformance recognize it; splitting health check follows GKE's split (different teams own each). | +| `ListenerRuleConfig` attached via `HTTPRoute.spec.rules[].filters[].extensionRef` | `VKSRoutePolicy` (Direct, `targetRefs.kind: HTTPRoute`, `sectionName: `) | Same uniform attachment pattern. Trade-off: route authors author one extra YAML doc; in exchange, RBAC can split route-author vs match-author cleanly and the policy is discoverable via `kubectl get vksroutepolicy`. | +| `mergingMode: PreferGateway \| PreferGatewayClass` | none — no GatewayClass-level config in v1 | Inherited Policy is heavier than v1 needs. If real demand emerges, add an Inherited variant later. | + +The earlier design's domain model (synthetic pool, hostname → regex, listener diff, weight scaling, NSG inheritance, finalizer protocol, owner labels) is preserved verbatim — those decisions are independent of attachment style. + +--- + +## References + +- [Kubernetes Gateway API](https://gateway-api.sigs.k8s.io/) +- [GEP-713: Metaresources and Policy Attachment](https://gateway-api.sigs.k8s.io/geps/gep-713/) +- [Policy Attachment reference page](https://gateway-api.sigs.k8s.io/reference/policy-attachment/) +- [Gateway API implementations list](https://gateway-api.sigs.k8s.io/implementations/) +- [GKE: Configure Gateway resources using Policies](https://docs.cloud.google.com/kubernetes-engine/docs/how-to/configure-gateway-resources) +- [GKE Gateway API Types reference](https://googlecloudplatform.github.io/gke-gateway-api/) +- [Envoy Gateway BackendTrafficPolicy / ClientTrafficPolicy](https://gateway.envoyproxy.io/docs/api/extension_types/) diff --git a/go.mod b/go.mod index 0fb02d6f..36c0fe9f 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/vngcloud/vngcloud-load-balancer-controller go 1.25.0 -toolchain go1.25.10 +toolchain go1.25.11 require ( github.com/anngdinh/operator-helper v0.0.8-0.20250606033238-e50b218b202c @@ -22,7 +22,7 @@ require ( github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.11.1 github.com/vngcloud/vngcloud-go-sdk/v2 v2.17.4-0.20251225102644-877dacf16698 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.21.0 k8s.io/api v0.31.3 k8s.io/apiextensions-apiserver v0.31.3 k8s.io/apimachinery v0.31.3 @@ -34,6 +34,8 @@ require ( sigs.k8s.io/yaml v1.4.0 ) +require sigs.k8s.io/gateway-api v1.2.0 + require ( dario.cat/mergo v1.0.1 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect @@ -59,9 +61,9 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -78,7 +80,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/icholy/digest v1.1.0 // indirect - github.com/imdario/mergo v0.3.13 // indirect + github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -123,19 +125,19 @@ require ( go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.24.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/tools v0.45.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect - google.golang.org/grpc v1.65.1 // indirect + google.golang.org/grpc v1.66.2 // indirect google.golang.org/protobuf v1.35.1 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -145,7 +147,7 @@ require ( k8s.io/apiserver v0.31.3 // indirect k8s.io/cluster-bootstrap v0.31.3 // indirect k8s.io/component-base v0.31.3 // indirect - k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect diff --git a/go.sum b/go.sum index f6439937..4dce0e7d 100644 --- a/go.sum +++ b/go.sum @@ -42,7 +42,6 @@ github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pq github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cuongpiger/joat v1.0.17 h1:P6VZZhZplLjKRnfbHRal8jkgXNBp69Sj9lQ7aST6Hmo= github.com/cuongpiger/joat v1.0.17/go.mod h1:Oi1GvzXKHc0nlEnh9pGCFK13h8+EhDnWmfv2TiNH35g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -74,13 +73,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= @@ -127,8 +125,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= -github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -141,11 +139,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -286,8 +281,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -299,16 +294,16 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -318,15 +313,15 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -334,20 +329,20 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw= -google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU= +google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU= +google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go.mod h1:OimBR/bc1wPO9iV4NC2bpyjy3VnAwZh5EBPQdtaE5oo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= -google.golang.org/grpc v1.65.1 h1:toSN4j5/Xju+HVovfaY5g1YZVuJeHzQZhP8eJ0L0f1I= -google.golang.org/grpc v1.65.1/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= +google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -364,7 +359,6 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= @@ -385,8 +379,8 @@ k8s.io/component-base v0.31.3 h1:DMCXXVx546Rfvhj+3cOm2EUxhS+EyztH423j+8sOwhQ= k8s.io/component-base v0.31.3/go.mod h1:xME6BHfUOafRgT0rGVBGl7TuSg8Z9/deT7qq6w7qjIU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108 h1:Q8Z7VlGhcJgBHJHYugJ/K/7iB8a2eSxCyxdVjJp+lLY= +k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 h1:2770sDpzrjjsAtVhSeUFseziht227YAWYHLGNM8QPwY= @@ -395,6 +389,8 @@ sigs.k8s.io/cluster-api v1.9.1 h1:s2YUB66XceK+EQ3Uq8Mq/N9TgqRU4TQUMsaQYXOIWlU= sigs.k8s.io/cluster-api v1.9.1/go.mod h1:pkFqVPq0ELlJgyDjgqpb4MU1XnWEi98B2q3DbEjC4ww= sigs.k8s.io/controller-runtime v0.19.3 h1:XO2GvC9OPftRst6xWCpTgBZO04S2cbp0Qqkj8bX1sPw= sigs.k8s.io/controller-runtime v0.19.3/go.mod h1:j4j87DqtsThvwTv5/Tc5NFRyyF/RF0ip4+62tbTSIUM= +sigs.k8s.io/gateway-api v1.2.0 h1:LrToiFwtqKTKZcZtoQPTuo3FxhrrhTgzQG0Te+YGSo8= +sigs.k8s.io/gateway-api v1.2.0/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= diff --git a/internal/controller/gateway/alb/gateway_controller.go b/internal/controller/gateway/alb/gateway_controller.go new file mode 100644 index 00000000..aa03f9de --- /dev/null +++ b/internal/controller/gateway/alb/gateway_controller.go @@ -0,0 +1,178 @@ +// Package alb hosts the reconciler for Gateway-API Gateway objects under the +// vngcloud-alb GatewayClass. The reconciler is intentionally thin: it owns +// init gating, queue plumbing, and event filtering. All Gateway → LBC +// translation lives in internal/usecase/gateway_uc/alb_gateway_uc. +package alb + +import ( + "context" + "sync/atomic" + "time" + + "github.com/anngdinh/operator-helper/contexts" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + gwhandlers "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared/eventhandlers" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/errs" +) + +const albControllerName = "gateway-alb" + +// GatewayReconciler reconciles Gateway objects under the vngcloud-alb class. +type GatewayReconciler struct { + k8sClient client.Client + scheme *runtime.Scheme + useCase usecase.ALBGatewayUseCase + + initDone atomic.Bool + maxConcurrentReconciles int +} + +// NewGatewayReconciler wires the dependencies. Called from cmd/main.go. +func NewGatewayReconciler( + k8sClient client.Client, + scheme *runtime.Scheme, + useCase usecase.ALBGatewayUseCase, + maxConcurrentReconciles int, +) *GatewayReconciler { + return &GatewayReconciler{ + k8sClient: k8sClient, + scheme: scheme, + useCase: useCase, + maxConcurrentReconciles: func() int { + if maxConcurrentReconciles > 0 { + return maxConcurrentReconciles + } + return domain.DefaultMaxConcurrentReconciles + }(), + } +} + +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/status,verbs=update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/finalizers,verbs=update +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/status,verbs=update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=referencegrants,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.vks.vngcloud.vn,resources=vksgatewaypolicies;vksbackendpolicies;vkshealthcheckpolicies;vksroutepolicies,verbs=get;list;watch +// +kubebuilder:rbac:groups=vks.vngcloud.vn,resources=loadbalancerconfigs,verbs=get;list;watch;create;update;patch;delete + +// Reconcile is the controller-runtime entry point. +func (r *GatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + if !r.initDone.Load() { + ctrl.Log.Info("ALB Gateway init not done yet, requeueing...") + return ctrl.Result{RequeueAfter: 1 * time.Second}, nil + } + + ctx = contexts.NewContext(ctx). + SetLogName("gw/" + req.Namespace + "/" + req.Name). + GetContext() + logger := contexts.NewContext(ctx).Log() + + ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + + return errs.HandleReconcileError(r.useCase.EnsureALBGatewayUseCase(ctx, req), logger) +} + +// SetupWithManager registers the reconciler with the manager and wires watches: +// - Gateway: primary, with a class-name predicate so only ALB Gateways enter +// this controller's queue. +// - HTTPRoute: secondary, enqueue parent Gateway via parentRefs. +// - VKSGatewayPolicy: secondary, enqueue every Gateway named in targetRefs. +// - LoadBalancerConfig: secondary, enqueue the owning Gateway when the LBC's +// Status changes (Phase F status-mirroring hook). +// - Service: secondary, enqueue affected parent Gateways via reverse index. +// +// Init runs as a manager Runnable so it fires only after the controller-runtime +// cache has synced — same pattern as ingress_controller.go and +// service_controller.go. The previous bare-goroutine approach raced manager +// startup (its first List failed because the cache wasn't started yet) and +// paid a 10s sleep penalty before the retry succeeded. +// +// Note: this controller doesn't watch corev1.Node directly, so its Node +// informer is created lazily on first List. That sync can take a couple +// seconds, and a transient cloud-side error during cluster bootstrap is +// also possible, so we tolerate a small number of retries before giving up +// and crashing the pod (which is the same final outcome ingress and service +// reach by returning the error directly). +func (r *GatewayReconciler) SetupWithManager(ctx context.Context, mgr manager.Manager) error { + if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + log := ctrl.Log.WithName("init").WithName(albControllerName) + log.Info("Running initialization...") + + const maxAttempts = 5 + const backoff = 2 * time.Second + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + lastErr = r.useCase.InitALBGatewayUseCase(ctx) + if lastErr == nil { + log.Info("Initialization complete", "attempt", attempt) + r.initDone.Store(true) + return nil + } + log.Error(lastErr, "Initialization failed; retrying", + "attempt", attempt, "max", maxAttempts) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + } + log.Error(lastErr, "Fatal: initialization failed after retries") + return lastErr // returning error causes manager to stop => pod crash + })); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + Named(albControllerName). + For(&gwv1.Gateway{}, builder.WithPredicates(albClassPredicate(r.k8sClient))). + Watches(&gwv1.HTTPRoute{}, gwhandlers.RouteToGateway()). + Watches(&gwv1alpha1.VKSGatewayPolicy{}, gwhandlers.VKSGatewayPolicyToGateway()). + Watches(&gwv1alpha1.VKSBackendPolicy{}, gwhandlers.VKSBackendPolicyToGateway(r.k8sClient)). + Watches(&gwv1alpha1.VKSHealthCheckPolicy{}, gwhandlers.VKSHealthCheckPolicyToGateway(r.k8sClient)). + Watches(&gwv1alpha1.VKSRoutePolicy{}, gwhandlers.VKSRoutePolicyToGateway(r.k8sClient)). + Watches(&v1alpha1.LoadBalancerConfig{}, lbcOwnerToGateway()). + Watches(&corev1.Service{}, gwhandlers.ServiceToRouteParents(r.k8sClient)). + Watches(&gwv1beta1.ReferenceGrant{}, gwhandlers.ReferenceGrantToGateways(r.k8sClient)). + WithOptions(controller.Options{MaxConcurrentReconciles: r.maxConcurrentReconciles}). + Complete(r) +} + +// albClassPredicate filters Gateway events to those bound to a GatewayClass +// whose ControllerName is the ALB one. Any other Gateway is dropped before it +// hits the workqueue. We re-check inside the use case as a defence-in-depth. +func albClassPredicate(k8sClient client.Client) predicate.Predicate { + matches := func(o client.Object) bool { + gw, ok := o.(*gwv1.Gateway) + if !ok { + return false + } + if gw.Spec.GatewayClassName == "" { + return false + } + gc := &gwv1.GatewayClass{} + if err := k8sClient.Get(context.Background(), + client.ObjectKey{Name: string(gw.Spec.GatewayClassName)}, gc); err != nil { + return false + } + return string(gc.Spec.ControllerName) == consts.GatewayClassControllerNameALB + } + return predicate.NewPredicateFuncs(matches) +} diff --git a/internal/controller/gateway/alb/gateway_controller_test.go b/internal/controller/gateway/alb/gateway_controller_test.go new file mode 100644 index 00000000..655a1344 --- /dev/null +++ b/internal/controller/gateway/alb/gateway_controller_test.go @@ -0,0 +1,398 @@ +package alb + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +const ( + albTimeout = time.Second * 30 + albInterval = time.Millisecond * 250 + testNS = "default" +) + +// newALBGatewayClass creates a GatewayClass that the ALB controller claims. +func newALBGatewayClass(name string) *gwv1.GatewayClass { + return &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: gwv1.GatewayClassSpec{ + ControllerName: consts.GatewayClassControllerNameALB, + }, + } +} + +// newGateway creates a Gateway under the given GatewayClass with one HTTP listener. +func newGateway(name, ns, gwClassName string) *gwv1.Gateway { + return &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + Spec: gwv1.GatewaySpec{ + GatewayClassName: gwv1.ObjectName(gwClassName), + Listeners: []gwv1.Listener{ + { + Name: "http", + Port: 80, + Protocol: gwv1.HTTPProtocolType, + }, + }, + }, + } +} + +// newNodePortSvc creates a minimal NodePort Service for testing. +func newNodePortSvc(name, ns string, port int32, nodePort int32) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: port, + Protocol: corev1.ProtocolTCP, + NodePort: nodePort, + }, + }, + Selector: map[string]string{"app": name}, + }, + } +} + +// newHTTPRoute creates a simple HTTPRoute pointing at a backend service and a +// parent Gateway. +// nolint:unparam +func newHTTPRoute(name, ns, gwName, svcName string, svcPort int32) *gwv1.HTTPRoute { + port := gwv1.PortNumber(svcPort) + return &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + }, + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Name: gwv1.ObjectName(gwName), + }, + }, + }, + Rules: []gwv1.HTTPRouteRule{ + { + BackendRefs: []gwv1.HTTPBackendRef{ + { + BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: gwv1.ObjectName(svcName), + Port: &port, + }, + }, + }, + }, + }, + }, + }, + } +} + +// findLBCByGatewayOwnerLabels lists LBCs owned by the given Gateway. +func findLBCsByGatewayOwnerLabels(gwName, ns string) (*v1alpha1.LoadBalancerConfigList, error) { + lbcList := &v1alpha1.LoadBalancerConfigList{} + err := k8sClient.List(ctx, lbcList, + client.InNamespace(ns), + client.MatchingLabels{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceName: gwName, + }, + ) + return lbcList, err +} + +var _ = Describe("ALB Gateway Controller", func() { + + Context("GatewayClass reconciliation", func() { + + It("should set Accepted=True on a GatewayClass with our controllerName", func() { + gc := newALBGatewayClass("alb-gc-accept") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-accept"}, got)).To(Succeed()) + var accepted *metav1.Condition + for i := range got.Status.Conditions { + if got.Status.Conditions[i].Type == string(gwv1.GatewayClassConditionStatusAccepted) { + accepted = &got.Status.Conditions[i] + break + } + } + g.Expect(accepted).NotTo(BeNil(), "expected Accepted condition") + g.Expect(accepted.Status).To(Equal(metav1.ConditionTrue)) + }, albTimeout, albInterval).Should(Succeed()) + }) + + It("should not write Accepted=True condition on a GatewayClass with a different controllerName", func() { + gc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "other-gc"}, + Spec: gwv1.GatewayClassSpec{ + ControllerName: "gateway.example.io/other", + }, + } + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + // Give the reconciler time to potentially (wrongly) act on this object. + // The API server may pre-populate a "Pending" condition; what we verify is + // that our controller never writes Accepted=True. + Consistently(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "other-gc"}, got)).To(Succeed()) + for _, c := range got.Status.Conditions { + if c.Type == string(gwv1.GatewayClassConditionStatusAccepted) { + g.Expect(c.Status).NotTo(Equal(metav1.ConditionTrue), + "expected our ALB controller NOT to set Accepted=True for a non-ALB GatewayClass") + } + } + }, 3*time.Second, albInterval).Should(Succeed()) + }) + + }) + + Context("Gateway → LBC translation", func() { + + It("should create an LBC owned by the Gateway", func() { + // Create the GatewayClass first + gc := newALBGatewayClass("alb-gc-lbc") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + // Wait for GatewayClass to be accepted so the predicate is satisfied + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-lbc"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + // Create a NodePort service + svc := newNodePortSvc("alb-svc-lbc", testNS, 80, 30180) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + // Create a Gateway + gw := newGateway("alb-gw-lbc", testNS, "alb-gc-lbc") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + // Create an HTTPRoute parented by the Gateway + route := newHTTPRoute("alb-route-lbc", testNS, "alb-gw-lbc", "alb-svc-lbc", 80) + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + // Eventually: an LBC should be created with Gateway owner labels + Eventually(func(g Gomega) { + lbcList, err := findLBCsByGatewayOwnerLabels("alb-gw-lbc", testNS) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(lbcList.Items).NotTo(BeEmpty(), "expected LBC to be created for Gateway") + + lbc := &lbcList.Items[0] + g.Expect(lbc.Labels[domain.LabelOwnerResourceKind]).To(Equal(domain.OwnerKindGateway)) + g.Expect(lbc.Labels[domain.LabelOwnerResourceName]).To(Equal("alb-gw-lbc")) + }, albTimeout, albInterval).Should(Succeed()) + }) + + It("should write HTTPRoute Accepted + ResolvedRefs status on its parent", func() { + gc := newALBGatewayClass("alb-gc-rstatus") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-rstatus"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + svc := newNodePortSvc("alb-svc-rstatus", testNS, 80, 30182) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := newGateway("alb-gw-rstatus", testNS, "alb-gc-rstatus") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + route := newHTTPRoute("alb-route-rstatus", testNS, "alb-gw-rstatus", "alb-svc-rstatus", 80) + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + findCond := func(conds []metav1.Condition, t string) *metav1.Condition { + for i := range conds { + if conds[i].Type == t { + return &conds[i] + } + } + return nil + } + Eventually(func(g Gomega) { + got := &gwv1.HTTPRoute{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-route-rstatus", Namespace: testNS}, got)).To(Succeed()) + var parent *gwv1.RouteParentStatus + for i := range got.Status.Parents { + if string(got.Status.Parents[i].ControllerName) == consts.GatewayClassControllerNameALB { + parent = &got.Status.Parents[i] + break + } + } + g.Expect(parent).NotTo(BeNil(), "expected our controller's parent status entry") + accepted := findCond(parent.Conditions, string(gwv1.RouteConditionAccepted)) + g.Expect(accepted).NotTo(BeNil()) + g.Expect(accepted.Status).To(Equal(metav1.ConditionTrue)) + resolved := findCond(parent.Conditions, string(gwv1.RouteConditionResolvedRefs)) + g.Expect(resolved).NotTo(BeNil()) + g.Expect(resolved.Status).To(Equal(metav1.ConditionTrue)) + }, albTimeout, albInterval).Should(Succeed()) + }) + + It("should set Gateway.Status Accepted condition", func() { + // Create GatewayClass + gc := newALBGatewayClass("alb-gc-status") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + // Wait for GatewayClass to be accepted + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-status"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + svc := newNodePortSvc("alb-svc-status", testNS, 80, 30181) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := newGateway("alb-gw-status", testNS, "alb-gc-status") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + // Eventually: Gateway should have Accepted condition from the use case + Eventually(func(g Gomega) { + got := &gwv1.Gateway{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gw-status", Namespace: testNS}, got)).To(Succeed()) + + var accepted *metav1.Condition + for i := range got.Status.Conditions { + if got.Status.Conditions[i].Type == string(gwv1.GatewayConditionAccepted) { + accepted = &got.Status.Conditions[i] + break + } + } + g.Expect(accepted).NotTo(BeNil(), "expected Accepted condition on Gateway") + g.Expect(accepted.Status).To(Equal(metav1.ConditionTrue)) + }, albTimeout, albInterval).Should(Succeed()) + }) + + It("should not create LBC for a Gateway with a non-ALB class", func() { + // Create a non-ALB GatewayClass + otherGC := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "other-gc-no-lbc"}, + Spec: gwv1.GatewayClassSpec{ + ControllerName: "gateway.example.io/other", + }, + } + Expect(k8sClient.Create(ctx, otherGC)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, otherGC) }) + + gw := newGateway("alb-gw-wrong-class", testNS, "other-gc-no-lbc") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + // Consistently: no LBC should be created + Consistently(func(g Gomega) { + lbcList, err := findLBCsByGatewayOwnerLabels("alb-gw-wrong-class", testNS) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(lbcList.Items).To(BeEmpty(), + "expected no LBC for Gateway with non-ALB class") + }, 4*time.Second, albInterval).Should(Succeed()) + }) + + }) + + Context("Gateway deletion", func() { + + It("should garbage-collect the LBC when the Gateway is deleted", func() { + // Create GatewayClass + gc := newALBGatewayClass("alb-gc-del") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + // Wait for GatewayClass to be accepted + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-del"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + svc := newNodePortSvc("alb-svc-del", testNS, 80, 30182) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := newGateway("alb-gw-del", testNS, "alb-gc-del") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + + // Wait until LBC is created + Eventually(func(g Gomega) { + lbcList, err := findLBCsByGatewayOwnerLabels("alb-gw-del", testNS) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(lbcList.Items).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + // Delete the Gateway + Expect(k8sClient.Delete(ctx, gw)).To(Succeed()) + + // Eventually: LBC should be gone (no items with those owner labels, all deleted) + Eventually(func(g Gomega) { + lbcList := &v1alpha1.LoadBalancerConfigList{} + err := k8sClient.List(ctx, lbcList, + client.InNamespace(testNS), + client.MatchingLabels{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceName: "alb-gw-del", + }, + ) + g.Expect(err).NotTo(HaveOccurred()) + + // Filter to non-deleted items + var live []v1alpha1.LoadBalancerConfig + for _, item := range lbcList.Items { + if item.DeletionTimestamp.IsZero() { + live = append(live, item) + } + } + g.Expect(live).To(BeEmpty(), "expected LBC owned by Gateway to be garbage-collected") + }, albTimeout*2, albInterval).Should(Succeed()) + + // Also verify Gateway itself is gone + Eventually(func() bool { + err := k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gw-del", Namespace: testNS}, &gwv1.Gateway{}) + return apierrors.IsNotFound(err) + }, albTimeout, albInterval).Should(BeTrue(), "expected Gateway to be fully deleted") + }) + + }) + +}) diff --git a/internal/controller/gateway/alb/gateway_translation_test.go b/internal/controller/gateway/alb/gateway_translation_test.go new file mode 100644 index 00000000..85e9c2aa --- /dev/null +++ b/internal/controller/gateway/alb/gateway_translation_test.go @@ -0,0 +1,431 @@ +package alb + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +// These specs cover the live-cluster smoke scenarios validated against a real +// vngcloud LB on KUBECONFIG=~/vks-test-gatewayapi.yaml: VKSBackendPolicy update +// propagation, unsupported-match drop, cross-namespace backend drop, +// multi-listener HTTP+HTTPS with TLS Secret, and policy ordering by match +// specificity. Each scenario is reduced to "spec equality on the generated +// LBC", since envtest can't reach the cloud LB itself. + +// fetchOwnedLBC returns the (single) LBC owned by the named Gateway, or nil if +// none yet exists. Used inside Eventually blocks. +// nolint:unparam +func fetchOwnedLBC(gwName, ns string) *v1alpha1.LoadBalancerConfig { + lbcList, err := findLBCsByGatewayOwnerLabels(gwName, ns) + if err != nil || len(lbcList.Items) == 0 { + return nil + } + return &lbcList.Items[0] +} + +// pathPrefixMatch builds an HTTPRouteMatch on PathPrefix(value). +func pathPrefixMatch(value string) gwv1.HTTPRouteMatch { + t := gwv1.PathMatchPathPrefix + return gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: &t, Value: ptr.To(value)}} +} + +// pathExactMatch builds an HTTPRouteMatch on Exact(value). +func pathExactMatch(value string) gwv1.HTTPRouteMatch { + t := gwv1.PathMatchExact + return gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: &t, Value: ptr.To(value)}} +} + +// hasL7Rule reports whether the policy has an L7Rule with the given (type, +// compare, value). +// nolint:unparam +func hasL7Rule(p v1alpha1.Policy, ruleType v2.PolicyRuleType, cmp v2.PolicyCompareType, value string) bool { + for _, r := range p.L7Rules { + if r.RuleType == ruleType && r.CompareType == cmp && r.RuleValue == value { + return true + } + } + return false +} + +var _ = Describe("ALB Gateway Translation (live-cluster smoke parity)", func() { + + Context("VKSBackendPolicy", func() { + + It("propagates poolAlgorithm changes to LBC.Pool.Algorithm", func() { + gc := newALBGatewayClass("alb-gc-bp-prop") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-bp-prop"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + svc := newNodePortSvc("alb-svc-bp", testNS, 80, 30190) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := newGateway("alb-gw-bp", testNS, "alb-gc-bp-prop") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + route := newHTTPRoute("alb-route-bp", testNS, "alb-gw-bp", "alb-svc-bp", 80) + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + // LBC is created; algorithm is unset until a VKSBackendPolicy lands. + Eventually(func(g Gomega) { + lbc := fetchOwnedLBC("alb-gw-bp", testNS) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Pools).NotTo(BeEmpty()) + g.Expect(lbc.Spec.Pools[0].Algorithm).To(BeNil()) + }, albTimeout, albInterval).Should(Succeed()) + + // Apply VKSBackendPolicy with SOURCE_IP. Reconcile should pick it + // up via the BackendPolicy event handler and overlay it onto the + // pool — no manual annotate or restart. + alg := "SOURCE_IP" + bp := &gwv1alpha1.VKSBackendPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "bp-source-ip", Namespace: testNS}, + Spec: gwv1alpha1.VKSBackendPolicySpec{ + TargetRefs: []gwv1alpha2.LocalPolicyTargetReference{{ + Group: "", Kind: "Service", Name: "alb-svc-bp", + }}, + PoolAlgorithm: &alg, + }, + } + Expect(k8sClient.Create(ctx, bp)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, bp) }) + + Eventually(func(g Gomega) { + lbc := fetchOwnedLBC("alb-gw-bp", testNS) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Pools).NotTo(BeEmpty()) + g.Expect(lbc.Spec.Pools[0].Algorithm).NotTo(BeNil()) + g.Expect(string(*lbc.Spec.Pools[0].Algorithm)).To(Equal("SOURCE_IP")) + }, albTimeout, albInterval).Should(Succeed()) + }) + + }) + + Context("Unsupported-match handling", func() { + + It("drops rules using header matches and keeps path-only rules", func() { + gc := newALBGatewayClass("alb-gc-unsupp") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-unsupp"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + svc := newNodePortSvc("alb-svc-unsupp", testNS, 80, 30191) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := newGateway("alb-gw-unsupp", testNS, "alb-gc-unsupp") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + port := gwv1.PortNumber(80) + rt := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "alb-route-unsupp", Namespace: testNS}, + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{{Name: "alb-gw-unsupp"}}, + }, + Rules: []gwv1.HTTPRouteRule{ + // Rule 0: path + Header — entire rule must be skipped. + { + Matches: []gwv1.HTTPRouteMatch{{ + Path: pathPrefixMatch("/dropped").Path, + Headers: []gwv1.HTTPHeaderMatch{{Name: "X-Foo", Value: "bar"}}, + }}, + BackendRefs: []gwv1.HTTPBackendRef{{BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{Name: "alb-svc-unsupp", Port: &port}, + }}}, + }, + // Rule 1: path-only — must produce a policy. + { + Matches: []gwv1.HTTPRouteMatch{pathPrefixMatch("/ok")}, + BackendRefs: []gwv1.HTTPBackendRef{{BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{Name: "alb-svc-unsupp", Port: &port}, + }}}, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, rt)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, rt) }) + + Eventually(func(g Gomega) { + lbc := fetchOwnedLBC("alb-gw-unsupp", testNS) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Listeners).To(HaveLen(1)) + policies := lbc.Spec.Listeners[0].Policies + // Exactly one policy survives — the dropped rule contributes nothing. + g.Expect(policies).To(HaveLen(1)) + g.Expect(hasL7Rule(policies[0], v2.PolicyRuleTypePATH, v2.PolicyCompareTypeSTARTSWITH, "/ok")).To(BeTrue()) + // And the dropped path is not present anywhere. + for _, p := range policies { + g.Expect(hasL7Rule(p, v2.PolicyRuleTypePATH, v2.PolicyCompareTypeSTARTSWITH, "/dropped")).To(BeFalse()) + } + }, albTimeout, albInterval).Should(Succeed()) + }) + + }) + + Context("Cross-namespace backendRef", func() { + + It("skips the rule when the only backendRef points outside the route's namespace", func() { + gc := newALBGatewayClass("alb-gc-xns") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-xns"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + otherNS := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "alb-xns-other"}} + Expect(client.IgnoreAlreadyExists(k8sClient.Create(ctx, otherNS))).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, otherNS) }) + + svc := newNodePortSvc("alb-svc-xns", "alb-xns-other", 80, 30192) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := newGateway("alb-gw-xns", testNS, "alb-gc-xns") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + port := gwv1.PortNumber(80) + otherNSObj := gwv1.Namespace("alb-xns-other") + rt := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "alb-route-xns", Namespace: testNS}, + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{{Name: "alb-gw-xns"}}, + }, + Rules: []gwv1.HTTPRouteRule{{ + Matches: []gwv1.HTTPRouteMatch{pathPrefixMatch("/")}, + BackendRefs: []gwv1.HTTPBackendRef{{BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: "alb-svc-xns", Port: &port, Namespace: &otherNSObj, + }, + }}}, + }}, + }, + } + Expect(k8sClient.Create(ctx, rt)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, rt) }) + + // LBC is created (Gateway accepted) but with no pools/policies — + // the only rule was dropped because its backend is cross-ns and + // ReferenceGrant evaluation lands in a follow-up. + Eventually(func(g Gomega) { + lbc := fetchOwnedLBC("alb-gw-xns", testNS) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Pools).To(BeEmpty()) + g.Expect(lbc.Spec.Listeners).To(HaveLen(1)) + g.Expect(lbc.Spec.Listeners[0].Policies).To(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + }) + + }) + + Context("Multi-listener Gateway with TLS Secret", func() { + + It("emits two listeners — HTTP and HTTPS with the secret's name in CertificateDefault", func() { + gc := newALBGatewayClass("alb-gc-ml") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-ml"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + svc := newNodePortSvc("alb-svc-ml", testNS, 80, 30193) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + tlsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "alb-tls-ml", Namespace: testNS}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.TLSCertKey: []byte("dummy-cert"), + corev1.TLSPrivateKeyKey: []byte("dummy-key"), + }, + } + Expect(k8sClient.Create(ctx, tlsSecret)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, tlsSecret) }) + + tlsKind := gwv1.Kind("Secret") + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "alb-gw-ml", Namespace: testNS}, + Spec: gwv1.GatewaySpec{ + GatewayClassName: gwv1.ObjectName("alb-gc-ml"), + Listeners: []gwv1.Listener{ + {Name: "http", Port: 80, Protocol: gwv1.HTTPProtocolType}, + { + Name: "https", Port: 443, Protocol: gwv1.HTTPSProtocolType, + TLS: &gwv1.GatewayTLSConfig{ + Mode: ptr.To(gwv1.TLSModeTerminate), + CertificateRefs: []gwv1.SecretObjectReference{{ + Kind: &tlsKind, Name: gwv1.ObjectName("alb-tls-ml"), + }}, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + rt := newHTTPRoute("alb-route-ml", testNS, "alb-gw-ml", "alb-svc-ml", 80) + Expect(k8sClient.Create(ctx, rt)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, rt) }) + + Eventually(func(g Gomega) { + lbc := fetchOwnedLBC("alb-gw-ml", testNS) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Listeners).To(HaveLen(2)) + + var http, https *v1alpha1.Listener + for i := range lbc.Spec.Listeners { + switch lbc.Spec.Listeners[i].Protocol { + case v2.ListenerProtocolHTTP: + http = &lbc.Spec.Listeners[i] + case v2.ListenerProtocolHTTPS: + https = &lbc.Spec.Listeners[i] + } + } + g.Expect(http).NotTo(BeNil()) + g.Expect(http.ProtocolPort).To(Equal(int32(80))) + g.Expect(http.CertificateDefault).To(BeNil()) + + g.Expect(https).NotTo(BeNil()) + g.Expect(https.ProtocolPort).To(Equal(int32(443))) + g.Expect(https.CertificateDefault).NotTo(BeNil()) + g.Expect(https.CertificateDefault.SecretName).NotTo(BeNil()) + g.Expect(*https.CertificateDefault.SecretName).To(Equal("alb-tls-ml")) + + // And the LBC controller should be told to import the Secret. + var found bool + for _, c := range lbc.Spec.CreateCertificates { + if c.SecretName == "alb-tls-ml" { + found = true + break + } + } + g.Expect(found).To(BeTrue(), "expected alb-tls-ml in CreateCertificates") + }, albTimeout, albInterval).Should(Succeed()) + }) + + }) + + Context("Policy ordering by match specificity", func() { + + It("orders Exact match before PathPrefix on the same listener", func() { + gc := newALBGatewayClass("alb-gc-ord") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-ord"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + svc := newNodePortSvc("alb-svc-ord-v1", testNS, 80, 30194) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + svc2 := newNodePortSvc("alb-svc-ord-v2", testNS, 80, 30195) + Expect(k8sClient.Create(ctx, svc2)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc2) }) + + gw := newGateway("alb-gw-ord", testNS, "alb-gc-ord") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + port := gwv1.PortNumber(80) + // Route A: PathPrefix /api → v1 (less specific) + routeA := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route-prefix", Namespace: testNS}, + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "alb-gw-ord"}}}, + Hostnames: []gwv1.Hostname{"ord.example.com"}, + Rules: []gwv1.HTTPRouteRule{{ + Matches: []gwv1.HTTPRouteMatch{pathPrefixMatch("/api")}, + BackendRefs: []gwv1.HTTPBackendRef{{BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{Name: "alb-svc-ord-v1", Port: &port}, + }}}, + }}, + }, + } + Expect(k8sClient.Create(ctx, routeA)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, routeA) }) + + // Route B: Exact /api/v1 → v2 (more specific — must come first). + routeB := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route-exact", Namespace: testNS}, + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "alb-gw-ord"}}}, + Hostnames: []gwv1.Hostname{"ord.example.com"}, + Rules: []gwv1.HTTPRouteRule{{ + Matches: []gwv1.HTTPRouteMatch{pathExactMatch("/api/v1")}, + BackendRefs: []gwv1.HTTPBackendRef{{BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{Name: "alb-svc-ord-v2", Port: &port}, + }}}, + }}, + }, + } + Expect(k8sClient.Create(ctx, routeB)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, routeB) }) + + Eventually(func(g Gomega) { + lbc := fetchOwnedLBC("alb-gw-ord", testNS) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Listeners).To(HaveLen(1)) + policies := lbc.Spec.Listeners[0].Policies + g.Expect(policies).To(HaveLen(2)) + + // Position 1 (most specific) must be the Exact match. + var exact, prefix *v1alpha1.Policy + for i := range policies { + switch { + case hasL7Rule(policies[i], v2.PolicyRuleTypePATH, v2.PolicyCompareTypeEQUALS, "/api/v1"): + exact = &policies[i] + case hasL7Rule(policies[i], v2.PolicyRuleTypePATH, v2.PolicyCompareTypeSTARTSWITH, "/api"): + prefix = &policies[i] + } + } + g.Expect(exact).NotTo(BeNil(), "expected Exact /api/v1 policy") + g.Expect(prefix).NotTo(BeNil(), "expected Prefix /api policy") + g.Expect(exact.Position).NotTo(BeNil()) + g.Expect(prefix.Position).NotTo(BeNil()) + g.Expect(*exact.Position).To(BeNumerically("<", *prefix.Position), + "Exact match must take a lower (better) Position than Prefix match") + }, albTimeout*2, albInterval).Should(Succeed()) + }) + + }) + +}) diff --git a/internal/controller/gateway/alb/gatewayclass_controller.go b/internal/controller/gateway/alb/gatewayclass_controller.go new file mode 100644 index 00000000..0b7f02eb --- /dev/null +++ b/internal/controller/gateway/alb/gatewayclass_controller.go @@ -0,0 +1,98 @@ +package alb + +import ( + "context" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +// +kubebuilder:rbac.groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses/status,verbs=patch;update + +// GatewayClassReconciler accepts every GatewayClass whose ControllerName is +// the ALB one. It writes the Accepted condition only — no other side effects. +type GatewayClassReconciler struct { + k8sClient client.Client + scheme *runtime.Scheme +} + +func NewGatewayClassReconciler(k8sClient client.Client, scheme *runtime.Scheme) *GatewayClassReconciler { + return &GatewayClassReconciler{k8sClient: k8sClient, scheme: scheme} +} + +// Reconcile sets Accepted=True on the GatewayClass. +func (r *GatewayClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + gc := &gwv1.GatewayClass{} + if err := r.k8sClient.Get(ctx, req.NamespacedName, gc); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + if string(gc.Spec.ControllerName) != consts.GatewayClassControllerNameALB { + return ctrl.Result{}, nil + } + + cur := gc.DeepCopy() + accepted := metav1.Condition{ + Type: string(gwv1.GatewayClassConditionStatusAccepted), + Status: metav1.ConditionTrue, + Reason: string(gwv1.GatewayClassReasonAccepted), + Message: "GatewayClass accepted by " + consts.GatewayClassControllerNameALB, + ObservedGeneration: gc.Generation, + LastTransitionTime: metav1.Now(), + } + shared.SetCondition(&cur.Status.Conditions, accepted) + + if conditionsEqualIgnoreTime(cur.Status.Conditions, gc.Status.Conditions) { + return ctrl.Result{}, nil + } + if err := r.k8sClient.Status().Patch(ctx, cur, client.MergeFrom(gc)); err != nil { + return ctrl.Result{RequeueAfter: 10 * time.Second}, err + } + return ctrl.Result{}, nil +} + +func (r *GatewayClassReconciler) SetupWithManager(_ context.Context, mgr manager.Manager) error { + matches := func(o client.Object) bool { + gc, ok := o.(*gwv1.GatewayClass) + return ok && string(gc.Spec.ControllerName) == consts.GatewayClassControllerNameALB + } + return ctrl.NewControllerManagedBy(mgr). + Named("gatewayclass-alb"). + For(&gwv1.GatewayClass{}, builder.WithPredicates(predicate.NewPredicateFuncs(matches))). + Complete(r) +} + +func conditionsEqualIgnoreTime(a, b []metav1.Condition) bool { + if len(a) != len(b) { + return false + } + idx := make(map[string]metav1.Condition, len(b)) + for i := range b { + idx[b[i].Type] = b[i] + } + for i := range a { + other, ok := idx[a[i].Type] + if !ok { + return false + } + if a[i].Status != other.Status || a[i].Reason != other.Reason || + a[i].Message != other.Message || a[i].ObservedGeneration != other.ObservedGeneration { + return false + } + } + return true +} diff --git a/internal/controller/gateway/alb/lbc_to_gateway.go b/internal/controller/gateway/alb/lbc_to_gateway.go new file mode 100644 index 00000000..7c661d2c --- /dev/null +++ b/internal/controller/gateway/alb/lbc_to_gateway.go @@ -0,0 +1,58 @@ +package alb + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" +) + +// lbcOwnerToGateway enqueues the Gateway that owns a LoadBalancerConfig when +// the LBC changes. The Gateway use case mirrors LBC.Status into Gateway.Status +// (Phase F); this watch is what triggers that mirroring. +// +// Match criterion: LBC has the labels we set during creation +// +// vks.vngcloud.vn/owner-resource-kind=Gateway +// vks.vngcloud.vn/owner-resource-name= +// +// We use the labels (not ownerReferences) because the existing pattern in +// ingress_uc explicitly omits ownerReferences so that users can keep the LBC +// alive after deleting the Ingress. We follow the same convention here. +func lbcOwnerToGateway() handler.EventHandler { + enq := func(obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + lbc, ok := obj.(*v1alpha1.LoadBalancerConfig) + if !ok { + return + } + if lbc.Labels[domain.LabelOwnerResourceKind] != domain.OwnerKindGateway { + return + } + gwName := lbc.Labels[domain.LabelOwnerResourceName] + if gwName == "" { + return + } + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{ + Namespace: lbc.Namespace, + Name: gwName, + }}) + } + return handler.Funcs{ + CreateFunc: func(_ context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(e.Object, q) + }, + UpdateFunc: func(_ context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(e.ObjectNew, q) + }, + DeleteFunc: func(_ context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(e.Object, q) + }, + } +} diff --git a/internal/controller/gateway/alb/lbc_to_gateway_test.go b/internal/controller/gateway/alb/lbc_to_gateway_test.go new file mode 100644 index 00000000..60f90f34 --- /dev/null +++ b/internal/controller/gateway/alb/lbc_to_gateway_test.go @@ -0,0 +1,157 @@ +package alb + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" +) + +// fakeQueue captures enqueued reconcile requests without needing a real queue. +type fakeQueue struct { + workqueue.TypedRateLimitingInterface[reconcile.Request] + items []reconcile.Request +} + +func (q *fakeQueue) Add(item reconcile.Request) { q.items = append(q.items, item) } + +// lbcWithLabels builds an LBC with the given owner labels. +func lbcWithLabels(ns, name string, labels map[string]string) *v1alpha1.LoadBalancerConfig { + return &v1alpha1.LoadBalancerConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + Labels: labels, + }, + } +} + +func TestLBCOwnerToGateway_CreateEnqueuesGateway(t *testing.T) { + lbc := lbcWithLabels("mynamespace", "my-lbc", map[string]string{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceName: "my-gateway", + }) + h := lbcOwnerToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: lbc}, q) + + if len(q.items) != 1 { + t.Fatalf("expected 1 enqueued item, got %d", len(q.items)) + } + want := types.NamespacedName{Namespace: "mynamespace", Name: "my-gateway"} + if q.items[0].NamespacedName != want { + t.Errorf("enqueued request: got %v, want %v", q.items[0].NamespacedName, want) + } +} + +func TestLBCOwnerToGateway_UpdateEnqueuesGateway(t *testing.T) { + lbc := lbcWithLabels("ns", "lbc-upd", map[string]string{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceName: "gw-updated", + }) + h := lbcOwnerToGateway() + q := &fakeQueue{} + // UpdateFunc uses ObjectNew + h.Update(context.Background(), event.UpdateEvent{ + ObjectOld: lbcWithLabels("ns", "lbc-upd", map[string]string{}), + ObjectNew: lbc, + }, q) + + if len(q.items) != 1 { + t.Fatalf("expected 1 enqueued item, got %d", len(q.items)) + } + want := types.NamespacedName{Namespace: "ns", Name: "gw-updated"} + if q.items[0].NamespacedName != want { + t.Errorf("enqueued request: got %v, want %v", q.items[0].NamespacedName, want) + } +} + +func TestLBCOwnerToGateway_DeleteEnqueuesGateway(t *testing.T) { + lbc := lbcWithLabels("del-ns", "lbc-del", map[string]string{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceName: "gw-deleted", + }) + h := lbcOwnerToGateway() + q := &fakeQueue{} + h.Delete(context.Background(), event.DeleteEvent{Object: lbc}, q) + + if len(q.items) != 1 { + t.Fatalf("expected 1 enqueued item, got %d", len(q.items)) + } + want := types.NamespacedName{Namespace: "del-ns", Name: "gw-deleted"} + if q.items[0].NamespacedName != want { + t.Errorf("enqueued request: got %v, want %v", q.items[0].NamespacedName, want) + } +} + +func TestLBCOwnerToGateway_MissingOwnerKindLabel_NoEnqueue(t *testing.T) { + // LBC has owner-resource-name but NOT owner-resource-kind=Gateway + lbc := lbcWithLabels("ns", "lbc-nokind", map[string]string{ + domain.LabelOwnerResourceName: "some-gw", + }) + h := lbcOwnerToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: lbc}, q) + + if len(q.items) != 0 { + t.Errorf("expected 0 enqueued items, got %d", len(q.items)) + } +} + +func TestLBCOwnerToGateway_WrongKindLabel_NoEnqueue(t *testing.T) { + lbc := lbcWithLabels("ns", "lbc-wrongkind", map[string]string{ + domain.LabelOwnerResourceKind: "Ingress", // wrong kind + domain.LabelOwnerResourceName: "some-gw", + }) + h := lbcOwnerToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: lbc}, q) + + if len(q.items) != 0 { + t.Errorf("expected 0 enqueued items, got %d", len(q.items)) + } +} + +func TestLBCOwnerToGateway_MissingOwnerNameLabel_NoEnqueue(t *testing.T) { + // LBC has kind=Gateway but missing owner-resource-name + lbc := lbcWithLabels("ns", "lbc-noname", map[string]string{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + // No LabelOwnerResourceName + }) + h := lbcOwnerToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: lbc}, q) + + if len(q.items) != 0 { + t.Errorf("expected 0 enqueued items, got %d", len(q.items)) + } +} + +func TestLBCOwnerToGateway_WrongObjectType_NoEnqueue(t *testing.T) { + // A non-LBC object (e.g. a Service) should be skipped + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-svc", + Namespace: "ns", + Labels: map[string]string{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceName: "some-gw", + }, + }, + } + h := lbcOwnerToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: svc}, q) + + if len(q.items) != 0 { + t.Errorf("expected 0 enqueued items for non-LBC object, got %d", len(q.items)) + } +} diff --git a/internal/controller/gateway/alb/policy_validator_test.go b/internal/controller/gateway/alb/policy_validator_test.go new file mode 100644 index 00000000..eedbf91c --- /dev/null +++ b/internal/controller/gateway/alb/policy_validator_test.go @@ -0,0 +1,81 @@ +package alb + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +var _ = Describe("VKS policy validators", func() { + + gwPolicyTargeting := func(name, gwName string) *gwv1alpha1.VKSGatewayPolicy { + return &gwv1alpha1.VKSGatewayPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: testNS}, + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + TargetRefs: []gwv1alpha1.LocalPolicyTargetReferenceWithSectionName{{ + LocalPolicyTargetReference: gwv1alpha2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: gwv1alpha2.ObjectName(gwName), + }, + }}, + }, + } + } + + acceptedReason := func(p *gwv1alpha1.VKSGatewayPolicy) string { + for _, c := range p.Status.Conditions { + if c.Type == gwv1alpha1.PolicyConditionAccepted { + return c.Reason + } + } + return "" + } + + It("reports the newer of two policies targeting the same Gateway as Conflicted", func() { + gc := newALBGatewayClass("alb-gc-pol") + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + Eventually(func(g Gomega) { + got := &gwv1.GatewayClass{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "alb-gc-pol"}, got)).To(Succeed()) + g.Expect(got.Status.Conditions).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + + gw := newGateway("alb-gw-pol", testNS, "alb-gc-pol") + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + older := gwPolicyTargeting("pol-a-older", "alb-gw-pol") // created first + lex-first -> winner + Expect(k8sClient.Create(ctx, older)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, older) }) + newer := gwPolicyTargeting("pol-b-newer", "alb-gw-pol") + Expect(k8sClient.Create(ctx, newer)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, newer) }) + + Eventually(func(g Gomega) { + a := &gwv1alpha1.VKSGatewayPolicy{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "pol-a-older", Namespace: testNS}, a)).To(Succeed()) + b := &gwv1alpha1.VKSGatewayPolicy{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "pol-b-newer", Namespace: testNS}, b)).To(Succeed()) + g.Expect(acceptedReason(a)).To(Equal(gwv1alpha1.PolicyReasonAccepted)) + g.Expect(acceptedReason(b)).To(Equal(gwv1alpha1.PolicyReasonConflicted)) + g.Expect(b.Status.Ancestors).NotTo(BeEmpty()) + }, albTimeout, albInterval).Should(Succeed()) + }) + + It("reports TargetNotFound when the target Gateway is absent", func() { + pol := gwPolicyTargeting("pol-orphan", "no-such-gw") + Expect(k8sClient.Create(ctx, pol)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, pol) }) + + Eventually(func(g Gomega) { + got := &gwv1alpha1.VKSGatewayPolicy{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "pol-orphan", Namespace: testNS}, got)).To(Succeed()) + g.Expect(acceptedReason(got)).To(Equal(gwv1alpha1.PolicyReasonTargetNotFound)) + }, albTimeout, albInterval).Should(Succeed()) + }) +}) diff --git a/internal/controller/gateway/alb/predicates_test.go b/internal/controller/gateway/alb/predicates_test.go new file mode 100644 index 00000000..a8d4708b --- /dev/null +++ b/internal/controller/gateway/alb/predicates_test.go @@ -0,0 +1,212 @@ +package alb + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +// newPredicateScheme returns a scheme with just the types albClassPredicate +// looks at. +func newPredicateScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := gwv1.Install(s); err != nil { + t.Fatalf("install gateway-api scheme: %v", err) + } + return s +} + +// gatewayWithClass builds a Gateway whose Spec.GatewayClassName is set. +func gatewayWithClass(name, gcName string) *gwv1.Gateway { + return &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: gwv1.GatewaySpec{GatewayClassName: gwv1.ObjectName(gcName)}, + } +} + +// gatewayClass builds a GatewayClass with the given controllerName. +func gatewayClass(name, controllerName string) *gwv1.GatewayClass { + return &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: gwv1.GatewayClassSpec{ControllerName: gwv1.GatewayController(controllerName)}, + } +} + +func TestAlbClassPredicate_MatchingControllerName_Accepts(t *testing.T) { + gc := gatewayClass("alb-gc", consts.GatewayClassControllerNameALB) + gw := gatewayWithClass("gw-1", "alb-gc") + c := fake.NewClientBuilder().WithScheme(newPredicateScheme(t)).WithObjects(gc).Build() + + pred := albClassPredicate(c) + if !pred.Create(event.CreateEvent{Object: gw}) { + t.Errorf("expected predicate to accept Gateway under ALB GatewayClass") + } +} + +func TestAlbClassPredicate_NonMatchingControllerName_Rejects(t *testing.T) { + gc := gatewayClass("other-gc", "gateway.example.io/other") + gw := gatewayWithClass("gw-2", "other-gc") + c := fake.NewClientBuilder().WithScheme(newPredicateScheme(t)).WithObjects(gc).Build() + + pred := albClassPredicate(c) + if pred.Create(event.CreateEvent{Object: gw}) { + t.Errorf("expected predicate to reject Gateway under non-ALB GatewayClass") + } +} + +func TestAlbClassPredicate_EmptyGatewayClassName_Rejects(t *testing.T) { + gw := gatewayWithClass("gw-empty", "") + c := fake.NewClientBuilder().WithScheme(newPredicateScheme(t)).Build() + + pred := albClassPredicate(c) + if pred.Create(event.CreateEvent{Object: gw}) { + t.Errorf("expected predicate to reject Gateway with empty GatewayClassName") + } +} + +func TestAlbClassPredicate_MissingGatewayClass_Rejects(t *testing.T) { + // Gateway references a class that doesn't exist in the cluster. + gw := gatewayWithClass("gw-orphan", "absent-gc") + c := fake.NewClientBuilder().WithScheme(newPredicateScheme(t)).Build() + + pred := albClassPredicate(c) + if pred.Create(event.CreateEvent{Object: gw}) { + t.Errorf("expected predicate to reject Gateway whose GatewayClass is not found") + } +} + +func TestAlbClassPredicate_NonGatewayObject_Rejects(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(newPredicateScheme(t)).Build() + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "stray", Namespace: "default"}} + + pred := albClassPredicate(c) + if pred.Create(event.CreateEvent{Object: svc}) { + t.Errorf("expected predicate to reject non-Gateway object") + } +} + +func TestAlbClassPredicate_AppliesToUpdateAndDelete(t *testing.T) { + // Sanity check: NewPredicateFuncs wires the same matcher into all four + // event types. We verify Update + Delete since reconcile loops triggered + // by class swaps and finalizer-removal both go through these paths. + gc := gatewayClass("alb-gc", consts.GatewayClassControllerNameALB) + gw := gatewayWithClass("gw-1", "alb-gc") + c := fake.NewClientBuilder().WithScheme(newPredicateScheme(t)).WithObjects(gc).Build() + + pred := albClassPredicate(c) + if !pred.Update(event.UpdateEvent{ObjectOld: gw, ObjectNew: gw}) { + t.Errorf("expected predicate to accept ALB Gateway on Update") + } + if !pred.Delete(event.DeleteEvent{Object: gw}) { + t.Errorf("expected predicate to accept ALB Gateway on Delete") + } +} + +// --- conditionsEqualIgnoreTime --- + +func TestConditionsEqualIgnoreTime(t *testing.T) { + t1 := metav1.Now() + t2 := metav1.NewTime(t1.Add(42)) // same content, different timestamp + + base := []metav1.Condition{ + { + Type: "Accepted", + Status: metav1.ConditionTrue, + Reason: "Accepted", + Message: "ok", + ObservedGeneration: 3, + LastTransitionTime: t1, + }, + } + + cases := []struct { + name string + a, b []metav1.Condition + want bool + }{ + { + name: "identical conditions", + a: base, + b: base, + want: true, + }, + { + name: "same content, different LastTransitionTime", + a: base, + b: []metav1.Condition{{ + Type: "Accepted", Status: metav1.ConditionTrue, + Reason: "Accepted", Message: "ok", + ObservedGeneration: 3, LastTransitionTime: t2, + }}, + want: true, + }, + { + name: "different length", + a: base, + b: []metav1.Condition{}, + want: false, + }, + { + name: "different Status", + a: base, + b: []metav1.Condition{{ + Type: "Accepted", Status: metav1.ConditionFalse, + Reason: "Accepted", Message: "ok", ObservedGeneration: 3, + }}, + want: false, + }, + { + name: "different Reason", + a: base, + b: []metav1.Condition{{ + Type: "Accepted", Status: metav1.ConditionTrue, + Reason: "Pending", Message: "ok", ObservedGeneration: 3, + }}, + want: false, + }, + { + name: "different Message", + a: base, + b: []metav1.Condition{{ + Type: "Accepted", Status: metav1.ConditionTrue, + Reason: "Accepted", Message: "different", ObservedGeneration: 3, + }}, + want: false, + }, + { + name: "different ObservedGeneration", + a: base, + b: []metav1.Condition{{ + Type: "Accepted", Status: metav1.ConditionTrue, + Reason: "Accepted", Message: "ok", ObservedGeneration: 4, + }}, + want: false, + }, + { + name: "different condition Type (no overlap)", + a: base, + b: []metav1.Condition{{ + Type: "Programmed", Status: metav1.ConditionTrue, + Reason: "Accepted", Message: "ok", ObservedGeneration: 3, + }}, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := conditionsEqualIgnoreTime(tc.a, tc.b) + if got != tc.want { + t.Errorf("conditionsEqualIgnoreTime: got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/controller/gateway/alb/suite_test.go b/internal/controller/gateway/alb/suite_test.go new file mode 100644 index 00000000..e0053116 --- /dev/null +++ b/internal/controller/gateway/alb/suite_test.go @@ -0,0 +1,214 @@ +package alb + +import ( + "context" + "os" + "os/exec" + "path" + "path/filepath" + goruntime "runtime" + "strconv" + "strings" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + gatewaypolicies "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/policies" + gatewayshared "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/k8s_repo" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/vngcloud_repo/vngcloud_mocks" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/alb_gateway_uc" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/k8s" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client + vngcloudRepo *vngcloud_mocks.MockProvider +) + +func TestALBController(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ALB Gateway Controller Suite") +} + +// gatewayAPICRDPath resolves the gateway-api standard CRD directory from the Go +// module cache. The version must match the one in go.mod. +func gatewayAPICRDPath() string { + out, err := exec.Command("go", "env", "GOMODCACHE").Output() + if err != nil { + panic(err) + } + return filepath.Join(strings.TrimSpace(string(out)), "sigs.k8s.io", "gateway-api@v1.2.0", "config", "crd", "standard") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + logrus.SetLevel(logrus.DebugLevel) + logrus.SetReportCaller(true) + logrus.SetFormatter(&logrus.TextFormatter{ + DisableTimestamp: true, + CallerPrettyfier: func(frame *goruntime.Frame) (function string, file string) { + fileName := path.Base(frame.File) + ":" + strconv.Itoa(frame.Line) + return "", fileName + }, + }) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = corev1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = v1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = gwv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = gwv1.Install(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + err = gwv1beta1.Install(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "..", "config", "crd", "bases"), + gatewayAPICRDPath(), + }, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if binDir := getFirstFoundEnvTestBinaryDir(); binDir != "" { + testEnv.BinaryAssetsDirectory = binDir + } + + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // Setup manager + k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + }) + Expect(err).ToNot(HaveOccurred()) + + k8sRepo := k8s_repo.NewK8sRepository(k8sManager.GetClient()) + + // Setup mock VNG Cloud repository + vngcloudRepo = vngcloud_mocks.NewMockProvider() + err = vngcloudRepo.Init(nil) + Expect(err).NotTo(HaveOccurred()) + + // Create a Node with a providerID that the MockProvider's GetServerNetworkInfo knows about. + // ServerId1 is "ins-00000000-0000-0000-0000-000000000001" and maps to MockSubnetID in MockServerIdToSubnet. + By("creating test node with vngcloud providerID") + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-alb-node-1", + }, + Spec: corev1.NodeSpec{ + // Format expected by GetProviderIdFromNode: "vngcloud://ins-" + ProviderID: "vngcloud://" + vngcloud_mocks.ServerId1, + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, node)).To(Succeed()) + Expect(k8sClient.Status().Update(ctx, node)).To(Succeed()) + + // Build the ALB Gateway use case + endpointResolver := utils.NewDefaultEndpointResolver(ctx, k8sManager.GetClient()) + albGatewayUseCase := alb_gateway_uc.NewALBGatewayUseCase( + "test-cluster", + k8sRepo, + vngcloudRepo, + endpointResolver, + k8sManager.GetClient(), + k8s.NewDefaultFinalizerManager(k8sManager.GetClient(), ctrl.Log), + ) + + // Setup Gateway reconciler + gatewayReconciler := NewGatewayReconciler( + k8sManager.GetClient(), + k8sManager.GetScheme(), + albGatewayUseCase, + 1, + ) + err = gatewayReconciler.SetupWithManager(ctx, k8sManager) + Expect(err).ToNot(HaveOccurred()) + + // Setup GatewayClass reconciler + gcReconciler := NewGatewayClassReconciler(k8sManager.GetClient(), k8sManager.GetScheme()) + err = gcReconciler.SetupWithManager(ctx, k8sManager) + Expect(err).ToNot(HaveOccurred()) + + // Policy validators (status-only) under test, mirroring cmd/main.go. + for _, pr := range gatewaypolicies.AllReconcilers(k8sManager.GetClient()) { + Expect(pr.SetupWithManager(ctx, k8sManager)).To(Succeed()) + } + + // Register field indexes (mirrors cmd/main.go ordering: after SetupWithManager, before mgr.Start) + err = gatewayshared.RegisterIndexes(ctx, k8sManager) + Expect(err).ToNot(HaveOccurred()) + + // Start the manager + go func() { + defer GinkgoRecover() + err = k8sManager.Start(ctx) + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + }() +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/controller/gateway/nlb/controller_scenarios_test.go b/internal/controller/gateway/nlb/controller_scenarios_test.go new file mode 100644 index 00000000..b568897a --- /dev/null +++ b/internal/controller/gateway/nlb/controller_scenarios_test.go @@ -0,0 +1,261 @@ +package nlb + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +// These envtest specs port behavioral scenarios from the Service controller +// ("update port", "all annotations -> LB attrs") and Ingress controller +// ("prefer subnet ID") onto the NLB Gateway path. They assert on the generated +// LoadBalancerConfig against the mock cloud — the same level the source +// controller suites operate at. + +func ensureNLBClass() { + gc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-nlb"}, + Spec: gwv1.GatewayClassSpec{ControllerName: gwv1.GatewayController(consts.GatewayClassControllerNameNLB)}, + } + Expect(client.IgnoreAlreadyExists(k8sClient.Create(ctx, gc))).To(Succeed()) +} + +func mkNodePortSvc(name string, port int32) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: name}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Selector: map[string]string{"app": name}, + Ports: []corev1.ServicePort{{Port: port, Protocol: corev1.ProtocolTCP, TargetPort: intstr.FromInt(int(port))}}, + }, + } +} + +func mkTCPGateway(name string, port int32) *gwv1.Gateway { + return &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: name}, + Spec: gwv1.GatewaySpec{ + GatewayClassName: "vngcloud-nlb", + Listeners: []gwv1.Listener{{Name: "tcp", Protocol: gwv1.TCPProtocolType, Port: gwv1.PortNumber(port)}}, + }, + } +} + +func mkTCPRoute(name, gwName, svc string, port int32) *gwv1a2.TCPRoute { + p := gwv1.PortNumber(port) + return &gwv1a2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: name}, + Spec: gwv1a2.TCPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: gwv1.ObjectName(gwName)}}}, + Rules: []gwv1a2.TCPRouteRule{{ + BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: gwv1.ObjectName(svc), Port: &p}}}, + }}, + }, + } +} + +func gwTargetRef(gwName string) gwv1alpha1.LocalPolicyTargetReferenceWithSectionName { + return gwv1alpha1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwv1a2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: gwv1a2.ObjectName(gwName), + }, + } +} + +var _ = Describe("NLB Gateway scenarios ported from Service/Ingress", func() { + // Service controller: "When updating service port ... delete old listener + // and pool, and create new ones with updated port". + It("reflects a listener port change on the LBC", func() { + ensureNLBClass() + svc := mkNodePortSvc("portchg", 6379) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := mkTCPGateway("portchg-gw", 6379) + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + route := mkTCPRoute("portchg-route", "portchg-gw", "portchg", 6379) + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + By("LBC listener starts on the original port 6379") + Eventually(func(g Gomega) { + lbc := ownedLBC(g, gw) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Listeners).To(HaveLen(1)) + g.Expect(lbc.Spec.Listeners[0].ProtocolPort).To(Equal(int32(6379))) + }, 30*time.Second, time.Second).Should(Succeed()) + + By("changing the Gateway listener port updates the LBC listener") + Eventually(func(g Gomega) { + cur := &gwv1.Gateway{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: nlbNS, Name: "portchg-gw"}, cur)).To(Succeed()) + cur.Spec.Listeners[0].Port = 6380 + g.Expect(k8sClient.Update(ctx, cur)).To(Succeed()) + }, 10*time.Second, time.Second).Should(Succeed()) + + Eventually(func(g Gomega) { + lbc := ownedLBC(g, gw) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Listeners).To(HaveLen(1)) + g.Expect(lbc.Spec.Listeners[0].ProtocolPort).To(Equal(int32(6380)), "listener moved to the new port") + }, 30*time.Second, time.Second).Should(Succeed()) + }) + + // Service controller: "should create LoadBalancer with correct attributes + // from annotations" — here the attributes come from VKSGatewayPolicy. + It("maps LB-level attributes from VKSGatewayPolicy onto the LBC", func() { + ensureNLBClass() + pol := &gwv1alpha1.VKSGatewayPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "attrs-pol"}, + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + TargetRefs: []gwv1alpha1.LocalPolicyTargetReferenceWithSectionName{gwTargetRef("attrs-gw")}, + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{ + Scheme: ptr.To("Internet"), + Tags: map[string]string{"env": "test"}, + }, + }, + } + Expect(k8sClient.Create(ctx, pol)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, pol) }) + + svc := mkNodePortSvc("attrs", 8080) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + gw := mkTCPGateway("attrs-gw", 8080) + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + route := mkTCPRoute("attrs-route", "attrs-gw", "attrs", 8080) + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + Eventually(func(g Gomega) { + lbc := ownedLBC(g, gw) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Scheme).NotTo(BeNil()) + g.Expect(string(*lbc.Spec.Scheme)).To(Equal("Internet")) + g.Expect(lbc.Spec.Tags).To(HaveKeyWithValue("env", "test")) + }, 30*time.Second, time.Second).Should(Succeed()) + }) + + // Real-client-IP use case: VKSBackendPolicy.proxyProtocol switches the TCP + // pool to the PROXY pool protocol so an L4 backend (e.g. HAProxy ingress) + // can recover the client IP. + It("switches the pool to PROXY protocol when VKSBackendPolicy.proxyProtocol is set", func() { + ensureNLBClass() + bp := &gwv1alpha1.VKSBackendPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "proxy-bp"}, + Spec: gwv1alpha1.VKSBackendPolicySpec{ + TargetRefs: []gwv1alpha1.LocalPolicyTargetReference{{Group: "", Kind: "Service", Name: "proxy-svc"}}, + ProxyProtocol: ptr.To(true), + }, + } + Expect(k8sClient.Create(ctx, bp)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, bp) }) + + svc := mkNodePortSvc("proxy-svc", 8080) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + gw := mkTCPGateway("proxy-gw", 8080) + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + route := mkTCPRoute("proxy-route", "proxy-gw", "proxy-svc", 8080) + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + Eventually(func(g Gomega) { + lbc := ownedLBC(g, gw) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Pools).To(HaveLen(1)) + g.Expect(lbc.Spec.Pools[0].Protocol).To(Equal(v2.PoolProtocolProxy)) + }, 30*time.Second, time.Second).Should(Succeed()) + }) + + // Ingress controller: "When create ingress with prefer subnet ID annotation + // ... should create load balancer in the specified subnet". + It("creates the LBC in the subnet from VKSGatewayPolicy.loadBalancerSpec.subnetId", func() { + ensureNLBClass() + // Create the policy BEFORE the Gateway: subnetId is a create-only field. + pol := &gwv1alpha1.VKSGatewayPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "subnet-pol"}, + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + TargetRefs: []gwv1alpha1.LocalPolicyTargetReferenceWithSectionName{gwTargetRef("subnet-gw")}, + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{SubnetID: ptr.To("subnet-custom-123")}, + }, + } + Expect(k8sClient.Create(ctx, pol)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, pol) }) + + svc := mkNodePortSvc("subnet", 7000) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + gw := mkTCPGateway("subnet-gw", 7000) + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + route := mkTCPRoute("subnet-route", "subnet-gw", "subnet", 7000) + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + Eventually(func(g Gomega) { + lbc := ownedLBC(g, gw) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Type).To(Equal(v2.LoadBalancerTypeLayer4)) + g.Expect(lbc.Spec.SubnetId).To(Equal("subnet-custom-123")) + }, 30*time.Second, time.Second).Should(Succeed()) + }) + + // Service controller: scheme=InterVPC + private-subnet-id + private-zone-id. + // The InterVPC scheme needs the client subnet (in another VPC) and its zone; + // all three must reach the LBC verbatim, same values the Service controller + // would write. + It("maps the InterVPC scheme, private subnet and private zone onto the LBC", func() { + ensureNLBClass() + // scheme/private-subnet/private-zone are create-only: policy first. + pol := &gwv1alpha1.VKSGatewayPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "intervpc-pol"}, + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + TargetRefs: []gwv1alpha1.LocalPolicyTargetReferenceWithSectionName{gwTargetRef("intervpc-gw")}, + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{ + Scheme: ptr.To("InterVPC"), + PrivateSubnetID: ptr.To("subnet-client-vpc"), + PrivateZoneID: ptr.To("HCM03-1A"), + }, + }, + } + Expect(k8sClient.Create(ctx, pol)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, pol) }) + + svc := mkNodePortSvc("intervpc", 7100) + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + gw := mkTCPGateway("intervpc-gw", 7100) + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + route := mkTCPRoute("intervpc-route", "intervpc-gw", "intervpc", 7100) + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + Eventually(func(g Gomega) { + lbc := ownedLBC(g, gw) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Scheme).NotTo(BeNil()) + g.Expect(string(*lbc.Spec.Scheme)).To(Equal("InterVPC")) + g.Expect(lbc.Spec.PrivateSubnetId).To(HaveValue(Equal("subnet-client-vpc"))) + g.Expect(lbc.Spec.PrivateZoneId).NotTo(BeNil()) + g.Expect(string(*lbc.Spec.PrivateZoneId)).To(Equal("HCM03-1A")) + }, 30*time.Second, time.Second).Should(Succeed()) + }) +}) diff --git a/internal/controller/gateway/nlb/gateway_controller.go b/internal/controller/gateway/nlb/gateway_controller.go new file mode 100644 index 00000000..54c25dc1 --- /dev/null +++ b/internal/controller/gateway/nlb/gateway_controller.go @@ -0,0 +1,198 @@ +// Package nlb hosts the reconciler for Gateway-API Gateway objects under the +// vngcloud-nlb GatewayClass (Phase 2, L4). It is the thin sibling of the alb +// package: init gating, queue plumbing, and event filtering only. All +// Gateway → L4 LoadBalancerConfig translation lives in +// internal/usecase/gateway_uc/nlb_gateway_uc. +// +// Note: the TCPRoute/UDPRoute CRDs live in the Gateway-API *experimental* +// channel and are a deployment prerequisite. This controller is disabled by +// default (--disable-nlb-gateway-controller) so clusters without those CRDs +// don't fail to start a route watch. +package nlb + +import ( + "context" + "sync/atomic" + "time" + + "github.com/anngdinh/operator-helper/contexts" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + gwshared "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + gwhandlers "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared/eventhandlers" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/errs" +) + +const nlbControllerName = "gateway-nlb" + +// GatewayReconciler reconciles Gateway objects under the vngcloud-nlb class. +type GatewayReconciler struct { + k8sClient client.Client + scheme *runtime.Scheme + useCase usecase.NLBGatewayUseCase + + initDone atomic.Bool + maxConcurrentReconciles int +} + +func NewGatewayReconciler( + k8sClient client.Client, + scheme *runtime.Scheme, + useCase usecase.NLBGatewayUseCase, + maxConcurrentReconciles int, +) *GatewayReconciler { + return &GatewayReconciler{ + k8sClient: k8sClient, + scheme: scheme, + useCase: useCase, + maxConcurrentReconciles: func() int { + if maxConcurrentReconciles > 0 { + return maxConcurrentReconciles + } + return domain.DefaultMaxConcurrentReconciles + }(), + } +} + +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/status,verbs=update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways/finalizers,verbs=update +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=tcproutes;udproutes,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=tcproutes/status;udproutes/status,verbs=update;patch +// +kubebuilder:rbac:groups=gateway.vks.vngcloud.vn,resources=vksgatewaypolicies;vksbackendpolicies;vkshealthcheckpolicies;vksroutepolicies,verbs=get;list;watch +// +kubebuilder:rbac:groups=vks.vngcloud.vn,resources=loadbalancerconfigs,verbs=get;list;watch;create;update;patch;delete + +func (r *GatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + if !r.initDone.Load() { + ctrl.Log.Info("NLB Gateway init not done yet, requeueing...") + return ctrl.Result{RequeueAfter: 1 * time.Second}, nil + } + + ctx = contexts.NewContext(ctx). + SetLogName("nlb-gw/" + req.Namespace + "/" + req.Name). + GetContext() + logger := contexts.NewContext(ctx).Log() + + ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + + return errs.HandleReconcileError(r.useCase.EnsureNLBGatewayUseCase(ctx, req), logger) +} + +func (r *GatewayReconciler) SetupWithManager(ctx context.Context, mgr manager.Manager) error { + if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + log := ctrl.Log.WithName("init").WithName(nlbControllerName) + log.Info("Running initialization...") + + const maxAttempts = 5 + const backoff = 2 * time.Second + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + lastErr = r.useCase.InitNLBGatewayUseCase(ctx) + if lastErr == nil { + log.Info("Initialization complete", "attempt", attempt) + r.initDone.Store(true) + return nil + } + log.Error(lastErr, "Initialization failed; retrying", "attempt", attempt, "max", maxAttempts) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + } + log.Error(lastErr, "Fatal: initialization failed after retries") + return lastErr + })); err != nil { + return err + } + + // L4 route field indexes (TCP/UDP → parent Gateway, → backend Service). + // Registered here (not by the ALB block) so the NLB path is self-contained. + if err := gwshared.RegisterL4Indexes(ctx, mgr); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + Named(nlbControllerName). + For(&gwv1.Gateway{}, builder.WithPredicates(nlbClassPredicate(r.k8sClient))). + Watches(&gwv1a2.TCPRoute{}, gwhandlers.TCPRouteToGateway()). + Watches(&gwv1a2.UDPRoute{}, gwhandlers.UDPRouteToGateway()). + Watches(&gwv1alpha1.VKSGatewayPolicy{}, gwhandlers.VKSGatewayPolicyToGateway()). + Watches(&gwv1alpha1.VKSBackendPolicy{}, gwhandlers.VKSBackendPolicyToL4Gateway(r.k8sClient)). + Watches(&gwv1alpha1.VKSHealthCheckPolicy{}, gwhandlers.VKSHealthCheckPolicyToL4Gateway(r.k8sClient)). + Watches(&v1alpha1.LoadBalancerConfig{}, lbcOwnerToGateway()). + Watches(&corev1.Service{}, gwhandlers.ServiceToL4RouteParents(r.k8sClient)). + Watches(&gwv1beta1.ReferenceGrant{}, gwhandlers.ReferenceGrantToGateways(r.k8sClient)). + WithOptions(controller.Options{MaxConcurrentReconciles: r.maxConcurrentReconciles}). + Complete(r) +} + +// nlbClassPredicate keeps only Gateways bound to a GatewayClass whose +// ControllerName is the NLB one. +func nlbClassPredicate(k8sClient client.Client) predicate.Predicate { + matches := func(o client.Object) bool { + gw, ok := o.(*gwv1.Gateway) + if !ok || gw.Spec.GatewayClassName == "" { + return false + } + gc := &gwv1.GatewayClass{} + if err := k8sClient.Get(context.Background(), + client.ObjectKey{Name: string(gw.Spec.GatewayClassName)}, gc); err != nil { + return false + } + return string(gc.Spec.ControllerName) == consts.GatewayClassControllerNameNLB + } + return predicate.NewPredicateFuncs(matches) +} + +// lbcOwnerToGateway enqueues the Gateway that owns an LBC when the LBC changes +// (mirrors LBC.Status into Gateway.Status). +func lbcOwnerToGateway() handler.EventHandler { + enq := func(obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + lbc, ok := obj.(*v1alpha1.LoadBalancerConfig) + if !ok { + return + } + if lbc.Labels[domain.LabelOwnerResourceKind] != domain.OwnerKindGateway { + return + } + gwName := lbc.Labels[domain.LabelOwnerResourceName] + if gwName == "" { + return + } + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: lbc.Namespace, Name: gwName}}) + } + return handler.Funcs{ + CreateFunc: func(_ context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(e.Object, q) + }, + UpdateFunc: func(_ context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(e.ObjectNew, q) + }, + DeleteFunc: func(_ context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(e.Object, q) + }, + } +} diff --git a/internal/controller/gateway/nlb/gateway_controller_test.go b/internal/controller/gateway/nlb/gateway_controller_test.go new file mode 100644 index 00000000..676a6f7a --- /dev/null +++ b/internal/controller/gateway/nlb/gateway_controller_test.go @@ -0,0 +1,242 @@ +package nlb + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +const nlbNS = "default" + +var _ = Describe("NLB GatewayClass", func() { + It("accepts the vngcloud-nlb GatewayClass", func() { + gc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-nlb-test"}, + Spec: gwv1.GatewayClassSpec{ControllerName: gwv1.GatewayController(consts.GatewayClassControllerNameNLB)}, + } + Expect(k8sClient.Create(ctx, gc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gc) }) + + Eventually(func() metav1.ConditionStatus { + cur := &gwv1.GatewayClass{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: gc.Name}, cur); err != nil { + return metav1.ConditionUnknown + } + for _, c := range cur.Status.Conditions { + if c.Type == string(gwv1.GatewayClassConditionStatusAccepted) { + return c.Status + } + } + return metav1.ConditionUnknown + }, 20*time.Second, time.Second).Should(Equal(metav1.ConditionTrue)) + }) +}) + +var _ = Describe("NLB Gateway → L4 LoadBalancerConfig", func() { + It("creates a Type=Network LBC with a TCP listener+pool from an attached TCPRoute", func() { + gc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-nlb"}, + Spec: gwv1.GatewayClassSpec{ControllerName: gwv1.GatewayController(consts.GatewayClassControllerNameNLB)}, + } + Expect(client.IgnoreAlreadyExists(k8sClient.Create(ctx, gc))).To(Succeed()) + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "redis"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Selector: map[string]string{"app": "redis"}, + Ports: []corev1.ServicePort{{Port: 6379, TargetPort: intstr.FromInt(6379), NodePort: 31379}}, + }, + } + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "nlb-gw"}, + Spec: gwv1.GatewaySpec{ + GatewayClassName: "vngcloud-nlb", + Listeners: []gwv1.Listener{ + {Name: "tcp", Protocol: gwv1.TCPProtocolType, Port: 6379}, + }, + }, + } + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { + _ = k8sClient.Delete(ctx, gw) + }) + + port := gwv1.PortNumber(6379) + route := &gwv1a2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "redis-route"}, + Spec: gwv1a2.TCPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "nlb-gw"}}}, + Rules: []gwv1a2.TCPRouteRule{{ + BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "redis", Port: &port}}}, + }}, + }, + } + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + By("the controller creates a Type=Network LBC with the TCP listener and pool") + Eventually(func(g Gomega) { + lbc := ownedLBC(g, gw) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Type).To(Equal(v2.LoadBalancerTypeLayer4)) + g.Expect(lbc.Spec.Listeners).To(HaveLen(1)) + g.Expect(lbc.Spec.Listeners[0].Protocol).To(Equal(v2.ListenerProtocolTCP)) + g.Expect(lbc.Spec.Listeners[0].ProtocolPort).To(Equal(int32(6379))) + g.Expect(lbc.Spec.Pools).To(HaveLen(1)) + g.Expect(lbc.Spec.Pools[0].Protocol).To(Equal(v2.PoolProtocolTCP)) + }, 30*time.Second, time.Second).Should(Succeed()) + + By("the TCPRoute reports Accepted=True on its parent") + Eventually(func(g Gomega) { + cur := &gwv1a2.TCPRoute{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: nlbNS, Name: "redis-route"}, cur)).To(Succeed()) + g.Expect(cur.Status.Parents).NotTo(BeEmpty()) + var accepted = metav1.ConditionUnknown + for _, c := range cur.Status.Parents[0].Conditions { + if c.Type == string(gwv1.RouteConditionAccepted) { + accepted = c.Status + } + } + g.Expect(accepted).To(Equal(metav1.ConditionTrue)) + }, 30*time.Second, time.Second).Should(Succeed()) + }) +}) + +var _ = Describe("NLB Gateway UDP + deletion", func() { + It("creates a UDP listener+pool from a UDPRoute", func() { + gc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-nlb"}, + Spec: gwv1.GatewayClassSpec{ControllerName: gwv1.GatewayController(consts.GatewayClassControllerNameNLB)}, + } + Expect(client.IgnoreAlreadyExists(k8sClient.Create(ctx, gc))).To(Succeed()) + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "dns"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Selector: map[string]string{"app": "dns"}, + Ports: []corev1.ServicePort{{Port: 5353, Protocol: corev1.ProtocolUDP, TargetPort: intstr.FromInt(5353), NodePort: 32053}}, + }, + } + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "nlb-udp-gw"}, + Spec: gwv1.GatewaySpec{ + GatewayClassName: "vngcloud-nlb", + Listeners: []gwv1.Listener{{Name: "udp", Protocol: gwv1.UDPProtocolType, Port: 5353}}, + }, + } + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, gw) }) + + port := gwv1.PortNumber(5353) + route := &gwv1a2.UDPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "dns-route"}, + Spec: gwv1a2.UDPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "nlb-udp-gw"}}}, + Rules: []gwv1a2.UDPRouteRule{{ + BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "dns", Port: &port}}}, + }}, + }, + } + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + Eventually(func(g Gomega) { + lbc := ownedLBC(g, gw) + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Type).To(Equal(v2.LoadBalancerTypeLayer4)) + g.Expect(lbc.Spec.Listeners).To(HaveLen(1)) + g.Expect(lbc.Spec.Listeners[0].Protocol).To(Equal(v2.ListenerProtocolUDP)) + g.Expect(lbc.Spec.Pools).To(HaveLen(1)) + g.Expect(lbc.Spec.Pools[0].Protocol).To(Equal(v2.PoolProtocolUDP)) + }, 30*time.Second, time.Second).Should(Succeed()) + }) + + It("removes the owned LBC when the Gateway is deleted", func() { + gc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-nlb"}, + Spec: gwv1.GatewayClassSpec{ControllerName: gwv1.GatewayController(consts.GatewayClassControllerNameNLB)}, + } + Expect(client.IgnoreAlreadyExists(k8sClient.Create(ctx, gc))).To(Succeed()) + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "del-svc"}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Selector: map[string]string{"app": "del"}, + Ports: []corev1.ServicePort{{Port: 9000, Protocol: corev1.ProtocolTCP, TargetPort: intstr.FromInt(9000), NodePort: 30900}}, + }, + } + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, svc) }) + + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "nlb-del-gw"}, + Spec: gwv1.GatewaySpec{ + GatewayClassName: "vngcloud-nlb", + Listeners: []gwv1.Listener{{Name: "tcp", Protocol: gwv1.TCPProtocolType, Port: 9000}}, + }, + } + Expect(k8sClient.Create(ctx, gw)).To(Succeed()) + + port := gwv1.PortNumber(9000) + route := &gwv1a2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: nlbNS, Name: "del-route"}, + Spec: gwv1a2.TCPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "nlb-del-gw"}}}, + Rules: []gwv1a2.TCPRouteRule{{ + BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "del-svc", Port: &port}}}, + }}, + }, + } + Expect(k8sClient.Create(ctx, route)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, route) }) + + By("LBC is created") + var lbcName string + Eventually(func(g Gomega) { + lbc := ownedLBC(g, gw) + g.Expect(lbc).NotTo(BeNil()) + lbcName = lbc.Name + }, 30*time.Second, time.Second).Should(Succeed()) + + By("deleting the Gateway removes the owned LBC (finalizer cleanup)") + Expect(k8sClient.Delete(ctx, gw)).To(Succeed()) + Eventually(func(g Gomega) { + cur := &v1alpha1.LoadBalancerConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: nlbNS, Name: lbcName}, cur) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "owned LBC must be deleted") + }, 30*time.Second, time.Second).Should(Succeed()) + }) +}) + +func ownedLBC(g Gomega, gw *gwv1.Gateway) *v1alpha1.LoadBalancerConfig { + var list v1alpha1.LoadBalancerConfigList + g.Expect(k8sClient.List(ctx, &list, client.InNamespace(gw.Namespace), + client.MatchingLabels{domain.OwnerLabelGatewayUID: string(gw.UID)})).To(Succeed()) + if len(list.Items) == 0 { + return nil + } + return &list.Items[0] +} diff --git a/internal/controller/gateway/nlb/gatewayclass_controller.go b/internal/controller/gateway/nlb/gatewayclass_controller.go new file mode 100644 index 00000000..fca437d1 --- /dev/null +++ b/internal/controller/gateway/nlb/gatewayclass_controller.go @@ -0,0 +1,97 @@ +package nlb + +import ( + "context" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses/status,verbs=patch;update + +// GatewayClassReconciler accepts every GatewayClass whose ControllerName is the +// NLB one, writing only the Accepted condition. +type GatewayClassReconciler struct { + k8sClient client.Client + scheme *runtime.Scheme +} + +func NewGatewayClassReconciler(k8sClient client.Client, scheme *runtime.Scheme) *GatewayClassReconciler { + return &GatewayClassReconciler{k8sClient: k8sClient, scheme: scheme} +} + +func (r *GatewayClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + gc := &gwv1.GatewayClass{} + if err := r.k8sClient.Get(ctx, req.NamespacedName, gc); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + if string(gc.Spec.ControllerName) != consts.GatewayClassControllerNameNLB { + return ctrl.Result{}, nil + } + + cur := gc.DeepCopy() + accepted := metav1.Condition{ + Type: string(gwv1.GatewayClassConditionStatusAccepted), + Status: metav1.ConditionTrue, + Reason: string(gwv1.GatewayClassReasonAccepted), + Message: "GatewayClass accepted by " + consts.GatewayClassControllerNameNLB, + ObservedGeneration: gc.Generation, + LastTransitionTime: metav1.Now(), + } + shared.SetCondition(&cur.Status.Conditions, accepted) + + if conditionsEqualIgnoreTime(cur.Status.Conditions, gc.Status.Conditions) { + return ctrl.Result{}, nil + } + if err := r.k8sClient.Status().Patch(ctx, cur, client.MergeFrom(gc)); err != nil { + return ctrl.Result{RequeueAfter: 10 * time.Second}, err + } + return ctrl.Result{}, nil +} + +func (r *GatewayClassReconciler) SetupWithManager(_ context.Context, mgr manager.Manager) error { + matches := func(o client.Object) bool { + gc, ok := o.(*gwv1.GatewayClass) + return ok && string(gc.Spec.ControllerName) == consts.GatewayClassControllerNameNLB + } + return ctrl.NewControllerManagedBy(mgr). + Named("gatewayclass-nlb"). + For(&gwv1.GatewayClass{}, builder.WithPredicates(predicate.NewPredicateFuncs(matches))). + Complete(r) +} + +func conditionsEqualIgnoreTime(a, b []metav1.Condition) bool { + if len(a) != len(b) { + return false + } + idx := make(map[string]metav1.Condition, len(b)) + for i := range b { + idx[b[i].Type] = b[i] + } + for i := range a { + other, ok := idx[a[i].Type] + if !ok { + return false + } + if a[i].Status != other.Status || a[i].Reason != other.Reason || + a[i].Message != other.Message || a[i].ObservedGeneration != other.ObservedGeneration { + return false + } + } + return true +} diff --git a/internal/controller/gateway/nlb/suite_test.go b/internal/controller/gateway/nlb/suite_test.go new file mode 100644 index 00000000..52d4f923 --- /dev/null +++ b/internal/controller/gateway/nlb/suite_test.go @@ -0,0 +1,148 @@ +package nlb + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/k8s_repo" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository/vngcloud_repo/vngcloud_mocks" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/nlb_gateway_uc" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/k8s" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client + vngcloudRepo *vngcloud_mocks.MockProvider +) + +func TestNLBController(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "NLB Gateway Controller Suite") +} + +// gatewayAPIExperimentalCRDPath resolves the gateway-api experimental CRD +// directory (TCPRoute/UDPRoute live only there) from the module cache. +func gatewayAPIExperimentalCRDPath() string { + out, err := exec.Command("go", "env", "GOMODCACHE").Output() + if err != nil { + panic(err) + } + return filepath.Join(strings.TrimSpace(string(out)), "sigs.k8s.io", "gateway-api@v1.2.0", "config", "crd", "experimental") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + ctx, cancel = context.WithCancel(context.TODO()) + + Expect(corev1.AddToScheme(scheme.Scheme)).To(Succeed()) + Expect(v1alpha1.AddToScheme(scheme.Scheme)).To(Succeed()) + Expect(gwv1alpha1.AddToScheme(scheme.Scheme)).To(Succeed()) + Expect(gwv1.Install(scheme.Scheme)).To(Succeed()) + Expect(gwv1a2.Install(scheme.Scheme)).To(Succeed()) + Expect(gwv1beta1.Install(scheme.Scheme)).To(Succeed()) + + By("bootstrapping test environment (with experimental gateway-api CRDs)") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "..", "config", "crd", "bases"), + gatewayAPIExperimentalCRDPath(), + }, + ErrorIfCRDPathMissing: true, + } + if binDir := getFirstFoundEnvTestBinaryDir(); binDir != "" { + testEnv.BinaryAssetsDirectory = binDir + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + + // Disable the metrics listener so the suite doesn't collide with a locally + // running controller (or another envtest) on :8080. + k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).ToNot(HaveOccurred()) + + k8sRepo := k8s_repo.NewK8sRepository(k8sManager.GetClient()) + vngcloudRepo = vngcloud_mocks.NewMockProvider() + Expect(vngcloudRepo.Init(nil)).To(Succeed()) + + By("creating a test node with a vngcloud providerID") + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "test-nlb-node-1"}, + Spec: corev1.NodeSpec{ProviderID: "vngcloud://" + vngcloud_mocks.ServerId1}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "10.0.0.1"}}, + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}, + }, + } + Expect(k8sClient.Create(ctx, node)).To(Succeed()) + Expect(k8sClient.Status().Update(ctx, node)).To(Succeed()) + + endpointResolver := utils.NewDefaultEndpointResolver(ctx, k8sManager.GetClient()) + nlbUC := nlb_gateway_uc.NewNLBGatewayUseCase("test-cluster", k8sRepo, vngcloudRepo, endpointResolver, k8sManager.GetClient(), k8s.NewDefaultFinalizerManager(k8sManager.GetClient(), ctrl.Log)) + + gwReconciler := NewGatewayReconciler(k8sManager.GetClient(), k8sManager.GetScheme(), nlbUC, 1) + Expect(gwReconciler.SetupWithManager(ctx, k8sManager)).To(Succeed()) + + gcReconciler := NewGatewayClassReconciler(k8sManager.GetClient(), k8sManager.GetScheme()) + Expect(gcReconciler.SetupWithManager(ctx, k8sManager)).To(Succeed()) + + go func() { + defer GinkgoRecover() + Expect(k8sManager.Start(ctx)).To(Succeed(), "failed to run manager") + }() +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + Expect(testEnv.Stop()).To(Succeed()) +}) + +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/controller/gateway/policies/adapters.go b/internal/controller/gateway/policies/adapters.go new file mode 100644 index 00000000..a4336c07 --- /dev/null +++ b/internal/controller/gateway/policies/adapters.go @@ -0,0 +1,106 @@ +package policies + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +// AllReconcilers builds the validator for each of the four VKS policy CRDs. +// Registered together whenever a Gateway controller is enabled. +func AllReconcilers(c client.Client) []Setupable { + return []Setupable{ + newGatewayPolicyValidator(c), + newBackendPolicyValidator(c), + newHealthCheckPolicyValidator(c), + newRoutePolicyValidator(c), + } +} + +func withSectionTargets(refs []gwv1alpha1.LocalPolicyTargetReferenceWithSectionName) []targetRef { + out := make([]targetRef, 0, len(refs)) + for _, ref := range refs { + tr := targetRef{Group: string(ref.Group), Kind: string(ref.Kind), Name: string(ref.Name)} + if ref.SectionName != nil { + tr.SectionName = string(*ref.SectionName) + } + out = append(out, tr) + } + return out +} + +func plainTargets(refs []gwv1alpha1.LocalPolicyTargetReference) []targetRef { + out := make([]targetRef, 0, len(refs)) + for _, ref := range refs { + out = append(out, targetRef{Group: string(ref.Group), Kind: string(ref.Kind), Name: string(ref.Name)}) + } + return out +} + +func newGatewayPolicyValidator(c client.Client) *Reconciler[*gwv1alpha1.VKSGatewayPolicy] { + return &Reconciler[*gwv1alpha1.VKSGatewayPolicy]{client: c, a: adapter[*gwv1alpha1.VKSGatewayPolicy]{ + name: "vksgatewaypolicy-validator", + newObj: func() *gwv1alpha1.VKSGatewayPolicy { return &gwv1alpha1.VKSGatewayPolicy{} }, + newList: func() client.ObjectList { return &gwv1alpha1.VKSGatewayPolicyList{} }, + items: func(l client.ObjectList) []*gwv1alpha1.VKSGatewayPolicy { + pl := l.(*gwv1alpha1.VKSGatewayPolicyList) + out := make([]*gwv1alpha1.VKSGatewayPolicy, 0, len(pl.Items)) + for i := range pl.Items { + out = append(out, &pl.Items[i]) + } + return out + }, + targetsOf: func(p *gwv1alpha1.VKSGatewayPolicy) []targetRef { return withSectionTargets(p.Spec.TargetRefs) }, + }} +} + +func newRoutePolicyValidator(c client.Client) *Reconciler[*gwv1alpha1.VKSRoutePolicy] { + return &Reconciler[*gwv1alpha1.VKSRoutePolicy]{client: c, a: adapter[*gwv1alpha1.VKSRoutePolicy]{ + name: "vksroutepolicy-validator", + newObj: func() *gwv1alpha1.VKSRoutePolicy { return &gwv1alpha1.VKSRoutePolicy{} }, + newList: func() client.ObjectList { return &gwv1alpha1.VKSRoutePolicyList{} }, + items: func(l client.ObjectList) []*gwv1alpha1.VKSRoutePolicy { + pl := l.(*gwv1alpha1.VKSRoutePolicyList) + out := make([]*gwv1alpha1.VKSRoutePolicy, 0, len(pl.Items)) + for i := range pl.Items { + out = append(out, &pl.Items[i]) + } + return out + }, + targetsOf: func(p *gwv1alpha1.VKSRoutePolicy) []targetRef { return withSectionTargets(p.Spec.TargetRefs) }, + }} +} + +func newBackendPolicyValidator(c client.Client) *Reconciler[*gwv1alpha1.VKSBackendPolicy] { + return &Reconciler[*gwv1alpha1.VKSBackendPolicy]{client: c, a: adapter[*gwv1alpha1.VKSBackendPolicy]{ + name: "vksbackendpolicy-validator", + newObj: func() *gwv1alpha1.VKSBackendPolicy { return &gwv1alpha1.VKSBackendPolicy{} }, + newList: func() client.ObjectList { return &gwv1alpha1.VKSBackendPolicyList{} }, + items: func(l client.ObjectList) []*gwv1alpha1.VKSBackendPolicy { + pl := l.(*gwv1alpha1.VKSBackendPolicyList) + out := make([]*gwv1alpha1.VKSBackendPolicy, 0, len(pl.Items)) + for i := range pl.Items { + out = append(out, &pl.Items[i]) + } + return out + }, + targetsOf: func(p *gwv1alpha1.VKSBackendPolicy) []targetRef { return plainTargets(p.Spec.TargetRefs) }, + }} +} + +func newHealthCheckPolicyValidator(c client.Client) *Reconciler[*gwv1alpha1.VKSHealthCheckPolicy] { + return &Reconciler[*gwv1alpha1.VKSHealthCheckPolicy]{client: c, a: adapter[*gwv1alpha1.VKSHealthCheckPolicy]{ + name: "vkshealthcheckpolicy-validator", + newObj: func() *gwv1alpha1.VKSHealthCheckPolicy { return &gwv1alpha1.VKSHealthCheckPolicy{} }, + newList: func() client.ObjectList { return &gwv1alpha1.VKSHealthCheckPolicyList{} }, + items: func(l client.ObjectList) []*gwv1alpha1.VKSHealthCheckPolicy { + pl := l.(*gwv1alpha1.VKSHealthCheckPolicyList) + out := make([]*gwv1alpha1.VKSHealthCheckPolicy, 0, len(pl.Items)) + for i := range pl.Items { + out = append(out, &pl.Items[i]) + } + return out + }, + targetsOf: func(p *gwv1alpha1.VKSHealthCheckPolicy) []targetRef { return plainTargets(p.Spec.TargetRefs) }, + }} +} diff --git a/internal/controller/gateway/policies/validator.go b/internal/controller/gateway/policies/validator.go new file mode 100644 index 00000000..ef9ff07f --- /dev/null +++ b/internal/controller/gateway/policies/validator.go @@ -0,0 +1,288 @@ +// Package policies holds the Direct-policy validator controllers. Each watches +// one VKS policy CRD and writes its GEP-713 status (Accepted / Conflicted / +// TargetNotFound) plus per-target ancestor status. The Gateway use case is the +// sole consumer of the resolved (oldest-wins) policy view; these controllers +// only report status, they never touch the LoadBalancer. +package policies + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + gwshared "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + ucshared "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +//nolint:lll +// +kubebuilder:rbac:groups=gateway.vks.vngcloud.vn,resources=vksgatewaypolicies/status;vksbackendpolicies/status;vkshealthcheckpolicies/status;vksroutepolicies/status,verbs=get;update;patch + +var albController = gwv1.GatewayController(consts.GatewayClassControllerNameALB) + +// targetRef is the normalized form of a policy's targetRef. +type targetRef struct { + Group, Kind, Name, SectionName string +} + +// validatablePolicy is satisfied by all four VKS policy CRDs (pointer types). +type validatablePolicy interface { + client.Object + ucshared.PolicyObj + GetCommonStatus() *gwv1alpha1.CommonStatus + GetCommonPolicyStatus() *gwv1alpha1.CommonPolicyStatus +} + +// adapter carries the per-CRD specifics the generic validator needs. +type adapter[P validatablePolicy] struct { + name string + newObj func() P + newList func() client.ObjectList + items func(client.ObjectList) []P + targetsOf func(P) []targetRef +} + +// Reconciler is a generic Direct-policy validator. +type Reconciler[P validatablePolicy] struct { + client client.Client + a adapter[P] +} + +// Setupable is the common interface returned by AllReconcilers. +type Setupable interface { + SetupWithManager(ctx context.Context, mgr manager.Manager) error +} + +func (r *Reconciler[P]) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + p := r.a.newObj() + if err := r.client.Get(ctx, req.NamespacedName, p); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + list := r.a.newList() + if err := r.client.List(ctx, list, client.InNamespace(p.GetNamespace())); err != nil { + return ctrl.Result{}, err + } + siblings := r.a.items(list) + + ancestors := make([]gwv1alpha1.PolicyAncestorStatus, 0) + overall := acceptedCond(p.GetGeneration()) + for _, tr := range r.a.targetsOf(p) { + cond := r.targetCondition(ctx, p, siblings, tr) + ancestors = append(ancestors, gwv1alpha1.PolicyAncestorStatus{ + AncestorRef: ancestorRef(tr), + ControllerName: albController, + Conditions: newConds(cond), + }) + overall = worseCond(overall, cond) + } + + cur := p.DeepCopyObject().(P) + cs := cur.GetCommonStatus() + gwshared.SetCondition(&cs.Conditions, overall) + cs.ObservedGeneration = p.GetGeneration() + cur.GetCommonPolicyStatus().Ancestors = ancestors + + if policyStatusEqual(p, cur) { + return ctrl.Result{}, nil + } + if err := r.client.Status().Patch(ctx, cur, client.MergeFrom(p)); err != nil { + return ctrl.Result{RequeueAfter: 10 * time.Second}, err + } + return ctrl.Result{}, nil +} + +// targetCondition computes the Accepted condition for one (policy, targetRef): +// TargetNotFound if the target is missing, Conflicted if an older policy owns +// the same (target, sectionName), else Accepted. +func (r *Reconciler[P]) targetCondition(ctx context.Context, p P, siblings []P, tr targetRef) metav1.Condition { + gen := p.GetGeneration() + exists, err := r.targetExists(ctx, p.GetNamespace(), tr) + if err != nil { + return policyCond(metav1.ConditionFalse, gwv1alpha1.PolicyReasonTargetNotFound, + fmt.Sprintf("checking target %s %q: %v", tr.Kind, tr.Name, err), gen) + } + if !exists { + return policyCond(metav1.ConditionFalse, gwv1alpha1.PolicyReasonTargetNotFound, + fmt.Sprintf("target %s %q not found in namespace %s", tr.Kind, tr.Name, p.GetNamespace()), gen) + } + pt := pkggw.PolicyTarget{Group: tr.Group, Kind: tr.Kind, Namespace: p.GetNamespace(), Name: tr.Name, SectionName: tr.SectionName} + winner, _ := ucshared.ResolveDirectPolicy(siblings, pt) + if sameObject(winner, p) { + return policyCond(metav1.ConditionTrue, gwv1alpha1.PolicyReasonAccepted, "Policy accepted", gen) + } + return policyCond(metav1.ConditionFalse, gwv1alpha1.PolicyReasonConflicted, + fmt.Sprintf("Shadowed by older policy %s/%s targeting the same %s", winner.GetNamespace(), winner.GetName(), tr.Kind), gen) +} + +func (r *Reconciler[P]) targetExists(ctx context.Context, ns string, tr targetRef) (bool, error) { + var obj client.Object + switch tr.Kind { + case "Gateway": + obj = &gwv1.Gateway{} + case "HTTPRoute": + obj = &gwv1.HTTPRoute{} + case "Service": + obj = &corev1.Service{} + default: + return false, nil + } + if err := r.client.Get(ctx, types.NamespacedName{Namespace: ns, Name: tr.Name}, obj); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + +// SetupWithManager watches the CRD for spec/create/delete changes (status-only +// updates are filtered by GenerationChangedPredicate, so our own status writes +// don't re-trigger). A second watch re-enqueues all same-kind policies in the +// namespace on any such change, so a conflict re-resolves when a sibling is +// created or the winner is deleted. +func (r *Reconciler[P]) SetupWithManager(_ context.Context, mgr manager.Manager) error { + enqueueSiblings := handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + list := r.a.newList() + if err := r.client.List(ctx, list, client.InNamespace(obj.GetNamespace())); err != nil { + return nil + } + var reqs []reconcile.Request + for _, it := range r.a.items(list) { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: it.GetNamespace(), Name: it.GetName()}}) + } + return reqs + }) + return ctrl.NewControllerManagedBy(mgr). + Named(r.a.name). + For(r.a.newObj(), builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Watches(r.a.newObj(), enqueueSiblings, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Complete(r) +} + +// --- helpers --- + +func ancestorRef(tr targetRef) gwv1.ParentReference { + g := gwv1.Group(tr.Group) + k := gwv1.Kind(tr.Kind) + ref := gwv1.ParentReference{Group: &g, Kind: &k, Name: gwv1.ObjectName(tr.Name)} + if tr.SectionName != "" { + s := gwv1.SectionName(tr.SectionName) + ref.SectionName = &s + } + return ref +} + +func policyCond(status metav1.ConditionStatus, reason, msg string, gen int64) metav1.Condition { + return metav1.Condition{ + Type: gwv1alpha1.PolicyConditionAccepted, + Status: status, + Reason: reason, + Message: msg, + ObservedGeneration: gen, + LastTransitionTime: metav1.Now(), + } +} + +func acceptedCond(gen int64) metav1.Condition { + return policyCond(metav1.ConditionTrue, gwv1alpha1.PolicyReasonAccepted, "Policy accepted", gen) +} + +func newConds(c metav1.Condition) []metav1.Condition { + var out []metav1.Condition + gwshared.SetCondition(&out, c) + return out +} + +func condRank(reason string) int { + switch reason { + case gwv1alpha1.PolicyReasonTargetNotFound: + return 3 + case gwv1alpha1.PolicyReasonConflicted: + return 2 + case gwv1alpha1.PolicyReasonInvalid: + return 1 + default: + return 0 + } +} + +func worseCond(a, b metav1.Condition) metav1.Condition { + if condRank(b.Reason) > condRank(a.Reason) { + return b + } + return a +} + +func sameObject[P validatablePolicy](a, b P) bool { + return a.GetName() == b.GetName() && a.GetNamespace() == b.GetNamespace() +} + +func policyStatusEqual[P validatablePolicy](a, b P) bool { + as, bs := a.GetCommonStatus(), b.GetCommonStatus() + if as.ObservedGeneration != bs.ObservedGeneration { + return false + } + if !conditionsEqualIgnoreTime(as.Conditions, bs.Conditions) { + return false + } + return ancestorsEqual(a.GetCommonPolicyStatus().Ancestors, b.GetCommonPolicyStatus().Ancestors) +} + +func ancestorsEqual(a, b []gwv1alpha1.PolicyAncestorStatus) bool { + if len(a) != len(b) { + return false + } + for i := range a { + var match *gwv1alpha1.PolicyAncestorStatus + for j := range b { + if b[j].ControllerName == a[i].ControllerName && b[j].AncestorRef.Name == a[i].AncestorRef.Name { + match = &b[j] + break + } + } + if match == nil || !conditionsEqualIgnoreTime(a[i].Conditions, match.Conditions) { + return false + } + } + return true +} + +func conditionsEqualIgnoreTime(a, b []metav1.Condition) bool { + if len(a) != len(b) { + return false + } + idx := make(map[string]metav1.Condition, len(b)) + for i := range b { + idx[b[i].Type] = b[i] + } + for i := range a { + other, ok := idx[a[i].Type] + if !ok { + return false + } + if a[i].Status != other.Status || a[i].Reason != other.Reason || + a[i].Message != other.Message || a[i].ObservedGeneration != other.ObservedGeneration { + return false + } + } + return true +} diff --git a/internal/controller/gateway/policies/validator_test.go b/internal/controller/gateway/policies/validator_test.go new file mode 100644 index 00000000..b608b5bc --- /dev/null +++ b/internal/controller/gateway/policies/validator_test.go @@ -0,0 +1,126 @@ +package policies + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +func policyScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = gwv1.Install(s) + _ = gwv1alpha1.AddToScheme(s) + _ = corev1.AddToScheme(s) + return s +} + +func newFakeClient(scheme *runtime.Scheme, objs ...client.Object) client.Client { + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithStatusSubresource(&gwv1alpha1.VKSGatewayPolicy{}, &gwv1alpha1.VKSBackendPolicy{}). + Build() +} + +// nolint:unparam +func gwPolicyTargeting(name, ns, gwName string, created time.Time) *gwv1alpha1.VKSGatewayPolicy { + return &gwv1alpha1.VKSGatewayPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name, CreationTimestamp: metav1.NewTime(created)}, + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + TargetRefs: []gwv1alpha1.LocalPolicyTargetReferenceWithSectionName{{ + LocalPolicyTargetReference: gwv1alpha2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: gwv1alpha2.ObjectName(gwName), + }, + }}, + }, + } +} + +func acceptedCondOf(p *gwv1alpha1.VKSGatewayPolicy) *metav1.Condition { + for i := range p.Status.Conditions { + if p.Status.Conditions[i].Type == gwv1alpha1.PolicyConditionAccepted { + return &p.Status.Conditions[i] + } + } + return nil +} + +// nolint:unparam +func reconcileGwPolicy(t *testing.T, c client.Client, name, ns string) *gwv1alpha1.VKSGatewayPolicy { + t.Helper() + r := newGatewayPolicyValidator(c) + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: name}}) + assert.NoError(t, err) + got := &gwv1alpha1.VKSGatewayPolicy{} + assert.NoError(t, c.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: name}, got)) + return got +} + +func TestValidator_AcceptedWhenTargetExists(t *testing.T) { + s := policyScheme() + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + p := gwPolicyTargeting("only", "prod", "gw", time.Unix(100, 0)) + c := newFakeClient(s, gw, p) + + got := reconcileGwPolicy(t, c, "only", "prod") + cond := acceptedCondOf(got) + assert.NotNil(t, cond) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, gwv1alpha1.PolicyReasonAccepted, cond.Reason) + assert.Len(t, got.Status.Ancestors, 1) + assert.Equal(t, albController, got.Status.Ancestors[0].ControllerName) +} + +func TestValidator_TargetNotFound(t *testing.T) { + s := policyScheme() + p := gwPolicyTargeting("orphan", "prod", "ghost-gw", time.Unix(100, 0)) + c := newFakeClient(s, p) // no Gateway + + got := reconcileGwPolicy(t, c, "orphan", "prod") + cond := acceptedCondOf(got) + assert.NotNil(t, cond) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, gwv1alpha1.PolicyReasonTargetNotFound, cond.Reason) +} + +func TestValidator_ConflictOldestWins(t *testing.T) { + s := policyScheme() + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + older := gwPolicyTargeting("older", "prod", "gw", time.Unix(100, 0)) + newer := gwPolicyTargeting("newer", "prod", "gw", time.Unix(200, 0)) + c := newFakeClient(s, gw, older, newer) + + gotOlder := reconcileGwPolicy(t, c, "older", "prod") + assert.Equal(t, metav1.ConditionTrue, acceptedCondOf(gotOlder).Status) + assert.Equal(t, gwv1alpha1.PolicyReasonAccepted, acceptedCondOf(gotOlder).Reason) + + gotNewer := reconcileGwPolicy(t, c, "newer", "prod") + assert.Equal(t, metav1.ConditionFalse, acceptedCondOf(gotNewer).Status) + assert.Equal(t, gwv1alpha1.PolicyReasonConflicted, acceptedCondOf(gotNewer).Reason) +} + +func TestValidator_Idempotent(t *testing.T) { + s := policyScheme() + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + p := gwPolicyTargeting("only", "prod", "gw", time.Unix(100, 0)) + c := newFakeClient(s, gw, p) + + first := reconcileGwPolicy(t, c, "only", "prod") + rv1 := first.ResourceVersion + second := reconcileGwPolicy(t, c, "only", "prod") + // No status change on the second reconcile -> no patch -> resourceVersion stable. + assert.Equal(t, rv1, second.ResourceVersion, "idempotent reconcile must not re-patch") +} diff --git a/internal/controller/gateway/shared/classifier.go b/internal/controller/gateway/shared/classifier.go new file mode 100644 index 00000000..127c455e --- /dev/null +++ b/internal/controller/gateway/shared/classifier.go @@ -0,0 +1,34 @@ +package shared + +import ( + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func ProtocolAllowedForALB(p gwv1.ProtocolType) bool { + switch p { + case gwv1.HTTPProtocolType, gwv1.HTTPSProtocolType, gwv1.TLSProtocolType: + return true + } + return false +} + +func ProtocolAllowedForNLB(p gwv1.ProtocolType) bool { + switch p { + case gwv1.TCPProtocolType, gwv1.UDPProtocolType, gwv1.TLSProtocolType: + return true + } + return false +} + +func HasMixedProtocols(listeners []gwv1.Listener, gwClass string) bool { + sawL7, sawL4 := false, false + for _, l := range listeners { + switch { + case ProtocolAllowedForALB(l.Protocol) && l.Protocol != gwv1.TLSProtocolType: + sawL7 = true + case ProtocolAllowedForNLB(l.Protocol) && l.Protocol != gwv1.TLSProtocolType: + sawL4 = true + } + } + return sawL7 && sawL4 +} diff --git a/internal/controller/gateway/shared/classifier_test.go b/internal/controller/gateway/shared/classifier_test.go new file mode 100644 index 00000000..b9b76afb --- /dev/null +++ b/internal/controller/gateway/shared/classifier_test.go @@ -0,0 +1,36 @@ +package shared + +import ( + "testing" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestProtocolAllowedForALB(t *testing.T) { + cases := map[gwv1.ProtocolType]bool{ + gwv1.HTTPProtocolType: true, + gwv1.HTTPSProtocolType: true, + gwv1.TLSProtocolType: true, + gwv1.TCPProtocolType: false, + gwv1.UDPProtocolType: false, + } + for p, want := range cases { + if got := ProtocolAllowedForALB(p); got != want { + t.Errorf("ProtocolAllowedForALB(%q) = %v; want %v", p, got, want) + } + } +} + +func TestMixedProtocols(t *testing.T) { + listeners := []gwv1.Listener{ + {Protocol: gwv1.HTTPProtocolType}, + {Protocol: gwv1.TCPProtocolType}, + } + if !HasMixedProtocols(listeners, "alb") { + t.Fatal("expected mixed=true") + } + pure := []gwv1.Listener{{Protocol: gwv1.HTTPProtocolType}, {Protocol: gwv1.HTTPSProtocolType}} + if HasMixedProtocols(pure, "alb") { + t.Fatal("expected mixed=false") + } +} diff --git a/internal/controller/gateway/shared/eventhandlers/eventhandlers_test.go b/internal/controller/gateway/shared/eventhandlers/eventhandlers_test.go new file mode 100644 index 00000000..9662940c --- /dev/null +++ b/internal/controller/gateway/shared/eventhandlers/eventhandlers_test.go @@ -0,0 +1,318 @@ +package eventhandlers + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" +) + +// fakeQueue captures enqueued requests without needing a real work queue. +type fakeQueue struct { + workqueue.TypedRateLimitingInterface[reconcile.Request] + items []reconcile.Request +} + +func (q *fakeQueue) Add(item reconcile.Request) { q.items = append(q.items, item) } + +func testScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = corev1.AddToScheme(s) + _ = gwv1.Install(s) + _ = vksv1.AddToScheme(s) + return s +} + +// routeWithParents constructs an HTTPRoute that declares the given parent Gateway names. +func routeWithParents(routeNS string, parents ...types.NamespacedName) *gwv1.HTTPRoute { + r := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route", Namespace: routeNS}, + } + for _, p := range parents { + ref := gwv1.ParentReference{Name: gwv1.ObjectName(p.Name)} + if p.Namespace != "" && p.Namespace != routeNS { + ns := gwv1.Namespace(p.Namespace) + ref.Namespace = &ns + } + r.Spec.ParentRefs = append(r.Spec.ParentRefs, ref) + } + return r +} + +// ---- RouteToGateway --------------------------------------------------------- + +func TestRouteToGateway_CreateEnqueuesParents(t *testing.T) { + route := routeWithParents("ns", + types.NamespacedName{Namespace: "ns", Name: "gw-a"}, + types.NamespacedName{Namespace: "other", Name: "gw-b"}, + ) + h := RouteToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: route}, q) + assertRequests(t, q.items, []types.NamespacedName{ + {Namespace: "ns", Name: "gw-a"}, + {Namespace: "other", Name: "gw-b"}, + }) +} + +func TestRouteToGateway_UpdateEnqueuesNewObject(t *testing.T) { + oldRoute := routeWithParents("ns", types.NamespacedName{Namespace: "ns", Name: "gw-old"}) + newRoute := routeWithParents("ns", types.NamespacedName{Namespace: "ns", Name: "gw-new"}) + h := RouteToGateway() + q := &fakeQueue{} + h.Update(context.Background(), event.UpdateEvent{ObjectOld: oldRoute, ObjectNew: newRoute}, q) + assertRequests(t, q.items, []types.NamespacedName{ + {Namespace: "ns", Name: "gw-new"}, + }) +} + +func TestRouteToGateway_DeleteEnqueuesParents(t *testing.T) { + route := routeWithParents("ns", types.NamespacedName{Namespace: "ns", Name: "gw-a"}) + h := RouteToGateway() + q := &fakeQueue{} + h.Delete(context.Background(), event.DeleteEvent{Object: route}, q) + assertRequests(t, q.items, []types.NamespacedName{ + {Namespace: "ns", Name: "gw-a"}, + }) +} + +func TestRouteToGateway_NonGatewayKindSkipped(t *testing.T) { + knd := gwv1.Kind("Service") + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route", Namespace: "ns"}, + } + route.Spec.ParentRefs = []gwv1.ParentReference{ + {Kind: &knd, Name: "svc-x"}, + } + h := RouteToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: route}, q) + if len(q.items) != 0 { + t.Errorf("expected 0 enqueued items, got %d", len(q.items)) + } +} + +func TestRouteToGateway_NonRouteObjectSkipped(t *testing.T) { + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "ns"}} + h := RouteToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: svc}, q) + if len(q.items) != 0 { + t.Errorf("expected 0 enqueued items, got %d", len(q.items)) + } +} + +// ---- VKSGatewayPolicyToGateway ---------------------------------------------- + +func policyWithTargets(ns string, names ...string) *vksv1.VKSGatewayPolicy { + refs := make([]vksv1.LocalPolicyTargetReferenceWithSectionName, len(names)) + for i, name := range names { + refs[i] = vksv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: vksv1.LocalPolicyTargetReference{Name: gwv1.ObjectName(name)}, + } + } + return &vksv1.VKSGatewayPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "pol", Namespace: ns}, + Spec: vksv1.VKSGatewayPolicySpec{TargetRefs: refs}, + } +} + +func TestVKSGatewayPolicyToGateway_CreateEnqueues(t *testing.T) { + pol := policyWithTargets("ns", "gw-a", "gw-b") + h := VKSGatewayPolicyToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: pol}, q) + assertRequests(t, q.items, []types.NamespacedName{ + {Namespace: "ns", Name: "gw-a"}, + {Namespace: "ns", Name: "gw-b"}, + }) +} + +func TestVKSGatewayPolicyToGateway_UpdateEnqueues(t *testing.T) { + oldPol := policyWithTargets("ns", "gw-old") + newPol := policyWithTargets("ns", "gw-new") + h := VKSGatewayPolicyToGateway() + q := &fakeQueue{} + h.Update(context.Background(), event.UpdateEvent{ObjectOld: oldPol, ObjectNew: newPol}, q) + assertRequests(t, q.items, []types.NamespacedName{ + {Namespace: "ns", Name: "gw-new"}, + }) +} + +func TestVKSGatewayPolicyToGateway_DeleteEnqueues(t *testing.T) { + pol := policyWithTargets("ns", "gw-a") + h := VKSGatewayPolicyToGateway() + q := &fakeQueue{} + h.Delete(context.Background(), event.DeleteEvent{Object: pol}, q) + assertRequests(t, q.items, []types.NamespacedName{ + {Namespace: "ns", Name: "gw-a"}, + }) +} + +func TestVKSGatewayPolicyToGateway_NonPolicySkipped(t *testing.T) { + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "ns"}} + h := VKSGatewayPolicyToGateway() + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: svc}, q) + if len(q.items) != 0 { + t.Errorf("expected 0 items, got %d", len(q.items)) + } +} + +// routeWithBackendAndParents builds an HTTPRoute in ns that refers to svcName as a backend +// and declares the given gateway names as parents. +func routeWithBackendAndParents(ns, svcName string, gatewayNames ...string) *gwv1.HTTPRoute { + r := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route", Namespace: ns}, + Spec: gwv1.HTTPRouteSpec{ + Rules: []gwv1.HTTPRouteRule{ + {BackendRefs: []gwv1.HTTPBackendRef{ + {BackendRef: gwv1.BackendRef{BackendObjectReference: gwv1.BackendObjectReference{Name: gwv1.ObjectName(svcName)}}}, + }}, + }, + }, + } + for _, gw := range gatewayNames { + r.Spec.ParentRefs = append(r.Spec.ParentRefs, gwv1.ParentReference{Name: gwv1.ObjectName(gw)}) + } + return r +} + +// routeWithBackendAndNonGWParent builds an HTTPRoute with a non-Gateway parentRef. +func routeWithBackendAndNonGWParent(ns, svcName string) *gwv1.HTTPRoute { + knd := gwv1.Kind("Service") + r := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route", Namespace: ns}, + Spec: gwv1.HTTPRouteSpec{ + Rules: []gwv1.HTTPRouteRule{ + {BackendRefs: []gwv1.HTTPBackendRef{ + {BackendRef: gwv1.BackendRef{BackendObjectReference: gwv1.BackendObjectReference{Name: gwv1.ObjectName(svcName)}}}, + }}, + }, + }, + } + r.Spec.ParentRefs = []gwv1.ParentReference{{Kind: &knd, Name: "svc-parent"}} + return r +} + +// ---- ServiceToRouteParents -------------------------------------------------- + +func TestServiceToRouteParents_CreateEnqueuesGateways(t *testing.T) { + scheme := testScheme() + route := routeWithBackendAndParents("ns", "svc", "gw-a") + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(route). + WithIndex(&gwv1.HTTPRoute{}, shared.IndexHTTPRouteByService, shared.IndexHTTPRouteByServiceFunc). + Build() + + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "ns"}} + h := ServiceToRouteParents(c) + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: svc}, q) + assertRequests(t, q.items, []types.NamespacedName{ + {Namespace: "ns", Name: "gw-a"}, + }) +} + +func TestServiceToRouteParents_UpdateEnqueuesGateways(t *testing.T) { + scheme := testScheme() + route := routeWithBackendAndParents("ns", "svc", "gw-x") + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(route). + WithIndex(&gwv1.HTTPRoute{}, shared.IndexHTTPRouteByService, shared.IndexHTTPRouteByServiceFunc). + Build() + + oldSvc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "ns"}} + newSvc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "ns"}} + h := ServiceToRouteParents(c) + q := &fakeQueue{} + h.Update(context.Background(), event.UpdateEvent{ObjectOld: oldSvc, ObjectNew: newSvc}, q) + assertRequests(t, q.items, []types.NamespacedName{ + {Namespace: "ns", Name: "gw-x"}, + }) +} + +func TestServiceToRouteParents_DeleteEnqueuesGateways(t *testing.T) { + scheme := testScheme() + route := routeWithBackendAndParents("ns", "svc", "gw-del") + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(route). + WithIndex(&gwv1.HTTPRoute{}, shared.IndexHTTPRouteByService, shared.IndexHTTPRouteByServiceFunc). + Build() + + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "ns"}} + h := ServiceToRouteParents(c) + q := &fakeQueue{} + h.Delete(context.Background(), event.DeleteEvent{Object: svc}, q) + assertRequests(t, q.items, []types.NamespacedName{ + {Namespace: "ns", Name: "gw-del"}, + }) +} + +func TestServiceToRouteParents_NonServiceSkipped(t *testing.T) { + scheme := testScheme() + c := fake.NewClientBuilder().WithScheme(scheme).Build() + route := &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Name: "route", Namespace: "ns"}} + h := ServiceToRouteParents(c) + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: route}, q) + if len(q.items) != 0 { + t.Errorf("expected 0 items, got %d", len(q.items)) + } +} + +func TestServiceToRouteParents_SkipsNonGatewayParents(t *testing.T) { + scheme := testScheme() + route := routeWithBackendAndNonGWParent("ns", "svc") + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(route). + WithIndex(&gwv1.HTTPRoute{}, shared.IndexHTTPRouteByService, shared.IndexHTTPRouteByServiceFunc). + Build() + + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "ns"}} + h := ServiceToRouteParents(c) + q := &fakeQueue{} + h.Create(context.Background(), event.CreateEvent{Object: svc}, q) + if len(q.items) != 0 { + t.Errorf("expected 0 items (non-gateway parent skipped), got %d", len(q.items)) + } +} + +// ---- helpers ---------------------------------------------------------------- + +func assertRequests(t *testing.T, got []reconcile.Request, want []types.NamespacedName) { + t.Helper() + wantSet := make(map[types.NamespacedName]bool, len(want)) + for _, nn := range want { + wantSet[nn] = true + } + gotSet := make(map[types.NamespacedName]bool, len(got)) + for _, r := range got { + gotSet[r.NamespacedName] = true + } + for nn := range wantSet { + if !gotSet[nn] { + t.Errorf("expected enqueued request %v not found; got %v", nn, got) + } + } + for nn := range gotSet { + if !wantSet[nn] { + t.Errorf("unexpected enqueued request %v", nn) + } + } +} diff --git a/internal/controller/gateway/shared/eventhandlers/l4_route_to_gateway.go b/internal/controller/gateway/shared/eventhandlers/l4_route_to_gateway.go new file mode 100644 index 00000000..4c1dc8fd --- /dev/null +++ b/internal/controller/gateway/shared/eventhandlers/l4_route_to_gateway.go @@ -0,0 +1,164 @@ +package eventhandlers + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" +) + +const gatewayKind = "Gateway" + +// enqueueParentGateways adds a reconcile request for every parentRef of kind +// Gateway on the route. Shared by the TCP/UDP route handlers. +func enqueueParentGateways(routeNS string, refs []gwv1.ParentReference, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + for _, p := range refs { + if p.Kind != nil && *p.Kind != gatewayKind { + continue + } + ns := routeNS + if p.Namespace != nil { + ns = string(*p.Namespace) + } + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: string(p.Name)}}) + } +} + +// TCPRouteToGateway enqueues the parent Gateway(s) of a changed TCPRoute. +func TCPRouteToGateway() handler.EventHandler { + enq := func(_ context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + if r, ok := obj.(*gwv1a2.TCPRoute); ok { + enqueueParentGateways(r.Namespace, r.Spec.ParentRefs, q) + } + } + return objToRequests(enq) +} + +// UDPRouteToGateway enqueues the parent Gateway(s) of a changed UDPRoute. +func UDPRouteToGateway() handler.EventHandler { + enq := func(_ context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + if r, ok := obj.(*gwv1a2.UDPRoute); ok { + enqueueParentGateways(r.Namespace, r.Spec.ParentRefs, q) + } + } + return objToRequests(enq) +} + +// ServiceToL4RouteParents enqueues the parent Gateways of any TCPRoute/UDPRoute +// whose backendRefs name a changed Service (endpoint churn → re-resolve pool +// members). Mirrors ServiceToRouteParents for the L4 path. +func ServiceToL4RouteParents(c client.Client) handler.EventHandler { + enq := func(ctx context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + svc, ok := obj.(*corev1.Service) + if !ok { + return + } + key := svc.Namespace + "/" + svc.Name + + var tcp gwv1a2.TCPRouteList + if err := c.List(ctx, &tcp, + client.InNamespace(svc.Namespace), + client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(shared.IndexTCPRouteByService, key)}, + ); err == nil { + for i := range tcp.Items { + enqueueParentGateways(tcp.Items[i].Namespace, tcp.Items[i].Spec.ParentRefs, q) + } + } + + var udp gwv1a2.UDPRouteList + if err := c.List(ctx, &udp, + client.InNamespace(svc.Namespace), + client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(shared.IndexUDPRouteByService, key)}, + ); err == nil { + for i := range udp.Items { + enqueueParentGateways(udp.Items[i].Namespace, udp.Items[i].Spec.ParentRefs, q) + } + } + } + return objToRequests(enq) +} + +// VKSBackendPolicyToL4Gateway enqueues parent Gateways of any TCP/UDP route +// whose backendRefs name a Service the policy targets. L4 sibling of +// VKSBackendPolicyToGateway (which uses HTTPRoute indexes). +func VKSBackendPolicyToL4Gateway(c client.Client) handler.EventHandler { + return l4PolicyByServiceHandler(c, func(obj client.Object) (string, []string) { + p, ok := obj.(*vksv1.VKSBackendPolicy) + if !ok { + return "", nil + } + return p.Namespace, targetNames(p.Spec.TargetRefs) + }) +} + +// VKSHealthCheckPolicyToL4Gateway is the L4 sibling for health-check policies. +func VKSHealthCheckPolicyToL4Gateway(c client.Client) handler.EventHandler { + return l4PolicyByServiceHandler(c, func(obj client.Object) (string, []string) { + p, ok := obj.(*vksv1.VKSHealthCheckPolicy) + if !ok { + return "", nil + } + return p.Namespace, targetNames(p.Spec.TargetRefs) + }) +} + +func targetNames(refs []vksv1.LocalPolicyTargetReference) []string { + out := make([]string, 0, len(refs)) + for _, r := range refs { + out = append(out, string(r.Name)) + } + return out +} + +func l4PolicyByServiceHandler(c client.Client, extract func(client.Object) (string, []string)) handler.EventHandler { + enq := func(ctx context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + ns, names := extract(obj) + if ns == "" { + return + } + for _, n := range names { + key := ns + "/" + n + var tcp gwv1a2.TCPRouteList + if err := c.List(ctx, &tcp, client.InNamespace(ns), + client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(shared.IndexTCPRouteByService, key)}); err == nil { + for i := range tcp.Items { + enqueueParentGateways(tcp.Items[i].Namespace, tcp.Items[i].Spec.ParentRefs, q) + } + } + var udp gwv1a2.UDPRouteList + if err := c.List(ctx, &udp, client.InNamespace(ns), + client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(shared.IndexUDPRouteByService, key)}); err == nil { + for i := range udp.Items { + enqueueParentGateways(udp.Items[i].Namespace, udp.Items[i].Spec.ParentRefs, q) + } + } + } + } + return objToRequests(enq) +} + +// objToRequests wraps an enqueue function as a CRUD-symmetric EventHandler. +func objToRequests(enq func(context.Context, client.Object, workqueue.TypedRateLimitingInterface[reconcile.Request])) handler.EventHandler { + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.ObjectNew, q) + }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + } +} diff --git a/internal/controller/gateway/shared/eventhandlers/policy_to_gateway.go b/internal/controller/gateway/shared/eventhandlers/policy_to_gateway.go new file mode 100644 index 00000000..eabfebda --- /dev/null +++ b/internal/controller/gateway/shared/eventhandlers/policy_to_gateway.go @@ -0,0 +1,143 @@ +package eventhandlers + +import ( + "context" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" +) + +// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch + +// VKSGatewayPolicyToGateway enqueues the Gateways named in the policy's +// targetRefs. The Gateway use case re-resolves the policy's effective +// LB-level + listener-level fields on every reconcile, so a policy edit +// only needs to reach the Gateway controller's queue to take effect. +func VKSGatewayPolicyToGateway() handler.EventHandler { + enq := func(_ context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + p, ok := obj.(*vksv1.VKSGatewayPolicy) + if !ok { + return + } + for _, t := range p.Spec.TargetRefs { + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: p.Namespace, Name: string(t.Name)}}) + } + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.ObjectNew, q) + }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + } +} + +// VKSBackendPolicyToGateway and VKSHealthCheckPolicyToGateway both target +// Services. To enqueue affected Gateways: list HTTPRoutes whose backendRefs +// reference the Service, then enqueue the parent Gateway of each. Reuses the +// IndexHTTPRouteByService field index registered by shared.RegisterIndexes. +func VKSBackendPolicyToGateway(c client.Client) handler.EventHandler { + return policyByServiceTargetHandler[*vksv1.VKSBackendPolicy](c, func(obj client.Object) []vksv1.LocalPolicyTargetReference { + p := obj.(*vksv1.VKSBackendPolicy) + return p.Spec.TargetRefs + }, func(obj client.Object) string { + return obj.(*vksv1.VKSBackendPolicy).Namespace + }) +} + +func VKSHealthCheckPolicyToGateway(c client.Client) handler.EventHandler { + return policyByServiceTargetHandler[*vksv1.VKSHealthCheckPolicy](c, func(obj client.Object) []vksv1.LocalPolicyTargetReference { + p := obj.(*vksv1.VKSHealthCheckPolicy) + return p.Spec.TargetRefs + }, func(obj client.Object) string { + return obj.(*vksv1.VKSHealthCheckPolicy).Namespace + }) +} + +// VKSRoutePolicyToGateway targets HTTPRoutes — enqueue parent Gateways of +// each named route. +func VKSRoutePolicyToGateway(c client.Client) handler.EventHandler { + enq := func(ctx context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + p, ok := obj.(*vksv1.VKSRoutePolicy) + if !ok { + return + } + for _, t := range p.Spec.TargetRefs { + var route gwv1.HTTPRoute + if err := c.Get(ctx, types.NamespacedName{Namespace: p.Namespace, Name: string(t.Name)}, &route); err != nil { + continue + } + enqueueRouteParents(&route, q) + } + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.ObjectNew, q) + }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + } +} + +// policyByServiceTargetHandler builds a handler.EventHandler that, given a +// policy whose targetRefs name Services, lists HTTPRoutes referencing those +// Services and enqueues their parent Gateways. +func policyByServiceTargetHandler[P client.Object](c client.Client, refs func(client.Object) []vksv1.LocalPolicyTargetReference, ns func(client.Object) string) handler.EventHandler { + enq := func(ctx context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + policyNS := ns(obj) + for _, t := range refs(obj) { + var routes gwv1.HTTPRouteList + sel := fields.OneTermEqualSelector(shared.IndexHTTPRouteByService, policyNS+"/"+string(t.Name)) + if err := c.List(ctx, &routes, + client.InNamespace(policyNS), + client.MatchingFieldsSelector{Selector: sel}, + ); err != nil { + continue + } + for i := range routes.Items { + enqueueRouteParents(&routes.Items[i], q) + } + } + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.ObjectNew, q) + }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + } +} + +func enqueueRouteParents(r *gwv1.HTTPRoute, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + for _, p := range r.Spec.ParentRefs { + if p.Kind != nil && *p.Kind != gatewayKind { + continue + } + ns := r.Namespace + if p.Namespace != nil { + ns = string(*p.Namespace) + } + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: string(p.Name)}}) + } +} diff --git a/internal/controller/gateway/shared/eventhandlers/referencegrant_to_gateways.go b/internal/controller/gateway/shared/eventhandlers/referencegrant_to_gateways.go new file mode 100644 index 00000000..56bc5dfa --- /dev/null +++ b/internal/controller/gateway/shared/eventhandlers/referencegrant_to_gateways.go @@ -0,0 +1,45 @@ +package eventhandlers + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// ReferenceGrantToGateways enqueues every Gateway when a ReferenceGrant +// changes. A grant in the backend's namespace can newly permit (or revoke) a +// cross-namespace backendRef, so the owning Gateway must re-resolve its pools. +// Mapping a grant to exactly the affected Gateways would need a cross-namespace +// reverse index; enqueuing all Gateways is correct and bounded (non-ALB +// Gateways no-op in the use case). +func ReferenceGrantToGateways(c client.Client) handler.EventHandler { + enq := func(ctx context.Context, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + var gws gwv1.GatewayList + if err := c.List(ctx, &gws); err != nil { + return + } + for i := range gws.Items { + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{ + Namespace: gws.Items[i].Namespace, Name: gws.Items[i].Name, + }}) + } + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, _ event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, q) + }, + UpdateFunc: func(ctx context.Context, _ event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, q) + }, + DeleteFunc: func(ctx context.Context, _ event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, q) + }, + } +} diff --git a/internal/controller/gateway/shared/eventhandlers/route_to_gateway.go b/internal/controller/gateway/shared/eventhandlers/route_to_gateway.go new file mode 100644 index 00000000..9de8f965 --- /dev/null +++ b/internal/controller/gateway/shared/eventhandlers/route_to_gateway.go @@ -0,0 +1,44 @@ +package eventhandlers + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func RouteToGateway() handler.EventHandler { + enq := func(_ context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + r, ok := obj.(*gwv1.HTTPRoute) + if !ok { + return + } + for _, p := range r.Spec.ParentRefs { + if p.Kind != nil && *p.Kind != "Gateway" { + continue + } + ns := r.Namespace + if p.Namespace != nil { + ns = string(*p.Namespace) + } + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: string(p.Name)}}) + } + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.ObjectNew, q) + }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + } +} diff --git a/internal/controller/gateway/shared/eventhandlers/service_to_route.go b/internal/controller/gateway/shared/eventhandlers/service_to_route.go new file mode 100644 index 00000000..de78a0f0 --- /dev/null +++ b/internal/controller/gateway/shared/eventhandlers/service_to_route.go @@ -0,0 +1,57 @@ +package eventhandlers + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" +) + +func ServiceToRouteParents(c client.Client) handler.EventHandler { + enq := func(ctx context.Context, obj client.Object, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + svc, ok := obj.(*corev1.Service) + if !ok { + return + } + var routes gwv1.HTTPRouteList + if err := c.List(ctx, &routes, + client.InNamespace(svc.Namespace), + client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(shared.IndexHTTPRouteByService, svc.Namespace+"/"+svc.Name)}, + ); err != nil { + return + } + for _, r := range routes.Items { + for _, p := range r.Spec.ParentRefs { + if p.Kind != nil && *p.Kind != "Gateway" { + continue + } + ns := r.Namespace + if p.Namespace != nil { + ns = string(*p.Namespace) + } + q.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: string(p.Name)}}) + } + } + } + return handler.Funcs{ + CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.ObjectNew, q) + }, + DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { + enq(ctx, e.Object, q) + }, + } +} diff --git a/internal/controller/gateway/shared/policy_order.go b/internal/controller/gateway/shared/policy_order.go new file mode 100644 index 00000000..661e8d9a --- /dev/null +++ b/internal/controller/gateway/shared/policy_order.go @@ -0,0 +1,71 @@ +package shared + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +type RankedMatch struct { + Match gwv1.HTTPRouteMatch + RouteCreated metav1.Time + RouteNamespace string + RouteName string +} + +// ByMatchSpecificity orders RankedMatches per the Gateway-API spec's match +// precedence: Exact > Regex > Prefix paths, longer paths beat shorter, +// more headers beat fewer, more query params beat fewer, older route +// creation timestamp wins, then alphabetical "{namespace}/{name}" — the +// final tiebreaker the Gateway-API conformance spec requires when two +// routes were applied in the same metav1.Time-second window. Without it, +// stable-sort falls through to input order, which depends on the cache's +// non-deterministic map iteration and produces a Position-flip reconcile +// loop. +type ByMatchSpecificity []RankedMatch + +func (s ByMatchSpecificity) Len() int { return len(s) } +func (s ByMatchSpecificity) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s ByMatchSpecificity) Less(i, j int) bool { + a, b := s[i], s[j] + if r := pathRank(a.Match.Path) - pathRank(b.Match.Path); r != 0 { + return r < 0 + } + if pl, pr := pathLen(a.Match.Path), pathLen(b.Match.Path); pl != pr { + return pl > pr + } + if hi, hj := len(a.Match.Headers), len(b.Match.Headers); hi != hj { + return hi > hj + } + if qi, qj := len(a.Match.QueryParams), len(b.Match.QueryParams); qi != qj { + return qi > qj + } + if !a.RouteCreated.Equal(&b.RouteCreated) { + return a.RouteCreated.Before(&b.RouteCreated) + } + if a.RouteNamespace != b.RouteNamespace { + return a.RouteNamespace < b.RouteNamespace + } + return a.RouteName < b.RouteName +} + +func pathRank(p *gwv1.HTTPPathMatch) int { + if p == nil || p.Type == nil { + return 99 + } + switch *p.Type { + case gwv1.PathMatchExact: + return 0 + case gwv1.PathMatchRegularExpression: + return 1 + case gwv1.PathMatchPathPrefix: + return 2 + } + return 99 +} + +func pathLen(p *gwv1.HTTPPathMatch) int { + if p == nil || p.Value == nil { + return 0 + } + return len(*p.Value) +} diff --git a/internal/controller/gateway/shared/policy_order_test.go b/internal/controller/gateway/shared/policy_order_test.go new file mode 100644 index 00000000..e50b9710 --- /dev/null +++ b/internal/controller/gateway/shared/policy_order_test.go @@ -0,0 +1,38 @@ +package shared + +import ( + "sort" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestSortMatches_ExactBeforePrefix(t *testing.T) { + pathExact := gwv1.PathMatchExact + pathPrefix := gwv1.PathMatchPathPrefix + items := []RankedMatch{ + {Match: gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: &pathPrefix, Value: ptr("/api")}}}, + {Match: gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Type: &pathExact, Value: ptr("/api/v1")}}}, + } + sort.Stable(ByMatchSpecificity(items)) + if items[0].Match.Path.Type == nil || *items[0].Match.Path.Type != pathExact { + t.Fatalf("expected exact first; got %#v", items[0]) + } +} + +func TestSortMatches_TimestampTiebreak(t *testing.T) { + older := metav1.NewTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + newer := metav1.NewTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + items := []RankedMatch{ + {RouteCreated: newer}, + {RouteCreated: older}, + } + sort.Stable(ByMatchSpecificity(items)) + if !items[0].RouteCreated.Equal(&older) { + t.Fatalf("expected older first") + } +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/controller/gateway/shared/reference_indexer.go b/internal/controller/gateway/shared/reference_indexer.go new file mode 100644 index 00000000..65ef1fa3 --- /dev/null +++ b/internal/controller/gateway/shared/reference_indexer.go @@ -0,0 +1,192 @@ +package shared + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +const ( + IndexHTTPRouteByService = "spec.rules.backendRefs.name" + IndexHTTPRouteByParentGateway = "spec.parentRefs.gateway" + IndexVKSGatewayPolicyByGateway = "spec.targetRefs.gateway" + IndexVKSBackendPolicyByService = "spec.targetRefs.service" + IndexVKSHealthCheckPolicyByService = "spec.targetRefs.service" + IndexVKSRoutePolicyByRoute = "spec.targetRefs.httproute" + // L4 route → parent Gateway (Phase 2 NLB). + IndexTCPRouteByParentGateway = "spec.parentRefs.gateway.tcp" + IndexUDPRouteByParentGateway = "spec.parentRefs.gateway.udp" + IndexTCPRouteByService = "spec.rules.backendRefs.name.tcp" + IndexUDPRouteByService = "spec.rules.backendRefs.name.udp" +) + +func RegisterIndexes(ctx context.Context, mgr manager.Manager) error { + if err := mgr.GetFieldIndexer().IndexField(ctx, &gwv1.HTTPRoute{}, IndexHTTPRouteByService, IndexHTTPRouteByServiceFunc); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &gwv1.HTTPRoute{}, IndexHTTPRouteByParentGateway, IndexHTTPRouteByParentFunc); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &vksv1.VKSGatewayPolicy{}, IndexVKSGatewayPolicyByGateway, IndexVKSGatewayPolicyByGatewayFunc); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &vksv1.VKSBackendPolicy{}, IndexVKSBackendPolicyByService, IndexVKSBackendPolicyByServiceFunc); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &vksv1.VKSHealthCheckPolicy{}, IndexVKSHealthCheckPolicyByService, IndexVKSHealthCheckPolicyByServiceFunc); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &vksv1.VKSRoutePolicy{}, IndexVKSRoutePolicyByRoute, IndexVKSRoutePolicyByRouteFunc); err != nil { + return err + } + return nil +} + +// RegisterL4Indexes registers the field indexes the NLB Gateway reconciler +// needs (TCPRoute/UDPRoute → parent Gateway and → backend Service). Kept +// separate from RegisterIndexes so the ALB path doesn't require the +// experimental L4 route CRDs to be installed: this is only called when the NLB +// controller is enabled. +func RegisterL4Indexes(ctx context.Context, mgr manager.Manager) error { + if err := mgr.GetFieldIndexer().IndexField(ctx, &gwv1a2.TCPRoute{}, IndexTCPRouteByParentGateway, IndexTCPRouteByParentFunc); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &gwv1a2.UDPRoute{}, IndexUDPRouteByParentGateway, IndexUDPRouteByParentFunc); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &gwv1a2.TCPRoute{}, IndexTCPRouteByService, IndexTCPRouteByServiceFunc); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &gwv1a2.UDPRoute{}, IndexUDPRouteByService, IndexUDPRouteByServiceFunc); err != nil { + return err + } + return nil +} + +func IndexTCPRouteByParentFunc(obj client.Object) []string { + r := obj.(*gwv1a2.TCPRoute) + return parentGatewayKeys(r.Namespace, r.Spec.ParentRefs) +} + +func IndexUDPRouteByParentFunc(obj client.Object) []string { + r := obj.(*gwv1a2.UDPRoute) + return parentGatewayKeys(r.Namespace, r.Spec.ParentRefs) +} + +func IndexTCPRouteByServiceFunc(obj client.Object) []string { + r := obj.(*gwv1a2.TCPRoute) + var keys []string + for _, rule := range r.Spec.Rules { + keys = append(keys, backendServiceKeys(r.Namespace, rule.BackendRefs)...) + } + return keys +} + +func IndexUDPRouteByServiceFunc(obj client.Object) []string { + r := obj.(*gwv1a2.UDPRoute) + var keys []string + for _, rule := range r.Spec.Rules { + keys = append(keys, backendServiceKeys(r.Namespace, rule.BackendRefs)...) + } + return keys +} + +// parentGatewayKeys returns "/" for every parentRef of kind Gateway. +func parentGatewayKeys(routeNS string, refs []gwv1.ParentReference) []string { + var keys []string + for _, p := range refs { + ns := routeNS + if p.Namespace != nil { + ns = string(*p.Namespace) + } + if p.Kind == nil || *p.Kind == "Gateway" { + keys = append(keys, ns+"/"+string(p.Name)) + } + } + return keys +} + +// backendServiceKeys returns "/" for every backendRef. +func backendServiceKeys(routeNS string, refs []gwv1.BackendRef) []string { + keys := make([]string, len(refs)) + for i, br := range refs { + ns := routeNS + if br.Namespace != nil { + ns = string(*br.Namespace) + } + keys[i] = ns + "/" + string(br.Name) + } + return keys +} + +func IndexHTTPRouteByServiceFunc(obj client.Object) []string { + r := obj.(*gwv1.HTTPRoute) + keys := make([]string, 0, len(r.Spec.Rules)) + for _, rule := range r.Spec.Rules { + for _, br := range rule.BackendRefs { + ns := r.Namespace + if br.Namespace != nil { + ns = string(*br.Namespace) + } + keys = append(keys, ns+"/"+string(br.Name)) + } + } + return keys +} + +func IndexHTTPRouteByParentFunc(obj client.Object) []string { + r := obj.(*gwv1.HTTPRoute) + var keys []string + for _, p := range r.Spec.ParentRefs { + ns := r.Namespace + if p.Namespace != nil { + ns = string(*p.Namespace) + } + if p.Kind == nil || *p.Kind == "Gateway" { + keys = append(keys, ns+"/"+string(p.Name)) + } + } + return keys +} + +func IndexVKSGatewayPolicyByGatewayFunc(obj client.Object) []string { + p := obj.(*vksv1.VKSGatewayPolicy) + keys := make([]string, len(p.Spec.TargetRefs)) + for i, t := range p.Spec.TargetRefs { + keys[i] = p.Namespace + "/" + string(t.Name) + } + return keys +} + +func IndexVKSBackendPolicyByServiceFunc(obj client.Object) []string { + p := obj.(*vksv1.VKSBackendPolicy) + keys := make([]string, len(p.Spec.TargetRefs)) + for i, t := range p.Spec.TargetRefs { + keys[i] = p.Namespace + "/" + string(t.Name) + } + return keys +} + +func IndexVKSHealthCheckPolicyByServiceFunc(obj client.Object) []string { + p := obj.(*vksv1.VKSHealthCheckPolicy) + keys := make([]string, len(p.Spec.TargetRefs)) + for i, t := range p.Spec.TargetRefs { + keys[i] = p.Namespace + "/" + string(t.Name) + } + return keys +} + +func IndexVKSRoutePolicyByRouteFunc(obj client.Object) []string { + p := obj.(*vksv1.VKSRoutePolicy) + keys := make([]string, len(p.Spec.TargetRefs)) + for i, t := range p.Spec.TargetRefs { + keys[i] = p.Namespace + "/" + string(t.Name) + } + return keys +} diff --git a/internal/controller/gateway/shared/shared_test.go b/internal/controller/gateway/shared/shared_test.go new file mode 100644 index 00000000..11cebfa8 --- /dev/null +++ b/internal/controller/gateway/shared/shared_test.go @@ -0,0 +1,313 @@ +package shared + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + vksv1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +// ---- helpers ---------------------------------------------------------------- + +func nsPtr(s string) *gwv1alpha2.Namespace { v := gwv1alpha2.Namespace(s); return &v } +func kindPtr(s string) *gwv1alpha2.Kind { v := gwv1alpha2.Kind(s); return &v } + +// ---- IndexHTTPRouteByServiceFunc -------------------------------------------- + +func TestIndexHTTPRouteByServiceFunc_Basic(t *testing.T) { + ns := gwv1.Namespace("other-ns") + r := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "route-ns"}, + Spec: gwv1.HTTPRouteSpec{ + Rules: []gwv1.HTTPRouteRule{ + { + BackendRefs: []gwv1.HTTPBackendRef{ + {BackendRef: gwv1.BackendRef{BackendObjectReference: gwv1.BackendObjectReference{Name: "svc-a"}}}, + {BackendRef: gwv1.BackendRef{BackendObjectReference: gwv1.BackendObjectReference{Name: "svc-b", Namespace: &ns}}}, + }, + }, + }, + }, + } + keys := IndexHTTPRouteByServiceFunc(r) + want := map[string]bool{"route-ns/svc-a": true, "other-ns/svc-b": true} + if len(keys) != 2 { + t.Fatalf("expected 2 keys, got %d: %v", len(keys), keys) + } + for _, k := range keys { + if !want[k] { + t.Errorf("unexpected key %q", k) + } + } +} + +func TestIndexHTTPRouteByServiceFunc_NoRules(t *testing.T) { + r := &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}} + if keys := IndexHTTPRouteByServiceFunc(r); len(keys) != 0 { + t.Errorf("expected no keys, got %v", keys) + } +} + +// ---- IndexHTTPRouteByParentFunc --------------------------------------------- + +func TestIndexHTTPRouteByParentFunc_GatewayKind(t *testing.T) { + ns := gwv1.Namespace("gw-ns") + r := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "route-ns"}, + } + r.Spec.ParentRefs = []gwv1.ParentReference{ + // nil Kind → defaults to Gateway + {Name: "gw-a"}, + // explicit Gateway kind with cross-namespace + {Kind: kindPtr("Gateway"), Name: "gw-b", Namespace: &ns}, + // non-Gateway kind should be excluded + {Kind: kindPtr("Service"), Name: "svc-x"}, + } + keys := IndexHTTPRouteByParentFunc(r) + want := map[string]bool{"route-ns/gw-a": true, "gw-ns/gw-b": true} + if len(keys) != 2 { + t.Fatalf("expected 2 keys, got %d: %v", len(keys), keys) + } + for _, k := range keys { + if !want[k] { + t.Errorf("unexpected key %q", k) + } + } +} + +// ---- IndexVKSGatewayPolicyByGatewayFunc ------------------------------------- + +func TestIndexVKSGatewayPolicyByGatewayFunc(t *testing.T) { + p := &vksv1.VKSGatewayPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, + Spec: vksv1.VKSGatewayPolicySpec{ + TargetRefs: []vksv1.LocalPolicyTargetReferenceWithSectionName{ + {LocalPolicyTargetReference: vksv1.LocalPolicyTargetReference{Name: "gw-1"}}, + {LocalPolicyTargetReference: vksv1.LocalPolicyTargetReference{Name: "gw-2"}}, + }, + }, + } + keys := IndexVKSGatewayPolicyByGatewayFunc(p) + want := map[string]bool{"ns/gw-1": true, "ns/gw-2": true} + if len(keys) != 2 { + t.Fatalf("expected 2 keys, got %d: %v", len(keys), keys) + } + for _, k := range keys { + if !want[k] { + t.Errorf("unexpected key %q", k) + } + } +} + +// ---- IndexVKSBackendPolicyByServiceFunc ------------------------------------- + +func TestIndexVKSBackendPolicyByServiceFunc(t *testing.T) { + p := &vksv1.VKSBackendPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, + Spec: vksv1.VKSBackendPolicySpec{ + TargetRefs: []vksv1.LocalPolicyTargetReference{ + {Name: "svc-a"}, + }, + }, + } + keys := IndexVKSBackendPolicyByServiceFunc(p) + if len(keys) != 1 || keys[0] != "ns/svc-a" { + t.Errorf("unexpected keys %v", keys) + } +} + +func TestIndexVKSBackendPolicyByServiceFunc_Empty(t *testing.T) { + p := &vksv1.VKSBackendPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, + Spec: vksv1.VKSBackendPolicySpec{}, + } + if keys := IndexVKSBackendPolicyByServiceFunc(p); len(keys) != 0 { + t.Errorf("expected no keys, got %v", keys) + } +} + +// ---- IndexVKSHealthCheckPolicyByServiceFunc --------------------------------- + +func TestIndexVKSHealthCheckPolicyByServiceFunc(t *testing.T) { + p := &vksv1.VKSHealthCheckPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, + Spec: vksv1.VKSHealthCheckPolicySpec{ + Protocol: "HTTP", + TargetRefs: []vksv1.LocalPolicyTargetReference{ + {Name: "svc-a"}, + {Name: "svc-b"}, + }, + }, + } + keys := IndexVKSHealthCheckPolicyByServiceFunc(p) + want := map[string]bool{"ns/svc-a": true, "ns/svc-b": true} + if len(keys) != 2 { + t.Fatalf("expected 2 keys, got %d: %v", len(keys), keys) + } + for _, k := range keys { + if !want[k] { + t.Errorf("unexpected key %q", k) + } + } +} + +// ---- IndexVKSRoutePolicyByRouteFunc ----------------------------------------- + +func TestIndexVKSRoutePolicyByRouteFunc(t *testing.T) { + p := &vksv1.VKSRoutePolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, + Spec: vksv1.VKSRoutePolicySpec{ + TargetRefs: []vksv1.LocalPolicyTargetReferenceWithSectionName{ + {LocalPolicyTargetReference: vksv1.LocalPolicyTargetReference{Name: "route-1"}}, + }, + }, + } + keys := IndexVKSRoutePolicyByRouteFunc(p) + if len(keys) != 1 || keys[0] != "ns/route-1" { + t.Errorf("unexpected keys %v", keys) + } +} + +// ---- SetCondition ----------------------------------------------------------- + +func TestSetCondition_AppendNew(t *testing.T) { + conds := &[]metav1.Condition{} + c := metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "OK", Message: "all good"} + SetCondition(conds, c) + if len(*conds) != 1 { + t.Fatalf("expected 1 condition, got %d", len(*conds)) + } + if (*conds)[0].Type != "Ready" { + t.Errorf("wrong type: %s", (*conds)[0].Type) + } +} + +func TestSetCondition_UpdateExisting(t *testing.T) { + old := metav1.Condition{Type: "Ready", Status: metav1.ConditionFalse, Reason: "NotOK", Message: "broken"} + conds := &[]metav1.Condition{old} + updated := metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "OK", Message: "fixed"} + SetCondition(conds, updated) + if len(*conds) != 1 { + t.Fatalf("expected 1 condition after update, got %d", len(*conds)) + } + if (*conds)[0].Status != metav1.ConditionTrue { + t.Errorf("expected True, got %v", (*conds)[0].Status) + } +} + +func TestSetCondition_NoopWhenUnchanged(t *testing.T) { + c := metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "OK", Message: "all good"} + conds := &[]metav1.Condition{c} + SetCondition(conds, c) + if len(*conds) != 1 { + t.Fatalf("expected 1 condition, got %d", len(*conds)) + } +} + +func TestSetCondition_MultipleTypes(t *testing.T) { + conds := &[]metav1.Condition{} + SetCondition(conds, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "OK", Message: "ok"}) + SetCondition(conds, metav1.Condition{Type: "Accepted", Status: metav1.ConditionFalse, Reason: "Denied", Message: "nope"}) + if len(*conds) != 2 { + t.Fatalf("expected 2 conditions, got %d", len(*conds)) + } +} + +// ---- EnsureAncestor --------------------------------------------------------- + +func refWithName(name gwv1alpha2.ObjectName) gwv1alpha2.ParentReference { + return gwv1alpha2.ParentReference{Name: name} +} + +func TestEnsureAncestor_AppendNew(t *testing.T) { + ancestors := &[]gwv1alpha2.PolicyAncestorStatus{} + ref := refWithName("gw-a") + conds := []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue, Reason: "OK", Message: "ok"}} + EnsureAncestor(ancestors, ref, "test-controller", conds) + if len(*ancestors) != 1 { + t.Fatalf("expected 1 ancestor, got %d", len(*ancestors)) + } + if string((*ancestors)[0].ControllerName) != "test-controller" { + t.Errorf("wrong controller name: %s", (*ancestors)[0].ControllerName) + } +} + +func TestEnsureAncestor_UpdateExisting(t *testing.T) { + ref := refWithName("gw-a") + old := gwv1alpha2.PolicyAncestorStatus{ + AncestorRef: ref, + ControllerName: "test-controller", + Conditions: []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionFalse}}, + } + ancestors := &[]gwv1alpha2.PolicyAncestorStatus{old} + newConds := []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue, Reason: "OK", Message: "ok"}} + EnsureAncestor(ancestors, ref, "test-controller", newConds) + if len(*ancestors) != 1 { + t.Fatalf("expected 1 ancestor after update, got %d", len(*ancestors)) + } + if (*ancestors)[0].Conditions[0].Status != metav1.ConditionTrue { + t.Errorf("expected updated status True") + } +} + +func TestEnsureAncestor_DifferentControllers(t *testing.T) { + ref := refWithName("gw-a") + a1 := gwv1alpha2.PolicyAncestorStatus{AncestorRef: ref, ControllerName: "ctrl-1"} + ancestors := &[]gwv1alpha2.PolicyAncestorStatus{a1} + EnsureAncestor(ancestors, ref, "ctrl-2", nil) + if len(*ancestors) != 2 { + t.Fatalf("expected 2 ancestors for different controllers, got %d", len(*ancestors)) + } +} + +func TestEnsureAncestor_DifferentRefs(t *testing.T) { + ref1 := refWithName("gw-a") + ref2 := refWithName("gw-b") + a1 := gwv1alpha2.PolicyAncestorStatus{AncestorRef: ref1, ControllerName: "ctrl"} + ancestors := &[]gwv1alpha2.PolicyAncestorStatus{a1} + EnsureAncestor(ancestors, ref2, "ctrl", nil) + if len(*ancestors) != 2 { + t.Fatalf("expected 2 ancestors for different refs, got %d", len(*ancestors)) + } +} + +// ---- parentRefEqual --------------------------------------------------------- + +func TestParentRefEqual_AllNilFields(t *testing.T) { + a := gwv1alpha2.ParentReference{Name: "gw"} + b := gwv1alpha2.ParentReference{Name: "gw"} + if !parentRefEqual(a, b) { + t.Fatal("identical refs should be equal") + } +} + +func TestParentRefEqual_ExplicitNS(t *testing.T) { + a := gwv1alpha2.ParentReference{Name: "gw", Namespace: nsPtr("ns-a")} + b := gwv1alpha2.ParentReference{Name: "gw", Namespace: nsPtr("ns-b")} + if parentRefEqual(a, b) { + t.Fatal("different namespace should not be equal") + } +} + +func TestParentRefEqual_ExplicitSection(t *testing.T) { + sec := gwv1alpha2.SectionName("listener-a") + a := gwv1alpha2.ParentReference{Name: "gw", SectionName: &sec} + b := gwv1alpha2.ParentReference{Name: "gw"} + if parentRefEqual(a, b) { + t.Fatal("one with section vs none should not be equal") + } +} + +func TestParentRefEqual_ExplicitGroupAndKind(t *testing.T) { + grp := gwv1alpha2.Group("gateway.networking.k8s.io") + knd := gwv1alpha2.Kind("Gateway") + a := gwv1alpha2.ParentReference{Name: "gw", Group: &grp, Kind: &knd} + b := gwv1alpha2.ParentReference{Name: "gw"} + // b has nil group/kind → derefGroup returns "" which differs from the explicit value + if parentRefEqual(a, b) { + t.Fatal("explicit group/kind vs nil should not be equal") + } +} diff --git a/internal/controller/gateway/shared/status.go b/internal/controller/gateway/shared/status.go new file mode 100644 index 00000000..ea540fb0 --- /dev/null +++ b/internal/controller/gateway/shared/status.go @@ -0,0 +1,71 @@ +package shared + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +func SetCondition(conds *[]metav1.Condition, c metav1.Condition) { + for i, existing := range *conds { + if existing.Type == c.Type { + if existing.Status == c.Status && + existing.Reason == c.Reason && + existing.Message == c.Message { + return + } + (*conds)[i] = c + return + } + } + *conds = append(*conds, c) +} + +func EnsureAncestor(ancestors *[]gwv1alpha2.PolicyAncestorStatus, ref gwv1alpha2.ParentReference, controllerName string, conditions []metav1.Condition) { + for i, a := range *ancestors { + if string(a.ControllerName) == controllerName && parentRefEqual(a.AncestorRef, ref) { + (*ancestors)[i].Conditions = conditions + return + } + } + *ancestors = append(*ancestors, gwv1alpha2.PolicyAncestorStatus{ + AncestorRef: ref, + ControllerName: gwv1alpha2.GatewayController(controllerName), + Conditions: conditions, + }) +} + +func parentRefEqual(a, b gwv1alpha2.ParentReference) bool { + return derefGroup(a.Group) == derefGroup(b.Group) && + derefKind(a.Kind) == derefKind(b.Kind) && + a.Name == b.Name && + derefNS(a.Namespace) == derefNS(b.Namespace) && + derefSection(a.SectionName) == derefSection(b.SectionName) +} + +func derefGroup(p *gwv1alpha2.Group) string { + if p == nil { + return "" + } + return string(*p) +} + +func derefKind(p *gwv1alpha2.Kind) string { + if p == nil { + return "" + } + return string(*p) +} + +func derefNS(p *gwv1alpha2.Namespace) string { + if p == nil { + return "" + } + return string(*p) +} + +func derefSection(p *gwv1alpha2.SectionName) string { + if p == nil { + return "" + } + return string(*p) +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 357b99ad..986ae92f 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -20,6 +20,24 @@ const ( ServiceGLBFinalizer = "glb.vks.vngcloud.vn/resources" ) +// Gateway API constants. +const ( + GatewayFinalizer = "gateway.vks.vngcloud.vn/resources" + HTTPRouteFinalizer = "gateway.vks.vngcloud.vn/route" + + OwnerKindGateway = "Gateway" + + // VKSResourceNamePrefix is the prefix every cloud-side resource the + // VKS controllers create must start with — load balancers, listeners, + // pools, policies, and member names. Matches the existing Ingress-style + // vks_default_pool / vks_http_listener convention. The vngcloud LB API + // limits names to [a-zA-Z0-9_.-]{5,50}. + VKSResourceNamePrefix = "vks_" + + OwnerLabelGatewayUID = "gateway.vks.vngcloud.vn/owner-uid" + OwnerLabelKind = "vks.vngcloud.vn/owner-resource-kind" +) + // Annotations const ( SERVICE_ANNOTATION_PREFIX = "vks.vngcloud.vn" diff --git a/internal/repository/vngcloud_repo/vngcloud_mocks/vngcloud_mock.go b/internal/repository/vngcloud_repo/vngcloud_mocks/vngcloud_mock.go index 57fb4e55..05ad40a2 100644 --- a/internal/repository/vngcloud_repo/vngcloud_mocks/vngcloud_mock.go +++ b/internal/repository/vngcloud_repo/vngcloud_mocks/vngcloud_mock.go @@ -26,6 +26,62 @@ import ( "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/errs" ) +// validateVngcloudName mirrors the real vngcloud API validation observed on a +// live cluster: identifier-style fields (listener name, pool name, member name, +// etc.) must be 5–50 chars and contain only [a-zA-Z0-9_.-]. The matching real +// error string is reproduced here so use-case unit tests catch regressions +// before they hit a real load balancer warm-up cycle. +// +// kind is just for the error message ("listenerName", "members[i].name", ...). +// validateVngcloudName mirrors the real vngcloud API validation. The error +// strings end with a period because that's how the upstream service returns +// them, and unit tests substring-match the verbatim message. +// +//nolint:staticcheck // ST1005: errors must mirror upstream wording exactly +func validateVngcloudName(kind, value string) error { + const minLen = 5 + const maxLen = 50 + const msg = "%s: Only letters (a-z, A-Z, 0-9, '_', '-', '.') are allowed and must be between 5 and 50 characters." + if len(value) < minLen || len(value) > maxLen { + return fmt.Errorf(msg, kind) + } + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '_', r == '-', r == '.': + continue + default: + return fmt.Errorf(msg, kind) + } + } + return nil +} + +// validateHealthCheckProtocolFields enforces vngcloud's cross-field rule: +// when healthCheckProtocol is TCP or PING-UDP, none of the HTTP-specific fields +// (healthCheckPath, healthCheckMethod, successCode, domainName, httpVersion) +// may be set. Discovered live by an UpdatePool roundtrip after a TGC swap. +func validateHealthCheckProtocolFields(hm *loadbalancerv2.HealthMonitor) error { + if hm == nil { + return nil + } + proto := string(hm.HealthCheckProtocol) + if proto != "TCP" && proto != "PING-UDP" { + return nil + } + httpOnlySet := (hm.HealthCheckPath != nil && *hm.HealthCheckPath != "") || + (hm.HealthCheckMethod != nil && *hm.HealthCheckMethod != "") || + (hm.SuccessCode != nil && *hm.SuccessCode != "") || + (hm.DomainName != nil && *hm.DomainName != "") || + (hm.HttpVersion != nil && *hm.HttpVersion != "") + if httpOnlySet { + return errors.New("If healthCheckProtocol field is TCP or PING-UDP, following fields cannot be specified: healthCheckPath, healthCheckMethod, successCode, domainName, httpVersion") + } + return nil +} + func randID() string { number := randRange(1000000, 3999999) return fmt.Sprint(number) @@ -921,6 +977,10 @@ func (m *MockProvider) CreateListener(ctx context.Context, lbID string, opt load logger := contexts.NewContext(ctx).Log() logger.Infof("%s Request create listener of load balancer %s", domain.RequestIcon, lbID) listener := opt.ToRequestBody().(*loadbalancerv2.CreateListenerRequest) + if err := validateVngcloudName("listenerName", listener.ListenerName); err != nil { + logger.Errorf("[ERROR] - CreateListener: %v", err) + return nil, err + } newListener := &wrapListener{ lbID: lbID, Listener: &entityv2.Listener{ @@ -1316,6 +1376,29 @@ func (m *MockProvider) CreatePool(ctx context.Context, lbID string, opt loadbala member []*loadbalancerv2.Member ) pool = opt.ToRequestBody().(*loadbalancerv2.CreatePoolRequest) + if err := validateVngcloudName("poolName", pool.PoolName); err != nil { + logger.Errorf("[ERROR] - CreatePool: %v", err) + return nil, err + } + for i, mem := range pool.Members { + mr := mem.ToRequestBody().(*loadbalancerv2.Member) + if err := validateVngcloudName(fmt.Sprintf("members[%d].name", i), mr.Name); err != nil { + logger.Errorf("[ERROR] - CreatePool: %v", err) + return nil, err + } + if mr.MonitorPort == 0 { + err := fmt.Errorf("members[%d].monitorPort: Required value", i) + logger.Errorf("[ERROR] - CreatePool: %v", err) + return nil, err + } + } + if pool.HealthMonitor != nil { + hm := pool.HealthMonitor.ToRequestBody().(*loadbalancerv2.HealthMonitor) + if err := validateHealthCheckProtocolFields(hm); err != nil { + logger.Errorf("[ERROR] - CreatePool: %v", err) + return nil, err + } + } defaultPoolID := "pool-" + randID() lb, _ := m.GetLoadBalancerByID(ctx, lbID) @@ -1521,6 +1604,13 @@ func (m *MockProvider) UpdatePool(ctx context.Context, lbID, poolID string, opt logger := contexts.NewContext(ctx).Log() logger.Infof("%s Request update pool %s of load balancer %s", domain.RequestIcon, poolID, lbID) updateOpt := opt.ToRequestBody().(*loadbalancerv2.UpdatePoolRequest) + if updateOpt.HealthMonitor != nil { + hm := updateOpt.HealthMonitor.ToRequestBody().(*loadbalancerv2.HealthMonitor) + if err := validateHealthCheckProtocolFields(hm); err != nil { + logger.Errorf("[ERROR] - UpdatePool: %v", err) + return err + } + } var pool *wrapPool for _, p := range m.pools { if p.lbID == lbID && p.GetId() == poolID { diff --git a/internal/usecase/contracts.go b/internal/usecase/contracts.go index 7ed0a174..f4fa59c9 100644 --- a/internal/usecase/contracts.go +++ b/internal/usecase/contracts.go @@ -49,3 +49,24 @@ type ServiceGLBUseCase interface { EnsureServiceGLBUseCase(ctx context.Context, req ctrl.Request) error DeleteServiceGLBUseCase(ctx context.Context, req ctrl.Request) error } + +// ALBGatewayUseCase handles reconciliation of Gateway-API Gateway objects under +// the vngcloud-alb GatewayClass. It translates Gateway + attached HTTPRoutes + +// VKSPolicy CRDs into a LoadBalancerConfig and lets the LBC controller drive +// the actual cloud LB. +type ALBGatewayUseCase interface { + InitALBGatewayUseCase(ctx context.Context) error + EnsureALBGatewayUseCase(ctx context.Context, req ctrl.Request) error + DeleteALBGatewayUseCase(ctx context.Context, req ctrl.Request) error +} + +// NLBGatewayUseCase handles reconciliation of Gateway-API Gateway objects under +// the vngcloud-nlb GatewayClass. It translates a Gateway + attached +// TCPRoute/UDPRoute + VKSPolicy CRDs into a Layer-4 LoadBalancerConfig and lets +// the LBC controller drive the actual cloud NLB. Mirror of ALBGatewayUseCase +// for the L4 path (Phase 2). +type NLBGatewayUseCase interface { + InitNLBGatewayUseCase(ctx context.Context) error + EnsureNLBGatewayUseCase(ctx context.Context, req ctrl.Request) error + DeleteNLBGatewayUseCase(ctx context.Context, req ctrl.Request) error +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_certs.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_certs.go new file mode 100644 index 00000000..c215a504 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_certs.go @@ -0,0 +1,121 @@ +package alb_gateway_uc + +import ( + "sort" + + "k8s.io/utils/ptr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +// applyListenerCertificates fills CertificateDefault / CertificateAuthorities +// on an HTTPS listener. +// +// Precedence per §3.3 of the design spec: +// 1. Per-listener VKSGatewayPolicy.CertificateIDs — vngcloud cert IDs win +// (default = first, authorities = rest, order preserved). +// 2. Unscoped VKSGatewayPolicy.CertificateIDs — same shape. +// 3. Listener.TLS.CertificateRefs[] — Secret names; LBC controller imports +// them via CreateCertificates. Same-namespace only in Phase 1; cross-ns +// refs are skipped (status surfaces a warning in Phase F). +func (t *defaultGatewayBuildTask) applyListenerCertificates(entry *v1alpha1.Listener, l *gwv1.Listener) { + if l.Protocol != gwv1.HTTPSProtocolType || l.TLS == nil { + return + } + + if ids := t.resolveListenerCertIDs(l); len(ids) > 0 { + certs := make([]v1alpha1.ListenerCertificate, 0, len(ids)) + for i := range ids { + certs = append(certs, v1alpha1.ListenerCertificate{Id: &ids[i]}) + } + entry.CertificateDefault = &certs[0] + if len(certs) > 1 { + entry.CertificateAuthorities = certs[1:] + } + return + } + + secretCerts := make([]v1alpha1.ListenerCertificate, 0, len(l.TLS.CertificateRefs)) + for _, ref := range l.TLS.CertificateRefs { + if ref.Namespace != nil && string(*ref.Namespace) != t.gw.Namespace { + // Cross-namespace TLS Secret. The LBC ListenerCertificate type has + // no namespace field, so we can't pass it through to the import + // path. Phase F surfaces this on the listener Accepted condition. + t.logger.Warnf("listener %q references cross-namespace Secret %s/%s; "+ + "skipping — LBC.ListenerCertificate has no namespace field", + l.Name, *ref.Namespace, ref.Name) + continue + } + if string(ref.Name) == "" { + continue + } + secretCerts = append(secretCerts, v1alpha1.ListenerCertificate{ + SecretName: ptr.To(string(ref.Name)), + }) + } + if len(secretCerts) == 0 { + return + } + entry.CertificateDefault = &secretCerts[0] + if len(secretCerts) > 1 { + entry.CertificateAuthorities = secretCerts[1:] + } +} + +// resolveListenerCertIDs returns CertificateIDs from per-listener policy if +// set, else from the unscoped policy. Order is preserved (first = default). +func (t *defaultGatewayBuildTask) resolveListenerCertIDs(l *gwv1.Listener) []string { + if scoped, ok := t.listenerPolicies[string(l.Name)]; ok && scoped != nil { + if len(scoped.Spec.CertificateIDs) > 0 { + return scoped.Spec.CertificateIDs + } + } + if t.unscopedPolicy != nil && len(t.unscopedPolicy.Spec.CertificateIDs) > 0 { + return t.unscopedPolicy.Spec.CertificateIDs + } + return nil +} + +// buildCreateCertificates collects unique same-namespace Secret names across +// every HTTPS listener so the LBC controller imports each Secret once. Cert-ID +// listeners (where CertificateIDs is set) skip this entirely — those IDs are +// already vngcloud-side. +func (t *defaultGatewayBuildTask) buildCreateCertificates() []v1alpha1.CreateCertificate { + seen := make(map[string]struct{}) + for i := range t.gw.Spec.Listeners { + l := &t.gw.Spec.Listeners[i] + if l.Protocol != gwv1.HTTPSProtocolType || l.TLS == nil { + continue + } + if len(t.resolveListenerCertIDs(l)) > 0 { + continue // cert-ID path; nothing to import + } + for _, ref := range l.TLS.CertificateRefs { + if ref.Namespace != nil && string(*ref.Namespace) != t.gw.Namespace { + continue + } + if string(ref.Name) == "" { + continue + } + seen[string(ref.Name)] = struct{}{} + } + } + if len(seen) == 0 { + return nil + } + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + out := make([]v1alpha1.CreateCertificate, 0, len(names)) + for i := range names { + out = append(out, v1alpha1.CreateCertificate{SecretName: names[i]}) + } + return out +} + +// _ keeps the package import set stable when individual fields are unused. +var _ = gwv1alpha1.GroupVersion diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_certs_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_certs_test.go new file mode 100644 index 00000000..f21bc737 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_certs_test.go @@ -0,0 +1,266 @@ +package alb_gateway_uc + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +func makeListenerEntry(_ string) v1alpha1.Listener { + return v1alpha1.Listener{} +} + +func makeHTTPSListener(name string, secretRefs []gwv1.SecretObjectReference) gwv1.Listener { + return gwv1.Listener{ + Name: gwv1.SectionName(name), + Protocol: gwv1.HTTPSProtocolType, + Port: 443, + TLS: &gwv1.GatewayTLSConfig{ + CertificateRefs: secretRefs, + }, + } +} + +func makeSecretRef(ns, name string) gwv1.SecretObjectReference { + ref := gwv1.SecretObjectReference{Name: gwv1.ObjectName(name)} + if ns != "" { + n := gwv1.Namespace(ns) + ref.Namespace = &n + } + return ref +} + +func TestApplyListenerCertificates_NonHTTPS(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw", UID: types.UID("uid1")}, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + + // HTTP listener → should be no-op + l := &gwv1.Listener{Protocol: gwv1.HTTPProtocolType, TLS: nil} + entry := makeListenerEntry("http") + task.applyListenerCertificates(&entry, l) + assert.Nil(t, entry.CertificateDefault) + assert.Nil(t, entry.CertificateAuthorities) +} + +func TestApplyListenerCertificates_HTTPSNoTLS(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + + // HTTPS but TLS is nil → no-op + l := &gwv1.Listener{Protocol: gwv1.HTTPSProtocolType, TLS: nil} + entry := makeListenerEntry("https") + task.applyListenerCertificates(&entry, l) + assert.Nil(t, entry.CertificateDefault) +} + +func TestApplyListenerCertificates_CertIDs(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + + // Unscoped policy with cert IDs + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.CertificateIDs = []string{"cert-1", "cert-2", "cert-3"} + + l := &gwv1.Listener{ + Name: "https", + Protocol: gwv1.HTTPSProtocolType, + TLS: &gwv1.GatewayTLSConfig{}, + } + entry := makeListenerEntry("https") + task.applyListenerCertificates(&entry, l) + + assert.NotNil(t, entry.CertificateDefault) + assert.Equal(t, "cert-1", *entry.CertificateDefault.Id) + assert.Len(t, entry.CertificateAuthorities, 2) + assert.Equal(t, "cert-2", *entry.CertificateAuthorities[0].Id) + assert.Equal(t, "cert-3", *entry.CertificateAuthorities[1].Id) +} + +func TestApplyListenerCertificates_ScopedCertIDsWin(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + + // Per-listener policy wins over unscoped + scoped := &gwv1alpha1.VKSGatewayPolicy{} + scoped.Spec.CertificateIDs = []string{"scoped-cert"} + task.listenerPolicies["https"] = scoped + + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.CertificateIDs = []string{"unscoped-cert"} + + l := &gwv1.Listener{ + Name: "https", + Protocol: gwv1.HTTPSProtocolType, + TLS: &gwv1.GatewayTLSConfig{}, + } + entry := makeListenerEntry("https") + task.applyListenerCertificates(&entry, l) + + assert.Equal(t, "scoped-cert", *entry.CertificateDefault.Id) + assert.Empty(t, entry.CertificateAuthorities) +} + +func TestApplyListenerCertificates_SecretRefs(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + task.unscopedPolicy = nil + + l := makeHTTPSListener("https", []gwv1.SecretObjectReference{ + makeSecretRef("", "my-tls-secret"), + makeSecretRef("", "my-ca-secret"), + }) + entry := makeListenerEntry("https") + task.applyListenerCertificates(&entry, &l) + + assert.NotNil(t, entry.CertificateDefault) + assert.Equal(t, "my-tls-secret", *entry.CertificateDefault.SecretName) + assert.Len(t, entry.CertificateAuthorities, 1) + assert.Equal(t, "my-ca-secret", *entry.CertificateAuthorities[0].SecretName) +} + +func TestApplyListenerCertificates_CrossNsSkipped(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + task.unscopedPolicy = nil + + // Cross-namespace ref should be skipped, same-ns ref should be kept + l := makeHTTPSListener("https", []gwv1.SecretObjectReference{ + makeSecretRef("other-ns", "cross-secret"), // skipped + makeSecretRef("", "same-ns-secret"), // kept + }) + entry := makeListenerEntry("https") + task.applyListenerCertificates(&entry, &l) + + assert.NotNil(t, entry.CertificateDefault) + assert.Equal(t, "same-ns-secret", *entry.CertificateDefault.SecretName) + assert.Empty(t, entry.CertificateAuthorities) +} + +func TestApplyListenerCertificates_EmptySecretName(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + task.unscopedPolicy = nil + + // Empty secret name skipped + l := makeHTTPSListener("https", []gwv1.SecretObjectReference{ + makeSecretRef("", ""), + }) + entry := makeListenerEntry("https") + task.applyListenerCertificates(&entry, &l) + + assert.Nil(t, entry.CertificateDefault) +} + +func TestBuildCreateCertificates(t *testing.T) { + t.Run("no HTTPS listeners returns nil", func(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + got := task.buildCreateCertificates() + assert.Nil(t, got) + }) + + t.Run("cert-ID listeners skip Secret collection", func(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + makeHTTPSListener("https", []gwv1.SecretObjectReference{makeSecretRef("", "my-secret")}), + }, + }, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.CertificateIDs = []string{"cert-id-1"} + got := task.buildCreateCertificates() + assert.Nil(t, got) // cert-ID path → no import needed + }) + + t.Run("dedupes same Secret across listeners", func(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + makeHTTPSListener("https1", []gwv1.SecretObjectReference{makeSecretRef("", "shared-secret")}), + makeHTTPSListener("https2", []gwv1.SecretObjectReference{makeSecretRef("", "shared-secret")}), + }, + }, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + task.unscopedPolicy = nil + got := task.buildCreateCertificates() + assert.Len(t, got, 1) // deduped + assert.Equal(t, "shared-secret", got[0].SecretName) + }) + + t.Run("cross-ns secrets skipped", func(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + makeHTTPSListener("https", []gwv1.SecretObjectReference{makeSecretRef("other-ns", "cross")}), + }, + }, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + task.unscopedPolicy = nil + got := task.buildCreateCertificates() + assert.Nil(t, got) + }) + + t.Run("multiple unique secrets sorted", func(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + makeHTTPSListener("https1", []gwv1.SecretObjectReference{makeSecretRef("", "z-secret")}), + makeHTTPSListener("https2", []gwv1.SecretObjectReference{makeSecretRef("", "a-secret")}), + }, + }, + } + task := newTestTask(t, gw) + task.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy) + task.unscopedPolicy = nil + got := task.buildCreateCertificates() + assert.Len(t, got, 2) + assert.Equal(t, "a-secret", got[0].SecretName) + assert.Equal(t, "z-secret", got[1].SecretName) + }) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_lbc.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_lbc.go new file mode 100644 index 00000000..fd38123e --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_lbc.go @@ -0,0 +1,369 @@ +package alb_gateway_uc + +import ( + "context" + "fmt" + "reflect" + + "github.com/sirupsen/logrus" + "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/common" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +// defaultGatewayBuildTask carries per-reconcile state and is the rough analogue +// of ingress_uc's defaultModelBuildTask. One task per Gateway reconcile. +type defaultGatewayBuildTask struct { + uc *albGatewayUseCase + logger *logrus.Entry + gw *gwv1.Gateway + nameHelper utils.NameHelper + + // Resolved at translation time. + unscopedPolicy *gwv1alpha1.VKSGatewayPolicy // for LB-level fields and listener defaults + listenerPolicies map[string]*gwv1alpha1.VKSGatewayPolicy // by Gateway listener name +} + +// run is the per-reconcile entry. Phase A only translates LB-level fields; +// listeners, certs, pools, and policies land in subsequent phases and will +// extend buildLoadBalancerConfig in place. +func (t *defaultGatewayBuildTask) run(ctx context.Context) error { + if err := t.resolveGatewayPolicies(ctx); err != nil { + return err + } + buildErr := t.buildLoadBalancerConfig(ctx) + // HTTPRoute Accepted/ResolvedRefs reflect attachment + backend resolution, + // which don't depend on cloud provisioning — write them regardless of + // buildErr (best-effort; must not mask the reconcile result). + if err := t.writeRouteStatuses(ctx); err != nil { + t.logger.Warnf("write HTTPRoute statuses for Gateway %s/%s: %v", t.gw.Namespace, t.gw.Name, err) + } + return buildErr +} + +// resolveGatewayPolicies finds the unscoped policy (LB-level fields + listener +// defaults) and per-listener policies (by sectionName). Conflict losers are +// stamped onto status by the policy validator controllers, not here. +func (t *defaultGatewayBuildTask) resolveGatewayPolicies(ctx context.Context) error { + var list gwv1alpha1.VKSGatewayPolicyList + if err := t.uc.k8sClient.List(ctx, &list, client.InNamespace(t.gw.Namespace)); err != nil { + return fmt.Errorf("list VKSGatewayPolicy: %w", err) + } + cands := make([]*gwv1alpha1.VKSGatewayPolicy, 0, len(list.Items)) + for i := range list.Items { + cands = append(cands, &list.Items[i]) + } + + unscopedTarget := pkggw.PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", + Namespace: t.gw.Namespace, Name: t.gw.Name, + } + t.unscopedPolicy, _ = sharedUC.ResolveDirectPolicy(cands, unscopedTarget) + + t.listenerPolicies = make(map[string]*gwv1alpha1.VKSGatewayPolicy, len(t.gw.Spec.Listeners)) + for _, l := range t.gw.Spec.Listeners { + listenerTarget := pkggw.PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", + Namespace: t.gw.Namespace, + Name: t.gw.Name, + SectionName: string(l.Name), + } + win, _ := sharedUC.ResolveDirectPolicy(cands, listenerTarget) + if win != nil { + t.listenerPolicies[string(l.Name)] = win + } + } + return nil +} + +// buildLoadBalancerConfig finds (or creates) the LBC owned by this Gateway and +// rewrites only the fields the Gateway controller is the source of truth for. +// Mirrors ingress_uc's pattern: read existing, mutate Spec, patch only on +// real change to avoid generation churn. +func (t *defaultGatewayBuildTask) buildLoadBalancerConfig(ctx context.Context) error { + lbcs, err := t.uc.listOwnedLBCs(ctx, t.gw) + if err != nil { + return err + } + if len(lbcs) > 1 { + return fmt.Errorf("multiple LBCs found for Gateway %s/%s; cannot reconcile", t.gw.Namespace, t.gw.Name) + } + + var ( + lbc *v1alpha1.LoadBalancerConfig + oldLBC *v1alpha1.LoadBalancerConfig + isCreated bool + ) + if len(lbcs) == 1 { + lbc = lbcs[0] + oldLBC = lbc.DeepCopy() + isCreated = true + } else { + lbc = &v1alpha1.LoadBalancerConfig{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: t.gw.Namespace, + GenerateName: t.gw.Name + "-", + }, + Spec: v1alpha1.LoadBalancerConfigSpec{}, + } + } + + // Owner labels — the same {kind, name, uid} set Ingress/Service use, so + // kubectl describe and existing reverse-lookups all keep working. + if lbc.Labels == nil { + lbc.Labels = make(map[string]string) + } + lbc.Labels[domain.LabelOwnerResourceKind] = domain.OwnerKindGateway + lbc.Labels[domain.LabelOwnerResourceName] = t.gw.Name + lbc.Labels[domain.LabelOwnerResourceUid] = string(t.gw.UID) + lbc.Labels[domain.OwnerLabelGatewayUID] = string(t.gw.UID) + + // Type, SubnetId, ZoneId, LoadBalancerName are mirrored from the cloud LB + // by the LBC controller. Writing them on every reconcile fights that sync + // and produces an infinite reconcile loop (see commit 1bbc823). Resolve and + // set them only at create time — that also keeps the subnet/zone resolution + // (which may call VNGCloud to adopt an existing LB or pin a zone) off the + // per-reconcile hot path. VpcId tracks the cluster network and is cheap, so + // it is set unconditionally. + lbc.Spec.VpcId = t.uc.defaultNetworkId + if !isCreated { + subnetID, networkID, zone, _, err := t.resolveSubnetAndZone(ctx) + if err != nil { + return err + } + if subnetID == "" || networkID == "" || zone == "" { + return fmt.Errorf("could not resolve subnet/zone for Gateway %s/%s; set VKSGatewayPolicy.LoadBalancerSpec.SubnetID/PreferZoneID or wait for cluster default network discovery", + t.gw.Namespace, t.gw.Name) + } + lbc.Spec.VpcId = networkID + lbc.Spec.Type = v2.LoadBalancerTypeLayer7 + lbc.Spec.SubnetId = subnetID + lbc.Spec.ZoneId = common.Zone(zone) + lbc.Spec.LoadBalancerName = t.resolveLoadBalancerName() + } + + if t.uc.clusterId != "" { + lbc.Spec.ClusterId = &t.uc.clusterId + } + + // LB-level fields from VKSGatewayPolicy.LoadBalancerSpec (only the + // unscoped policy contributes here per the design spec). + t.applyLoadBalancerSpec(lbc) + + listeners, err := t.buildListeners() + if err != nil { + return err + } + lbc.Spec.CreateCertificates = t.buildCreateCertificates() + + pools, listenerPolicies, err := t.buildPoolsAndPolicies(ctx) + if err != nil { + return err + } + lbc.Spec.Pools = pools + + // Fold per-listener policies onto the matching listener entry. The + // listenerPolicies map is keyed by the *Gateway* listener name (Gateway + // listeners and the LBC listener slice share the same i — both are + // emitted in Gateway.Spec.Listeners order, with unsupported-protocol + // entries skipped from the LBC slice but preserved here in the map for + // later phases). Iterating against the Gateway listeners keeps the + // pairing correct even when the LBC listener name was renamed (e.g. + // short names get a "gw__" prefix to satisfy the cloud API's + // 5-char minimum). + gwIdx := 0 + for i := range listeners { + // Advance gwIdx to the next supported-protocol Gateway listener. + for gwIdx < len(t.gw.Spec.Listeners) { + if _, ok := mapListenerProtocol(t.gw.Spec.Listeners[gwIdx].Protocol); ok { + break + } + gwIdx++ + } + if gwIdx >= len(t.gw.Spec.Listeners) { + break + } + if pol, ok := listenerPolicies[string(t.gw.Spec.Listeners[gwIdx].Name)]; ok { + listeners[i].Policies = pol + } + gwIdx++ + } + lbc.Spec.Listeners = listeners + + if !isCreated { + if err := t.uc.k8sRepo.CreateLoadBalancerConfig(ctx, lbc); err != nil { + // Status reflects the failure so the user sees Programmed=False. + _ = t.writeGatewayStatus(ctx, nil, err) + return fmt.Errorf("create LBC for Gateway %s/%s: %w", t.gw.Namespace, t.gw.Name, err) + } + t.logger.Infof("created LBC %s/%s", lbc.Namespace, lbc.Name) + return t.writeGatewayStatus(ctx, lbc, nil) + } + if !reflect.DeepEqual(oldLBC.Spec, lbc.Spec) || !reflect.DeepEqual(oldLBC.Labels, lbc.Labels) { + if err := t.uc.k8sRepo.PatchLoadBalancerConfig(ctx, lbc, client.MergeFrom(oldLBC)); err != nil { + _ = t.writeGatewayStatus(ctx, oldLBC, err) + return fmt.Errorf("patch LBC %s/%s: %w", lbc.Namespace, lbc.Name, err) + } + t.logger.Infof("patched LBC %s/%s", lbc.Namespace, lbc.Name) + } + return t.writeGatewayStatus(ctx, lbc, nil) +} + +// applyLoadBalancerSpec writes the LB-level fields the unscoped +// VKSGatewayPolicy contributes. Listener-level fields (timeouts, allowedCidrs, +// insertHeaders) are applied per-listener in Phase C. +// +// The unscoped policy is the sole source of truth for these fields, so each +// is overwritten unconditionally (including with nil) — removing a field from +// the policy must un-set it on the LBC, otherwise stale values persist +// forever. This matches the Ingress/Service controller pattern, which +// reassigns every reconcile from annotation-or-nil. +func (t *defaultGatewayBuildTask) applyLoadBalancerSpec(lbc *v1alpha1.LoadBalancerConfig) { + var s *gwv1alpha1.VKSLoadBalancerSpec + if t.unscopedPolicy != nil { + s = t.unscopedPolicy.Spec.LoadBalancerSpec + } + + lbc.Spec.Scheme = nil + lbc.Spec.PackageId = nil + lbc.Spec.LoadBalancerId = nil + lbc.Spec.PrivateSubnetId = nil + lbc.Spec.PrivateZoneId = nil + lbc.Spec.EnableAutoscale = nil + lbc.Spec.IsPoc = nil + lbc.Spec.Tags = nil + + if s == nil { + return + } + if s.Scheme != nil { + scheme := v2.LoadBalancerScheme(*s.Scheme) + lbc.Spec.Scheme = &scheme + } + if s.PackageID != nil { + lbc.Spec.PackageId = s.PackageID + } + if s.LoadBalancerID != nil { + lbc.Spec.LoadBalancerId = s.LoadBalancerID + } + if s.PrivateSubnetID != nil { + lbc.Spec.PrivateSubnetId = s.PrivateSubnetID + } + if s.PrivateZoneID != nil { + zone := common.Zone(*s.PrivateZoneID) + lbc.Spec.PrivateZoneId = &zone + } + if s.EnableAutoscale != nil { + lbc.Spec.EnableAutoscale = s.EnableAutoscale + } + if s.IsPOC != nil { + lbc.Spec.IsPoc = s.IsPOC + } + if len(s.Tags) > 0 { + // Replace the whole map (don't merge). A key removed from the + // policy must disappear from the LBC; merging would leak it. + lbc.Spec.Tags = make(map[string]string, len(s.Tags)) + for k, v := range s.Tags { + lbc.Spec.Tags[k] = v + } + } +} + +// resolveSubnetAndZone returns (subnetID, networkID, zone, cidr, err) for a new +// LB, honoring the unscoped VKSGatewayPolicy.LoadBalancerSpec in the same +// priority order the Ingress controller uses: +// 1. LoadBalancerID — adopt the existing LB; mirror its backend subnet's +// zone/cidr so the LBC matches the LB the LBC controller adopts (it adopts +// via Spec.LoadBalancerId; see lbc_uc/deploy_lb.go). +// 2. SubnetID — explicit subnet (zone stays the cluster default). +// 3. PreferZoneID — pin to a zone by finding a cluster node in it. +// 4. cluster defaults. +func (t *defaultGatewayBuildTask) resolveSubnetAndZone(ctx context.Context) (string, string, string, string, error) { + var s *gwv1alpha1.VKSLoadBalancerSpec + if t.unscopedPolicy != nil { + s = t.unscopedPolicy.Spec.LoadBalancerSpec + } + + switch { + case s != nil && s.LoadBalancerID != nil && *s.LoadBalancerID != "": + lb, err := t.uc.vngcloudRepo.GetLoadBalancerByID(ctx, *s.LoadBalancerID) + if err != nil || lb == nil { + return "", "", "", "", fmt.Errorf("get load balancer by id %s for adoption: %w", *s.LoadBalancerID, err) + } + if lb.BackendSubnetID == "" || lb.BackendSubnetID == t.uc.defaultSubnetId { + return t.uc.defaultSubnetId, t.uc.defaultNetworkId, string(t.uc.defaultZone), t.uc.defaultSubnetCIDR, nil + } + subnet, err := t.uc.vngcloudRepo.GetSubnetByID(ctx, t.uc.defaultNetworkId, lb.BackendSubnetID) + if err != nil || subnet == nil { + return "", "", "", "", fmt.Errorf("get subnet %s of adopted load balancer %s: %w", lb.BackendSubnetID, *s.LoadBalancerID, err) + } + return subnet.Id, t.uc.defaultNetworkId, subnet.ZoneID, subnet.Cidr, nil + + case s != nil && s.SubnetID != nil && *s.SubnetID != "": + return *s.SubnetID, t.uc.defaultNetworkId, string(t.uc.defaultZone), t.uc.defaultSubnetCIDR, nil + + case s != nil && s.PreferZoneID != nil && *s.PreferZoneID != "": + if common.Zone(*s.PreferZoneID) == t.uc.defaultZone { + return t.uc.defaultSubnetId, t.uc.defaultNetworkId, string(t.uc.defaultZone), t.uc.defaultSubnetCIDR, nil + } + nodes := &corev1.NodeList{} + if err := t.uc.k8sRepo.ListNode(ctx, nodes); err != nil { + return "", "", "", "", fmt.Errorf("list nodes to resolve prefer zone %s: %w", *s.PreferZoneID, err) + } + for _, providerID := range utils.GetListProviderIdFromNodeList(nodes) { + z, n, sn, cidr, err := t.uc.vngcloudRepo.GetServerNetworkInfo(ctx, providerID) + if err != nil { + continue + } + if string(z) == *s.PreferZoneID { + return sn, n, string(z), cidr, nil + } + } + return "", "", "", "", fmt.Errorf("no cluster node found in prefer zone %s", *s.PreferZoneID) + + default: + return t.uc.defaultSubnetId, t.uc.defaultNetworkId, string(t.uc.defaultZone), t.uc.defaultSubnetCIDR, nil + } +} + +// resolveLoadBalancerName returns the cloud LB name to set at create time: +// the unscoped policy's LoadBalancerName when set, else a controller-generated +// default. Mirrors the Ingress "load-balancer-name" annotation. +func (t *defaultGatewayBuildTask) resolveLoadBalancerName() string { + if t.unscopedPolicy != nil && t.unscopedPolicy.Spec.LoadBalancerSpec != nil { + if n := t.unscopedPolicy.Spec.LoadBalancerSpec.LoadBalancerName; n != nil && *n != "" { + return *n + } + } + return t.nameHelper.GetLoadBalancerDefaultName() +} + +// listOwnedLBCs finds the LBC(s) owned by gw via the +// vks.vngcloud.vn/owner-resource-uid label. Caller treats >1 as an error. +func (uc *albGatewayUseCase) listOwnedLBCs(ctx context.Context, gw *gwv1.Gateway) ([]*v1alpha1.LoadBalancerConfig, error) { + var list v1alpha1.LoadBalancerConfigList + if err := uc.k8sRepo.ListLoadBalancerConfig(ctx, &list, + client.InNamespace(gw.Namespace), + client.MatchingLabels{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceUid: string(gw.UID), + }, + ); err != nil { + return nil, fmt.Errorf("list LBC owned by Gateway %s/%s: %w", gw.Namespace, gw.Name, err) + } + out := make([]*v1alpha1.LoadBalancerConfig, 0, len(list.Items)) + for i := range list.Items { + out = append(out, &list.Items[i]) + } + return out, nil +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_lbc_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_lbc_test.go new file mode 100644 index 00000000..5a97468d --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_lbc_test.go @@ -0,0 +1,649 @@ +package alb_gateway_uc + +import ( + "context" + "errors" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/entity" + "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/common" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +const testLoadBalancerID = "lb-existing" + +// newTaskAndMocks builds a task exposing its repository mocks so tests can set +// expectations on the VNGCloud / K8s repos (needed for subnet/zone resolution). +func newTaskAndMocks(t *testing.T, gw *gwv1.Gateway) (*defaultGatewayBuildTask, *repository.MockK8sRepository, *repository.MockVngCloudRepository) { + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fake.NewClientBuilder().WithScheme(newTestScheme()).Build(), + defaultZone: "HCM03-1C", + defaultNetworkId: "net-1", + defaultSubnetId: "subnet-1", + defaultSubnetCIDR: "10.0.0.0/24", + clusterId: "cluster-1", + } + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("cluster-1", "gateway", gw.Namespace, gw.Name), + } + return task, mockK8s, mockVng +} + +func gwPolicyWithLBSpec(lb *gwv1alpha1.VKSLoadBalancerSpec) *gwv1alpha1.VKSGatewayPolicy { + return &gwv1alpha1.VKSGatewayPolicy{Spec: gwv1alpha1.VKSGatewayPolicySpec{LoadBalancerSpec: lb}} +} + +// --- resolveSubnetAndZone: adoption (load-balancer-id) --- + +func TestResolveSubnetAndZone_AdoptByLoadBalancerID(t *testing.T) { + task, _, mockVng := newTaskAndMocks(t, &gwv1.Gateway{}) + lbID := testLoadBalancerID + task.unscopedPolicy = gwPolicyWithLBSpec(&gwv1alpha1.VKSLoadBalancerSpec{LoadBalancerID: &lbID}) + // Adopting an LB whose backend subnet differs from the cluster default → + // mirror that subnet's zone/cidr so the LBC is coherent with the real LB. + mockVng.EXPECT().GetLoadBalancerByID(mock.Anything, testLoadBalancerID). + Return(&entity.LoadBalancer{BackendSubnetID: "subnet-other"}, nil) + mockVng.EXPECT().GetSubnetByID(mock.Anything, "net-1", "subnet-other"). + Return(&entity.Subnet{Id: "subnet-other", ZoneID: "HCM03-2B", Cidr: "10.1.0.0/24"}, nil) + + subnet, network, zone, cidr, err := task.resolveSubnetAndZone(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "subnet-other", subnet) + assert.Equal(t, "net-1", network) + assert.Equal(t, "HCM03-2B", zone) + assert.Equal(t, "10.1.0.0/24", cidr) +} + +func TestResolveSubnetAndZone_AdoptLBOnDefaultSubnet(t *testing.T) { + task, _, mockVng := newTaskAndMocks(t, &gwv1.Gateway{}) + lbID := testLoadBalancerID + task.unscopedPolicy = gwPolicyWithLBSpec(&gwv1alpha1.VKSLoadBalancerSpec{LoadBalancerID: &lbID}) + // LB already on the cluster default subnet → short-circuit to defaults, + // no GetSubnetByID call (asserting it's NOT called via mockery strictness). + mockVng.EXPECT().GetLoadBalancerByID(mock.Anything, testLoadBalancerID). + Return(&entity.LoadBalancer{BackendSubnetID: "subnet-1"}, nil) + + subnet, network, zone, _, err := task.resolveSubnetAndZone(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "subnet-1", subnet) + assert.Equal(t, "net-1", network) + assert.Equal(t, "HCM03-1C", zone) +} + +func TestResolveSubnetAndZone_AdoptLBLookupError(t *testing.T) { + task, _, mockVng := newTaskAndMocks(t, &gwv1.Gateway{}) + lbID := "missing" + task.unscopedPolicy = gwPolicyWithLBSpec(&gwv1alpha1.VKSLoadBalancerSpec{LoadBalancerID: &lbID}) + mockVng.EXPECT().GetLoadBalancerByID(mock.Anything, "missing"). + Return(nil, errors.New("not found")) + _, _, _, _, err := task.resolveSubnetAndZone(context.Background()) + assert.Error(t, err) +} + +// --- resolveSubnetAndZone: prefer-zone-id --- + +func TestResolveSubnetAndZone_PreferZone(t *testing.T) { + task, mockK8s, mockVng := newTaskAndMocks(t, &gwv1.Gateway{}) + zone := "HCM03-2B" + task.unscopedPolicy = gwPolicyWithLBSpec(&gwv1alpha1.VKSLoadBalancerSpec{PreferZoneID: &zone}) + mockK8s.EXPECT().ListNode(mock.Anything, mock.AnythingOfType("*v1.NodeList")). + RunAndReturn(func(_ context.Context, list *corev1.NodeList, _ ...client.ListOption) error { + list.Items = []corev1.Node{*makeNode("node-1", "vngcloud://ins-00000000-0000-0000-0000-000000000099")} + return nil + }) + mockVng.EXPECT().GetServerNetworkInfo(mock.Anything, "ins-00000000-0000-0000-0000-000000000099"). + Return(common.Zone("HCM03-2B"), "net-2", "subnet-2", "10.2.0.0/24", nil) + + subnet, network, z, cidr, err := task.resolveSubnetAndZone(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "subnet-2", subnet) + assert.Equal(t, "net-2", network) + assert.Equal(t, "HCM03-2B", z) + assert.Equal(t, "10.2.0.0/24", cidr) +} + +func TestResolveSubnetAndZone_PreferZoneEqualsDefault(t *testing.T) { + task, _, _ := newTaskAndMocks(t, &gwv1.Gateway{}) + zone := "HCM03-1C" // == default zone → short-circuit, no node listing + task.unscopedPolicy = gwPolicyWithLBSpec(&gwv1alpha1.VKSLoadBalancerSpec{PreferZoneID: &zone}) + subnet, _, z, _, err := task.resolveSubnetAndZone(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "subnet-1", subnet) + assert.Equal(t, "HCM03-1C", z) +} + +func TestResolveSubnetAndZone_PreferZoneNoMatch(t *testing.T) { + task, mockK8s, mockVng := newTaskAndMocks(t, &gwv1.Gateway{}) + zone := "HCM99-9Z" + task.unscopedPolicy = gwPolicyWithLBSpec(&gwv1alpha1.VKSLoadBalancerSpec{PreferZoneID: &zone}) + mockK8s.EXPECT().ListNode(mock.Anything, mock.AnythingOfType("*v1.NodeList")). + RunAndReturn(func(_ context.Context, list *corev1.NodeList, _ ...client.ListOption) error { + list.Items = []corev1.Node{*makeNode("node-1", "vngcloud://ins-00000000-0000-0000-0000-000000000099")} + return nil + }) + mockVng.EXPECT().GetServerNetworkInfo(mock.Anything, "ins-00000000-0000-0000-0000-000000000099"). + Return(common.Zone("HCM03-1C"), "net-1", "subnet-1", "10.0.0.0/24", nil) + _, _, _, _, err := task.resolveSubnetAndZone(context.Background()) + assert.Error(t, err) +} + +// --- resolveLoadBalancerName --- + +func TestResolveLoadBalancerName(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + + // No policy name → controller default. + assert.Equal(t, task.nameHelper.GetLoadBalancerDefaultName(), task.resolveLoadBalancerName()) + + // Policy name wins. + custom := "my-custom-lb" + task.unscopedPolicy = gwPolicyWithLBSpec(&gwv1alpha1.VKSLoadBalancerSpec{LoadBalancerName: &custom}) + assert.Equal(t, "my-custom-lb", task.resolveLoadBalancerName()) +} + +// newFakeClientWithHTTPRouteIndex builds a fake client that has the HTTPRoute parent-gateway +// field index registered — required by listAttachedHTTPRoutes. +func newFakeClientWithHTTPRouteIndex(objs ...client.Object) client.Client { + s := newTestScheme() + return fake.NewClientBuilder(). + WithScheme(s). + WithObjects(objs...). + WithStatusSubresource(&gwv1.Gateway{}). + WithIndex(&gwv1.HTTPRoute{}, shared.IndexHTTPRouteByParentGateway, func(obj client.Object) []string { + r := obj.(*gwv1.HTTPRoute) + var keys []string + for _, p := range r.Spec.ParentRefs { + ns := r.Namespace + if p.Namespace != nil { + ns = string(*p.Namespace) + } + if p.Kind == nil || *p.Kind == "Gateway" { + keys = append(keys, ns+"/"+string(p.Name)) + } + } + return keys + }). + Build() +} + +// newFullTask builds a task with a real fake client containing the gateway policy objs. +func newFullTask(t *testing.T, gw *gwv1.Gateway, objs ...client.Object) (*defaultGatewayBuildTask, *repository.MockK8sRepository) { + s := newTestScheme() + builder := fake.NewClientBuilder().WithScheme(s).WithStatusSubresource(&gwv1.Gateway{}) + for _, o := range objs { + builder = builder.WithObjects(o) + } + fakeClient := builder.Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + defaultZone: "HCM03-1C", + defaultNetworkId: "net-1", + defaultSubnetId: "subnet-1", + defaultSubnetCIDR: "10.0.0.0/24", + clusterId: "cluster-1", + } + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("cluster-1", "gateway", gw.Namespace, gw.Name), + } + return task, mockK8s +} + +func TestResolveGatewayPolicies_NoPolicies(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw", UID: "uid-1"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + task, _ := newFullTask(t, gw) + err := task.resolveGatewayPolicies(context.Background()) + assert.NoError(t, err) + assert.Nil(t, task.unscopedPolicy) + assert.Empty(t, task.listenerPolicies) +} + +func TestResolveGatewayPolicies_WithUnscopedPolicy(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw", + UID: "uid-1", + }, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + // VKSGatewayPolicy targeting this Gateway (no sectionName = unscoped) + policy := &gwv1alpha1.VKSGatewayPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw-policy"}, + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + TargetRefs: []gwv1alpha1.LocalPolicyTargetReferenceWithSectionName{{ + LocalPolicyTargetReference: gwv1alpha1.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "my-gw", + }, + }}, + }, + } + + task, _ := newFullTask(t, gw, policy) + err := task.resolveGatewayPolicies(context.Background()) + assert.NoError(t, err) + assert.NotNil(t, task.unscopedPolicy) +} + +func TestApplyLoadBalancerSpec_NilPolicy(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + task.unscopedPolicy = nil + lbc := &v1alpha1.LoadBalancerConfig{} + task.applyLoadBalancerSpec(lbc) + // No-op + assert.Nil(t, lbc.Spec.Scheme) + assert.Nil(t, lbc.Spec.PackageId) +} + +func TestApplyLoadBalancerSpec_NilLoadBalancerSpec(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} // no LoadBalancerSpec + lbc := &v1alpha1.LoadBalancerConfig{} + task.applyLoadBalancerSpec(lbc) + assert.Nil(t, lbc.Spec.Scheme) +} + +func TestApplyLoadBalancerSpec_WithFields(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + + scheme := "Internet" + pkg := "pkg-1" + lbID := "lb-existing" + subID := "subnet-private" + zoneID := "HCM03-1A" + autoscale := true + isPoc := false + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{ + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{ + Scheme: &scheme, + PackageID: &pkg, + LoadBalancerID: &lbID, + PrivateSubnetID: &subID, + PrivateZoneID: &zoneID, + EnableAutoscale: &autoscale, + IsPOC: &isPoc, + Tags: map[string]string{"env": "prod"}, + }, + }, + } + lbc := &v1alpha1.LoadBalancerConfig{} + task.applyLoadBalancerSpec(lbc) + assert.NotNil(t, lbc.Spec.Scheme) + assert.NotNil(t, lbc.Spec.PackageId) + assert.Equal(t, "pkg-1", *lbc.Spec.PackageId) + assert.NotNil(t, lbc.Spec.LoadBalancerId) + assert.NotNil(t, lbc.Spec.PrivateSubnetId) + assert.Equal(t, "subnet-private", *lbc.Spec.PrivateSubnetId) + if assert.NotNil(t, lbc.Spec.PrivateZoneId) { + assert.Equal(t, "HCM03-1A", string(*lbc.Spec.PrivateZoneId)) + } + assert.NotNil(t, lbc.Spec.EnableAutoscale) + assert.True(t, *lbc.Spec.EnableAutoscale) + assert.NotNil(t, lbc.Spec.IsPoc) + assert.False(t, *lbc.Spec.IsPoc) + assert.Equal(t, map[string]string{"env": "prod"}, lbc.Spec.Tags) +} + +// TestApplyLoadBalancerSpec_RemovesStaleFields locks in the fix for the +// sticky-field bug: when the user removes a field from VKSGatewayPolicy +// (or removes the policy entirely), applyLoadBalancerSpec must clear the +// corresponding LBC.Spec field — otherwise the previous value persists +// forever, diverging from the Ingress controller's reassign-every-reconcile +// behavior. +func TestApplyLoadBalancerSpec_RemovesStaleFields(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + + // Pre-populate the LBC with values from a previous reconcile. + scheme := v2.LoadBalancerScheme("Internet") + pkg := "pkg-old" + lbID := "lb-old" + subID := "subnet-old" + autoscale := true + isPoc := true + lbc := &v1alpha1.LoadBalancerConfig{ + Spec: v1alpha1.LoadBalancerConfigSpec{ + Scheme: &scheme, + PackageId: &pkg, + LoadBalancerId: &lbID, + PrivateSubnetId: &subID, + EnableAutoscale: &autoscale, + IsPoc: &isPoc, + Tags: map[string]string{"keep-me": "no"}, + }, + } + + t.Run("policy entirely removed clears all stale fields", func(t *testing.T) { + task := newTestTask(t, gw) + task.unscopedPolicy = nil + got := lbc.DeepCopy() + task.applyLoadBalancerSpec(got) + assert.Nil(t, got.Spec.Scheme) + assert.Nil(t, got.Spec.PackageId) + assert.Nil(t, got.Spec.LoadBalancerId) + assert.Nil(t, got.Spec.PrivateSubnetId) + assert.Nil(t, got.Spec.EnableAutoscale) + assert.Nil(t, got.Spec.IsPoc) + assert.Nil(t, got.Spec.Tags) + }) + + t.Run("partial policy clears only the unset fields", func(t *testing.T) { + task := newTestTask(t, gw) + newScheme := "Internal" + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{ + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{ + Scheme: &newScheme, + }, + }, + } + got := lbc.DeepCopy() + task.applyLoadBalancerSpec(got) + // Scheme overwritten with the new value. + assert.NotNil(t, got.Spec.Scheme) + assert.Equal(t, v2.LoadBalancerScheme("Internal"), *got.Spec.Scheme) + // All other previously-set fields are cleared. + assert.Nil(t, got.Spec.PackageId) + assert.Nil(t, got.Spec.LoadBalancerId) + assert.Nil(t, got.Spec.PrivateSubnetId) + assert.Nil(t, got.Spec.EnableAutoscale) + assert.Nil(t, got.Spec.IsPoc) + assert.Nil(t, got.Spec.Tags) + }) + + t.Run("Tags are replaced, not merged", func(t *testing.T) { + task := newTestTask(t, gw) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{ + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{ + Tags: map[string]string{"env": "prod"}, + }, + }, + } + got := &v1alpha1.LoadBalancerConfig{ + Spec: v1alpha1.LoadBalancerConfigSpec{ + Tags: map[string]string{"old-key": "old-val"}, + }, + } + task.applyLoadBalancerSpec(got) + assert.Equal(t, map[string]string{"env": "prod"}, got.Spec.Tags, + "old-key must be evicted when the policy doesn't list it") + }) +} + +// TestBuildLoadBalancerConfig_CreatePath tests the happy path where no LBC exists yet. +func TestBuildLoadBalancerConfig_CreatePath(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw", + UID: types.UID("gw-uid-1"), + }, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + // The Gateway itself is in the fake client for status patch; index required for HTTPRoute listing. + fakeClient := newFakeClientWithHTTPRouteIndex(gw) + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + // listOwnedLBCs → empty (new gateway) + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + + // CreateLoadBalancerConfig called + mockK8s.EXPECT(). + CreateLoadBalancerConfig(mock.Anything, mock.Anything). + Return(nil).Once() + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + defaultZone: "HCM03-1C", + defaultNetworkId: "net-1", + defaultSubnetId: "subnet-1", + defaultSubnetCIDR: "10.0.0.0/24", + clusterId: "cluster-1", + } + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("cluster-1", "gateway", gw.Namespace, gw.Name), + } + + err := task.buildLoadBalancerConfig(context.Background()) + assert.NoError(t, err) +} + +// TestBuildLoadBalancerConfig_PatchPath tests the happy path where the LBC already exists. +func TestBuildLoadBalancerConfig_PatchPath(t *testing.T) { + gwUID := types.UID("gw-uid-2") + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw", UID: gwUID}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + existingLBC := &v1alpha1.LoadBalancerConfig{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw-lbc", + Labels: map[string]string{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceUid: string(gwUID), + }, + }, + Spec: v1alpha1.LoadBalancerConfigSpec{VpcId: "net-old"}, + } + + // Index required for HTTPRoute listing. + fakeClient := newFakeClientWithHTTPRouteIndex(gw) + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + // listOwnedLBCs returns one existing LBC + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, list *v1alpha1.LoadBalancerConfigList, _ ...client.ListOption) error { + list.Items = []v1alpha1.LoadBalancerConfig{*existingLBC} + return nil + }).Once() + + // Spec changed (VpcId) → PatchLoadBalancerConfig called + mockK8s.EXPECT(). + PatchLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + defaultZone: "HCM03-1C", + defaultNetworkId: "net-1", + defaultSubnetId: "subnet-1", + defaultSubnetCIDR: "10.0.0.0/24", + clusterId: "cluster-1", + } + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("cluster-1", "gateway", gw.Namespace, gw.Name), + } + + err := task.buildLoadBalancerConfig(context.Background()) + assert.NoError(t, err) +} + +// TestBuildLoadBalancerConfig_MultipleOwnedLBCsError ensures >1 LBC returns error. +func TestBuildLoadBalancerConfig_MultipleOwnedLBCsError(t *testing.T) { + gwUID := types.UID("gw-uid-3") + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw", UID: gwUID}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, list *v1alpha1.LoadBalancerConfigList, _ ...client.ListOption) error { + list.Items = []v1alpha1.LoadBalancerConfig{ + {ObjectMeta: metav1.ObjectMeta{Name: "lbc-1"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "lbc-2"}}, + } + return nil + }).Once() + + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{ + uc: uc, gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("cluster-1", "gateway", gw.Namespace, gw.Name), + } + err := task.buildLoadBalancerConfig(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "multiple LBCs") +} + +// TestRun exercises the top-level task.run() path. +func TestRun_NoSubnet(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw-run", UID: "uid-run"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + fakeClient := newFakeClientWithHTTPRouteIndex(gw) + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + // No subnet/zone → error in buildLoadBalancerConfig + } + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("cluster-1", "gateway", gw.Namespace, gw.Name), + } + err := task.run(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not resolve subnet/zone") +} + +// TestBuildLoadBalancerConfig_MissingSubnet tests error when no subnet available. +func TestBuildLoadBalancerConfig_MissingSubnet(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw", UID: "uid"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + // No defaults set — subnet/network/zone all empty + } + task := &defaultGatewayBuildTask{ + uc: uc, gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("cluster-1", "gateway", gw.Namespace, gw.Name), + } + err := task.buildLoadBalancerConfig(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not resolve subnet/zone") +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_listeners.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_listeners.go new file mode 100644 index 00000000..af91e300 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_listeners.go @@ -0,0 +1,162 @@ +package alb_gateway_uc + +import ( + "fmt" + "strings" + + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" +) + +// buildListeners translates Gateway.Spec.Listeners into v1alpha1.Listener entries. +// +// Phase C scope: protocol/port/timeouts/allowedCidrs/insertHeaders/clientCertId. +// TLS certs (CertificateDefault / CertificateAuthorities) are filled in Phase D. +// L7 Policies are populated in Phase E from attached HTTPRoutes. +// +// Per the design spec, listener-level VKSGatewayPolicy fields win over the +// unscoped policy on the same Gateway, field by field. Listeners with +// unsupported protocols (TCP/UDP/TLS for ALB Phase 1) are dropped here and +// produce a per-listener Accepted=False condition in Phase F status work. +func (t *defaultGatewayBuildTask) buildListeners() ([]v1alpha1.Listener, error) { + out := make([]v1alpha1.Listener, 0, len(t.gw.Spec.Listeners)) + for i := range t.gw.Spec.Listeners { + l := &t.gw.Spec.Listeners[i] + proto, ok := mapListenerProtocol(l.Protocol) + if !ok { + // Skip silently for now; Phase F will surface this on the + // Gateway listener's Accepted condition. + t.logger.Warnf("listener %q uses unsupported protocol %q for ALB; skipping", + l.Name, l.Protocol) + continue + } + entry := v1alpha1.Listener{ + Name: t.cloudListenerName(l), + Protocol: proto, + ProtocolPort: int32(l.Port), + } + t.applyListenerPolicy(&entry, l) + t.applyListenerCertificates(&entry, l) + out = append(out, entry) + } + if len(out) == 0 { + return nil, fmt.Errorf("gateway %s/%s has no ALB-supported listeners", t.gw.Namespace, t.gw.Name) + } + return out, nil +} + +// mapListenerProtocol converts a Gateway-API protocol to the LBC enum. +// ALB Phase 1 supports only HTTP and HTTPS at the listener level. TLS +// (passthrough) belongs on the future NLB GatewayClass; TCP/UDP are L4. +func mapListenerProtocol(p gwv1.ProtocolType) (v2.ListenerProtocol, bool) { + switch p { + case gwv1.HTTPProtocolType: + return v2.ListenerProtocolHTTP, true + case gwv1.HTTPSProtocolType: + return v2.ListenerProtocolHTTPS, true + } + return "", false +} + +// applyListenerPolicy merges fields from the matching VKSGatewayPolicy into +// the listener. Per-listener (sectionName-scoped) wins over unscoped policy +// per the design spec; fields not set in either are left at their LBC default. +func (t *defaultGatewayBuildTask) applyListenerPolicy(entry *v1alpha1.Listener, l *gwv1.Listener) { + scoped := t.listenerPolicies[string(l.Name)] + policies := []*gwv1alpha1.VKSGatewayPolicy{scoped, t.unscopedPolicy} + + // Timeouts: first non-nil wins (per-listener > unscoped). + if v := firstNonNilDuration(policies, func(p *gwv1alpha1.VKSGatewayPolicy) *durationLike { + return wrapDur(p.Spec.TimeoutClient) + }); v != nil { + entry.TimeoutClient = ptr32(int32(v.Duration.Seconds())) + } + if v := firstNonNilDuration(policies, func(p *gwv1alpha1.VKSGatewayPolicy) *durationLike { + return wrapDur(p.Spec.TimeoutMember) + }); v != nil { + entry.TimeoutMember = ptr32(int32(v.Duration.Seconds())) + } + if v := firstNonNilDuration(policies, func(p *gwv1alpha1.VKSGatewayPolicy) *durationLike { + return wrapDur(p.Spec.TimeoutConnection) + }); v != nil { + entry.TimeoutConnection = ptr32(int32(v.Duration.Seconds())) + } + + // AllowedCIDRs: per-listener overrides unscoped entirely (no merge). + // vngcloud's API accepts a single comma-joined string. + if cidrs := firstNonEmptyStringSlice(policies, func(p *gwv1alpha1.VKSGatewayPolicy) []string { + return p.Spec.AllowedCIDRs + }); len(cidrs) > 0 { + s := strings.Join(cidrs, ",") + entry.AllowedCidrs = &s + } + + // InsertHeaders: per-listener overrides unscoped entirely (no merge). + // LBC stores headers as []InsertHeader; map[string]string in the policy + // goes through a deterministic-by-key flatten so generation is stable. + // Default — when neither policy supplies headers — is the X-Forwarded-* + // triplet the Ingress controller emits when the annotation is absent + // (see ingress_uc/build_listener.go: buildAnnotationInsertHeaders). + hdrs := firstNonEmptyStringMap(policies, func(p *gwv1alpha1.VKSGatewayPolicy) map[string]string { + return p.Spec.InsertHeaders + }) + if len(hdrs) == 0 { + hdrs = defaultInsertHeaders() + } + entry.InsertHeaders = flattenInsertHeaders(hdrs) + + // ClientCertificateId: per-listener wins. + if id := firstNonNilString(policies, func(p *gwv1alpha1.VKSGatewayPolicy) *string { + return p.Spec.ClientCertificateID + }); id != nil { + entry.ClientCertificateId = id + } +} + +// cloudListenerName produces a deterministic listener name keyed on the +// Gateway's UID + the listener's K8s name. Format: "vks_gw__". +// Truncated to the cloud's 50-char limit; the underlying ListenerCreate +// regex accepts [a-zA-Z0-9_.-]{5,50} so the underscores are fine. +func (t *defaultGatewayBuildTask) cloudListenerName(l *gwv1.Listener) string { + uid := string(t.gw.UID) + if len(uid) > 8 { + uid = uid[:8] + } + name := fmt.Sprintf("%sgw_%s_%s", domain.VKSResourceNamePrefix, uid, l.Name) + if len(name) > 50 { + name = name[:50] + } + return name +} + +// defaultInsertHeaders mirrors the Ingress controller's no-annotation default +// so a Gateway-managed LB receives the same X-Forwarded-* request headers as +// an Ingress-managed one. Users can override (or empty out) the set by +// supplying VKSGatewayPolicy.InsertHeaders explicitly. +func defaultInsertHeaders() map[string]string { + return map[string]string{ + "X-Forwarded-For": "true", + "X-Forwarded-Proto": "true", + "X-Forwarded-Port": "true", + } +} + +// flattenInsertHeaders returns headers in deterministic order so spec-equality +// checks (DeepEqual in build_lbc.go) don't churn on map iteration. +func flattenInsertHeaders(h map[string]string) []v1alpha1.InsertHeader { + keys := make([]string, 0, len(h)) + for k := range h { + keys = append(keys, k) + } + // stable order via sort + sortStrings(keys) + out := make([]v1alpha1.InsertHeader, 0, len(h)) + for _, k := range keys { + out = append(out, v1alpha1.InsertHeader{HeaderName: k, HeaderValue: h[k]}) + } + return out +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_listeners_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_listeners_test.go new file mode 100644 index 00000000..bb350ae4 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_listeners_test.go @@ -0,0 +1,291 @@ +package alb_gateway_uc + +import ( + "strings" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +func newTestScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = gwv1.Install(s) + _ = gwv1alpha1.AddToScheme(s) + _ = v1alpha1.AddToScheme(s) + _ = corev1.AddToScheme(s) + _ = gwv1beta1.Install(s) + return s +} + +func newTestTask(t *testing.T, gw *gwv1.Gateway) *defaultGatewayBuildTask { + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithStatusSubresource(&gwv1.Gateway{}).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + defaultZone: "HCM03-1C", + defaultNetworkId: "net-1", + defaultSubnetId: "subnet-1", + defaultSubnetCIDR: "10.0.0.0/24", + clusterId: "cluster-1", + } + return &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("cluster-1", "gateway", gw.Namespace, gw.Name), + } +} + +func TestMapListenerProtocol(t *testing.T) { + tests := []struct { + name string + proto gwv1.ProtocolType + wantOk bool + wantVal v2.ListenerProtocol + }{ + {"HTTP", gwv1.HTTPProtocolType, true, v2.ListenerProtocolHTTP}, + {"HTTPS", gwv1.HTTPSProtocolType, true, v2.ListenerProtocolHTTPS}, + {"TCP", gwv1.TCPProtocolType, false, ""}, + {"UDP", gwv1.UDPProtocolType, false, ""}, + {"TLS", gwv1.TLSProtocolType, false, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := mapListenerProtocol(tt.proto) + assert.Equal(t, tt.wantOk, ok) + if ok { + assert.Equal(t, tt.wantVal, got) + } + }) + } +} + +// TestCloudListenerName checks the per-Gateway listener name. The cloud +// enforces [a-zA-Z0-9_.-]{5,50}; the format is "vks_gw__". +func TestCloudListenerName(t *testing.T) { + tests := []struct { + name string + gwUID string + listenerName string + maxLen int + checkPrefix string + }{ + { + name: "short name padded with uid prefix", + gwUID: "abcd1234-dead-beef-0000-111122223333", + listenerName: "http", + maxLen: 50, + checkPrefix: "vks_gw_abcd1234_http", + }, + { + name: "long listener name truncated to 50 chars", + gwUID: "abcd1234-dead-beef-0000-111122223333", + listenerName: "this-is-a-very-long-listener-name-that-exceeds-limit", + maxLen: 50, + checkPrefix: "vks_gw_abcd1234_", + }, + { + name: "uid shorter than 8 chars used as is", + gwUID: "short", + listenerName: "http", + maxLen: 50, + checkPrefix: "vks_gw_short_http", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw", + UID: types.UID(tt.gwUID), + }, + } + task := newTestTask(t, gw) + l := &gwv1.Listener{Name: gwv1.SectionName(tt.listenerName)} + got := task.cloudListenerName(l) + assert.LessOrEqual(t, len(got), tt.maxLen) + assert.True(t, strings.HasPrefix(got, tt.checkPrefix), + "expected prefix %q in %q", tt.checkPrefix, got) + }) + } + + t.Run("deterministic across calls", func(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{UID: "uid-1234"}} + task := newTestTask(t, gw) + l := &gwv1.Listener{Name: "http"} + assert.Equal(t, task.cloudListenerName(l), task.cloudListenerName(l)) + }) +} + +func TestFlattenInsertHeaders(t *testing.T) { + t.Run("empty map returns empty", func(t *testing.T) { + got := flattenInsertHeaders(map[string]string{}) + assert.Empty(t, got) + }) + t.Run("deterministic order regardless of map iteration", func(t *testing.T) { + h := map[string]string{"Z": "3", "A": "1", "M": "2"} + got := flattenInsertHeaders(h) + assert.Len(t, got, 3) + assert.Equal(t, "A", got[0].HeaderName) + assert.Equal(t, "M", got[1].HeaderName) + assert.Equal(t, "Z", got[2].HeaderName) + }) + t.Run("values preserved", func(t *testing.T) { + h := map[string]string{"X-Real-IP": "true"} + got := flattenInsertHeaders(h) + assert.Len(t, got, 1) + assert.Equal(t, v1alpha1.InsertHeader{HeaderName: "X-Real-IP", HeaderValue: "true"}, got[0]) + }) +} + +func TestApplyListenerPolicy(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + t.Run("nil policies: timeouts/cidrs nil but InsertHeaders defaulted", func(t *testing.T) { + task := newTestTask(t, gw) + task.unscopedPolicy = nil + entry := &v1alpha1.Listener{} + task.applyListenerPolicy(entry, &gw.Spec.Listeners[0]) + assert.Nil(t, entry.TimeoutClient) + assert.Nil(t, entry.TimeoutMember) + assert.Nil(t, entry.TimeoutConnection) + assert.Nil(t, entry.AllowedCidrs) + // InsertHeaders defaults to the X-Forwarded-* triplet when no policy + // supplies a value — same default the Ingress controller emits. + names := make([]string, 0, len(entry.InsertHeaders)) + for _, h := range entry.InsertHeaders { + names = append(names, h.HeaderName) + } + assert.ElementsMatch(t, []string{"X-Forwarded-For", "X-Forwarded-Proto", "X-Forwarded-Port"}, names) + }) + + t.Run("unscoped policy applies timeout", func(t *testing.T) { + task := newTestTask(t, gw) + d := metav1.Duration{Duration: 30 * time.Second} + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.TimeoutClient = &d + entry := &v1alpha1.Listener{} + task.applyListenerPolicy(entry, &gw.Spec.Listeners[0]) + assert.NotNil(t, entry.TimeoutClient) + assert.Equal(t, int32(30), *entry.TimeoutClient) + }) + + t.Run("per-listener policy wins over unscoped for timeout", func(t *testing.T) { + task := newTestTask(t, gw) + unscopedD := metav1.Duration{Duration: 10 * time.Second} + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.TimeoutClient = &unscopedD + + scopedD := metav1.Duration{Duration: 60 * time.Second} + scoped := &gwv1alpha1.VKSGatewayPolicy{} + scoped.Spec.TimeoutClient = &scopedD + task.listenerPolicies["http"] = scoped + + entry := &v1alpha1.Listener{} + task.applyListenerPolicy(entry, &gw.Spec.Listeners[0]) + assert.NotNil(t, entry.TimeoutClient) + assert.Equal(t, int32(60), *entry.TimeoutClient) + }) + + t.Run("AllowedCIDRs joined with comma", func(t *testing.T) { + task := newTestTask(t, gw) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.AllowedCIDRs = []string{"10.0.0.0/8", "192.168.0.0/16"} + entry := &v1alpha1.Listener{} + task.applyListenerPolicy(entry, &gw.Spec.Listeners[0]) + assert.NotNil(t, entry.AllowedCidrs) + assert.Equal(t, "10.0.0.0/8,192.168.0.0/16", *entry.AllowedCidrs) + }) + + t.Run("InsertHeaders applied from unscoped", func(t *testing.T) { + task := newTestTask(t, gw) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.InsertHeaders = map[string]string{"X-Forwarded-For": "true"} + entry := &v1alpha1.Listener{} + task.applyListenerPolicy(entry, &gw.Spec.Listeners[0]) + assert.Len(t, entry.InsertHeaders, 1) + assert.Equal(t, "X-Forwarded-For", entry.InsertHeaders[0].HeaderName) + }) + + t.Run("ClientCertificateId applied from policy", func(t *testing.T) { + task := newTestTask(t, gw) + certID := "cert-123" + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.ClientCertificateID = &certID + entry := &v1alpha1.Listener{} + task.applyListenerPolicy(entry, &gw.Spec.Listeners[0]) + assert.NotNil(t, entry.ClientCertificateId) + assert.Equal(t, "cert-123", *entry.ClientCertificateId) + }) + + t.Run("AllowedCIDRs: per-listener overrides unscoped", func(t *testing.T) { + task := newTestTask(t, gw) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.AllowedCIDRs = []string{"0.0.0.0/0"} + scoped := &gwv1alpha1.VKSGatewayPolicy{} + scoped.Spec.AllowedCIDRs = []string{"10.0.0.0/8"} + task.listenerPolicies["http"] = scoped + entry := &v1alpha1.Listener{} + task.applyListenerPolicy(entry, &gw.Spec.Listeners[0]) + assert.Equal(t, "10.0.0.0/8", *entry.AllowedCidrs) + }) +} + +func TestBuildListeners_AllUnsupported(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "tcp", Protocol: gwv1.TCPProtocolType, Port: 443}, + }, + }, + } + task := newTestTask(t, gw) + _, err := task.buildListeners() + assert.Error(t, err) // all listeners unsupported → error +} + +func TestBuildListeners_MixedProtocols(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw", UID: "uid-abc"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + {Name: "tcp", Protocol: gwv1.TCPProtocolType, Port: 9000}, + {Name: "https", Protocol: gwv1.HTTPSProtocolType, Port: 443}, + }, + }, + } + task := newTestTask(t, gw) + listeners, err := task.buildListeners() + assert.NoError(t, err) + assert.Len(t, listeners, 2) // TCP skipped +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_policies.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_policies.go new file mode 100644 index 00000000..bf62027f --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_policies.go @@ -0,0 +1,410 @@ +package alb_gateway_uc + +import ( + "context" + "fmt" + "hash/fnv" + "sort" + + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + "k8s.io/utils/ptr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" +) + +// buildPoolsAndPolicies walks attached HTTPRoutes and produces +// - a flat []Pool (deduped by pool name) +// - the slice of (listenerName → []Policy) ready to fold into LBC.Listeners. +// +// Per the design spec §1.6, the vngcloud LB API supports only HOST_NAME and +// PATH at the L7 rule level. HTTPRoute matches that use header / queryParam / +// method are dropped here with a warning; Phase F status work surfaces this +// as Accepted=False on the affected route's parent status. +// +// Phase E (initial): no VKSRoutePolicy overlay, no VKSBackendPolicy / +// VKSHealthCheckPolicy on pools. Those land in a follow-up commit. +func (t *defaultGatewayBuildTask) buildPoolsAndPolicies(ctx context.Context) ([]v1alpha1.Pool, map[string][]v1alpha1.Policy, error) { + routes, err := t.listAttachedHTTPRoutes(ctx) + if err != nil { + return nil, nil, err + } + + pools := make([]v1alpha1.Pool, 0) + seenPool := make(map[string]struct{}) + appendPool := func(p *v1alpha1.Pool) { + if _, ok := seenPool[p.Name]; ok { + return + } + seenPool[p.Name] = struct{}{} + pools = append(pools, *p) + } + + listenerPolicies := make(map[string][]v1alpha1.Policy) + listenerRanked := make(map[string][]rankedPolicy) + + for ri := range routes { + route := &routes[ri] + parentRefs := parentRefsForGateway(route, t.gw) + if len(parentRefs) == 0 { + continue + } + + for ruleIdx, rule := range route.Spec.Rules { + if hasUnsupportedMatchDimension(rule.Matches) { + t.logger.Warnf("HTTPRoute %s/%s rule %d uses match dimensions VNGCloud LB doesn't support "+ + "(headers/queryParams/method); skipping rule. Route status reports PartiallyInvalid.", + route.Namespace, route.Name, ruleIdx) + continue + } + + // A rule's backends merge into one synthetic pool with a single + // overlay; divergent per-backend policies fail the rule closed + // (route status reports BackendConfigMismatch). + if diverge, err := t.ruleBackendPoliciesDiverge(ctx, route, rule); err != nil { + return nil, nil, err + } else if diverge { + t.logger.Warnf("HTTPRoute %s/%s rule %d: backends carry divergent VKSBackend/VKSHealthCheck policies; skipping rule", + route.Namespace, route.Name, ruleIdx) + continue + } + + // A rule with no backendRefs is valid Gateway-API when it carries + // a RequestRedirect filter (or, in future phases, FixedResponse- + // equivalent — not supported by VNGCloud, see §1.6). For those + // filter-only rules we don't synthesize a pool; the policy will + // emit REDIRECT_TO_URL straight from the filter. + poolName := "" + if len(rule.BackendRefs) > 0 { + pool, err := t.synthesizePool(ctx, route, ruleIdx, rule) + if err != nil { + t.logger.Warnf("HTTPRoute %s/%s rule %d: %v; skipping rule", + route.Namespace, route.Name, ruleIdx, err) + continue + } + appendPool(pool) + poolName = pool.Name + } else if !hasRedirectFilter(rule.Filters) { + t.logger.Warnf("HTTPRoute %s/%s rule %d has no backendRefs and no RequestRedirect filter; skipping", + route.Namespace, route.Name, ruleIdx) + continue + } + + ruleName := "" + if rule.Name != nil { + ruleName = string(*rule.Name) + } + + for _, parent := range parentRefs { + for li := range t.gw.Spec.Listeners { + l := &t.gw.Spec.Listeners[li] + if l.Protocol != gwv1.HTTPProtocolType && l.Protocol != gwv1.HTTPSProtocolType { + continue + } + if !listenerAcceptsRoute(l, route, &parent) { + continue + } + hostnames := matchingRouteHostnames(l, route) + ranked := t.buildListenerPolicies(route, ruleIdx, rule, hostnames, poolName) + ranked, err := t.applyRoutePolicyToRankedPolicies(ctx, ranked, route, ruleName) + if err != nil { + return nil, nil, err + } + listenerRanked[string(l.Name)] = append(listenerRanked[string(l.Name)], ranked...) + } + } + } + } + + // Sort each listener's policies by Gateway-API match specificity + // (Exact > Regex > Prefix; longer paths first; more headers; older + // route timestamp tiebreak) and assign sequential Positions starting + // at 1 so the cloud LB evaluates the most specific match first. + // Mirrors how GCP gke-gateway / AWS LBC order rules on a URL map. + // VKSRoutePolicy.Position is honored verbatim if the user set it. + for k, items := range listenerRanked { + listenerPolicies[k] = sortAndAssignPositions(items) + } + sort.SliceStable(pools, func(i, j int) bool { return pools[i].Name < pools[j].Name }) + + return pools, listenerPolicies, nil +} + +// hasUnsupportedMatchDimension returns true if any HTTPRouteMatch uses a +// dimension VNGCloud LB doesn't translate (header / queryParam / method). +// This is checked at the rule level — one such match invalidates the whole +// rule, since rejecting per-match would silently drop traffic. +func hasUnsupportedMatchDimension(matches []gwv1.HTTPRouteMatch) bool { + for _, m := range matches { + if len(m.Headers) > 0 || len(m.QueryParams) > 0 || m.Method != nil { + return true + } + } + return false +} + +// hasRedirectFilter reports whether any filter is a RequestRedirect — used to +// keep filter-only HTTPRoute rules (no backendRefs) alive so they can emit a +// REDIRECT_TO_URL policy. +func hasRedirectFilter(filters []gwv1.HTTPRouteFilter) bool { + for _, f := range filters { + if f.Type == gwv1.HTTPRouteFilterRequestRedirect && f.RequestRedirect != nil { + return true + } + } + return false +} + +// rankedPolicy pairs a generated v1alpha1.Policy with the metadata +// sortAndAssignPositions needs to order it on the listener (more-specific +// match first). userPosition is non-nil when a VKSRoutePolicy.Position +// applied via overlay; sortAndAssignPositions honors it verbatim instead +// of overwriting with the auto-assigned value. +type rankedPolicy struct { + policy v1alpha1.Policy + rank shared.RankedMatch + userPosition *int32 +} + +// buildListenerPolicies emits one rankedPolicy per (hostname × match) +// combo. With no hostnames, one policy per match (no HOST_NAME rule). +// Action is REDIRECT_TO_POOL when poolName is non-empty; if poolName is +// empty (filter-only rule) the action stays REDIRECT_TO_POOL until the +// RequestRedirect filter — guaranteed present by the caller in that case — +// flips it to REDIRECT_TO_URL. +// +// policyName is keyed on (route.UID, ruleIdx, host, match) so multiple +// HTTPRoutes attached to the same Gateway don't collide on a single name. +func (t *defaultGatewayBuildTask) buildListenerPolicies(route *gwv1.HTTPRoute, ruleIdx int, rule gwv1.HTTPRouteRule, hostnames []string, poolName string) []rankedPolicy { + matches := rule.Matches + if len(matches) == 0 { + // Default-empty match → match all on this listener. + matches = []gwv1.HTTPRouteMatch{{}} + } + hostList := hostnames + if len(hostList) == 0 { + hostList = []string{""} + } + + out := make([]rankedPolicy, 0, len(matches)*len(hostList)) + for _, host := range hostList { + for _, m := range matches { + rules := buildL7Rules(host, m) + policy := v1alpha1.Policy{ + Name: policyName(route, ruleIdx, host, m), + Action: v2.PolicyActionREDIRECTTOPOOL, + L7Rules: rules, + } + if poolName != "" { + policy.RedirectPoolName = ptr.To(poolName) + } + if applyRequestRedirectFilter(&policy, rule.Filters) { + policy.RedirectPoolName = nil + } + out = append(out, rankedPolicy{ + policy: policy, + rank: shared.RankedMatch{ + Match: m, + RouteCreated: route.CreationTimestamp, + RouteNamespace: route.Namespace, + RouteName: route.Name, + }, + }) + } + } + return out +} + +// sortAndAssignPositions orders the listener's policies by Gateway-API +// match specificity and assigns sequential Position values 1..N so the +// cloud LB evaluates the most-specific match first. User-set positions +// from VKSRoutePolicy.Position are honored verbatim (not renumbered). +// Sorting is stable so policies that compare equal stay in their original +// relative order — important for deterministic Spec equality across +// reconciles. +func sortAndAssignPositions(items []rankedPolicy) []v1alpha1.Policy { + // Dedup by name first so two routes that hash to the same policy name + // don't emit a duplicate (defence in depth — names should already be + // unique because they include route.UID). + seen := make(map[string]struct{}, len(items)) + deduped := make([]rankedPolicy, 0, len(items)) + for i := range items { + if _, ok := seen[items[i].policy.Name]; ok { + continue + } + seen[items[i].policy.Name] = struct{}{} + deduped = append(deduped, items[i]) + } + + // Stable sort by match specificity. Lower index → higher priority on + // the cloud LB. + ranks := make(shared.ByMatchSpecificity, 0, len(deduped)) + for _, rp := range deduped { + ranks = append(ranks, rp.rank) + } + indices := make([]int, len(deduped)) + for i := range indices { + indices[i] = i + } + sort.SliceStable(indices, func(i, j int) bool { + return ranks.Less(indices[i], indices[j]) + }) + + out := make([]v1alpha1.Policy, 0, len(deduped)) + for newPos, oldIdx := range indices { + rp := deduped[oldIdx] + if rp.userPosition != nil { + rp.policy.Position = rp.userPosition + } else { + pos := int32(newPos + 1) // 1-based + rp.policy.Position = &pos + } + out = append(out, rp.policy) + } + return out +} + +// buildL7Rules emits a HOST_NAME rule (literal → EQUAL_TO, wildcard → REGEX) +// when host is non-empty, and a PATH rule from the match's path. Other match +// dimensions (header / queryParam / method) are guaranteed empty by +// hasUnsupportedMatchDimension having returned false earlier. +func buildL7Rules(host string, m gwv1.HTTPRouteMatch) []v1alpha1.L7Rule { + rules := make([]v1alpha1.L7Rule, 0, 2) + if host != "" { + compare := v2.PolicyCompareTypeEQUALS + value := host + if isWildcardHost(host) { + compare = v2.PolicyCompareTypeREGEX + value = wildcardHostToRegex(host) + } + rules = append(rules, v1alpha1.L7Rule{ + RuleType: v2.PolicyRuleTypeHOSTNAME, + CompareType: compare, + RuleValue: value, + }) + } + if m.Path != nil && m.Path.Value != nil && *m.Path.Value != "" { + compare := v2.PolicyCompareTypeSTARTSWITH + if m.Path.Type != nil { + switch *m.Path.Type { + case gwv1.PathMatchExact: + compare = v2.PolicyCompareTypeEQUALS + case gwv1.PathMatchRegularExpression: + compare = v2.PolicyCompareTypeREGEX + case gwv1.PathMatchPathPrefix: + compare = v2.PolicyCompareTypeSTARTSWITH + } + } + rules = append(rules, v1alpha1.L7Rule{ + RuleType: v2.PolicyRuleTypePATH, + CompareType: compare, + RuleValue: *m.Path.Value, + }) + } + return rules +} + +// applyRequestRedirectFilter handles the Gateway-API RequestRedirect filter. +// Returns true when the filter was applied (caller drops RedirectPoolName). +func applyRequestRedirectFilter(p *v1alpha1.Policy, filters []gwv1.HTTPRouteFilter) bool { + for _, f := range filters { + if f.Type != gwv1.HTTPRouteFilterRequestRedirect || f.RequestRedirect == nil { + continue + } + r := f.RequestRedirect + p.Action = v2.PolicyActionREDIRECTTOURL + p.RedirectUrl = ptr.To(buildRedirectURL(r)) + if r.StatusCode != nil { + code := int32(*r.StatusCode) + p.RedirectHttpCode = &code + } + return true + } + return false +} + +func buildRedirectURL(r *gwv1.HTTPRequestRedirectFilter) string { + scheme := "https" + if r.Scheme != nil { + scheme = *r.Scheme + } + host := "" + if r.Hostname != nil { + host = string(*r.Hostname) + } + url := scheme + "://" + host + if r.Port != nil { + url = fmt.Sprintf("%s:%d", url, *r.Port) + } + if r.Path != nil && r.Path.ReplaceFullPath != nil { + url += *r.Path.ReplaceFullPath + } + return url +} + +// policyName generates a deterministic LBC.Policy name keyed by route UID, +// rule index, hostname, and a hash of the match. Mirrors the synth-pool +// approach so two reconciles converge on the same policy name. +func policyName(route *gwv1.HTTPRoute, ruleIdx int, host string, m gwv1.HTTPRouteMatch) string { + h := fnv.New32a() + _, _ = h.Write([]byte(host)) + if m.Path != nil { + if m.Path.Value != nil { + _, _ = h.Write([]byte(*m.Path.Value)) + } + if m.Path.Type != nil { + _, _ = h.Write([]byte(*m.Path.Type)) + } + } + uid := string(route.UID) + if len(uid) > 8 { + uid = uid[:8] + } + name := fmt.Sprintf("%sgw_%s_%d_%x", domain.VKSResourceNamePrefix, uid, ruleIdx, h.Sum32()) + if len(name) > 50 { + name = name[:50] + } + return name +} + +// dedupPoliciesByName drops second-and-later occurrences of any policy with +// a duplicate name. Sorts by name afterwards so the listener's policy slice +// is comparable across reconciles. +func dedupPoliciesByName(in []v1alpha1.Policy) []v1alpha1.Policy { + seen := make(map[string]struct{}, len(in)) + out := make([]v1alpha1.Policy, 0, len(in)) + for i := range in { + if _, ok := seen[in[i].Name]; ok { + continue + } + seen[in[i].Name] = struct{}{} + out = append(out, in[i]) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func isWildcardHost(h string) bool { + return len(h) > 2 && h[0] == '*' && h[1] == '.' +} + +func wildcardHostToRegex(h string) string { + // "*.example.com" → "^[^.]+\.example\.com$" — pkg/gateway has the same + // shape; we re-implement here to keep the LBC.L7Rule.RuleValue self- + // contained without an extra import dance. + regexed := make([]byte, 0, len(h)+8) + regexed = append(regexed, '^', '[', '^', '.', ']', '+', '\\', '.') + for i := 2; i < len(h); i++ { + c := h[i] + switch c { + case '.', '+', '?', '^', '$', '|', '(', ')', '{', '}', '[', ']', '\\': + regexed = append(regexed, '\\', c) + default: + regexed = append(regexed, c) + } + } + regexed = append(regexed, '$') + return string(regexed) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_policies_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_policies_test.go new file mode 100644 index 00000000..0a14a0cd --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_policies_test.go @@ -0,0 +1,370 @@ +package alb_gateway_uc + +import ( + "testing" + + "github.com/stretchr/testify/assert" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + "k8s.io/utils/ptr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +func TestHasUnsupportedMatchDimension(t *testing.T) { + tests := []struct { + name string + matches []gwv1.HTTPRouteMatch + want bool + }{ + { + name: "empty matches is supported", + matches: nil, + want: false, + }, + { + name: "path-only is supported", + matches: []gwv1.HTTPRouteMatch{{ + Path: &gwv1.HTTPPathMatch{ + Type: ptrPathType(gwv1.PathMatchExact), + Value: ptr.To("/foo"), + }, + }}, + want: false, + }, + { + name: "header match is unsupported", + matches: []gwv1.HTTPRouteMatch{{ + Headers: []gwv1.HTTPHeaderMatch{{Name: "X-Foo", Value: "bar"}}, + }}, + want: true, + }, + { + name: "queryParam match is unsupported", + matches: []gwv1.HTTPRouteMatch{{ + QueryParams: []gwv1.HTTPQueryParamMatch{{Name: "q", Value: "v"}}, + }}, + want: true, + }, + { + name: "method match is unsupported", + matches: []gwv1.HTTPRouteMatch{{ + Method: ptrMethod(gwv1.HTTPMethodGet), + }}, + want: true, + }, + { + name: "path + header is unsupported", + matches: []gwv1.HTTPRouteMatch{{ + Path: &gwv1.HTTPPathMatch{Value: ptr.To("/")}, + Headers: []gwv1.HTTPHeaderMatch{{Name: "X-H", Value: "v"}}, + }}, + want: true, + }, + { + name: "multiple matches, one unsupported", + matches: []gwv1.HTTPRouteMatch{ + {Path: &gwv1.HTTPPathMatch{Value: ptr.To("/ok")}}, + {Method: ptrMethod(gwv1.HTTPMethodPost)}, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, hasUnsupportedMatchDimension(tt.matches)) + }) + } +} + +func ptrPathType(pt gwv1.PathMatchType) *gwv1.PathMatchType { return &pt } +func ptrMethod(m gwv1.HTTPMethod) *gwv1.HTTPMethod { return &m } + +func TestIsWildcardHost(t *testing.T) { + tests := []struct { + host string + want bool + }{ + {"*.example.com", true}, + {"example.com", false}, + {"*", false}, + {"*.", false}, + {"*.a", true}, + {"foo.*.bar.com", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + assert.Equal(t, tt.want, isWildcardHost(tt.host)) + }) + } +} + +func TestWildcardHostToRegex(t *testing.T) { + tests := []struct { + host string + want string + }{ + {"*.example.com", `^[^.]+\.example\.com$`}, + {"*.foo.bar", `^[^.]+\.foo\.bar$`}, + {"*.a", `^[^.]+\.a$`}, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + assert.Equal(t, tt.want, wildcardHostToRegex(tt.host)) + }) + } +} + +func TestPolicyName(t *testing.T) { + route := &gwv1.HTTPRoute{} + route.UID = "abcdef12-dead-beef-0000-111122223333" + + t.Run("deterministic across calls", func(t *testing.T) { + m := gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Value: ptr.To("/foo"), Type: ptrPathType(gwv1.PathMatchExact)}} + n1 := policyName(route, 0, "example.com", m) + n2 := policyName(route, 0, "example.com", m) + assert.Equal(t, n1, n2) + }) + t.Run("at most 50 chars", func(t *testing.T) { + m := gwv1.HTTPRouteMatch{Path: &gwv1.HTTPPathMatch{Value: ptr.To("/very/long/path/that/makes/the/name/exceed/fifty/chars")}} + name := policyName(route, 99, "really.long.hostname.example.com", m) + assert.LessOrEqual(t, len(name), 50) + }) + t.Run("different ruleIdx produces different name", func(t *testing.T) { + m := gwv1.HTTPRouteMatch{} + n0 := policyName(route, 0, "", m) + n1 := policyName(route, 1, "", m) + assert.NotEqual(t, n0, n1) + }) + t.Run("different host produces different name", func(t *testing.T) { + m := gwv1.HTTPRouteMatch{} + n0 := policyName(route, 0, "a.com", m) + n1 := policyName(route, 0, "b.com", m) + assert.NotEqual(t, n0, n1) + }) +} + +func TestDedupPoliciesByName(t *testing.T) { + t.Run("empty input returns empty", func(t *testing.T) { + got := dedupPoliciesByName(nil) + assert.Empty(t, got) + }) + t.Run("no duplicates preserved and sorted", func(t *testing.T) { + in := []v1alpha1.Policy{{Name: "b"}, {Name: "a"}} + got := dedupPoliciesByName(in) + assert.Equal(t, []v1alpha1.Policy{{Name: "a"}, {Name: "b"}}, got) + }) + t.Run("duplicates dropped (first wins)", func(t *testing.T) { + in := []v1alpha1.Policy{ + {Name: "x", Action: v2.PolicyActionREDIRECTTOPOOL}, + {Name: "y"}, + {Name: "x", Action: v2.PolicyActionREJECT}, // dup + } + got := dedupPoliciesByName(in) + assert.Len(t, got, 2) + found := false + for _, p := range got { + if p.Name == "x" { + assert.Equal(t, v2.PolicyActionREDIRECTTOPOOL, p.Action) + found = true + } + } + assert.True(t, found) + }) + t.Run("output sorted by name", func(t *testing.T) { + in := []v1alpha1.Policy{{Name: "z"}, {Name: "m"}, {Name: "a"}} + got := dedupPoliciesByName(in) + assert.Equal(t, "a", got[0].Name) + assert.Equal(t, "m", got[1].Name) + assert.Equal(t, "z", got[2].Name) + }) +} + +func TestBuildL7Rules(t *testing.T) { + t.Run("empty host and nil path produces no rules", func(t *testing.T) { + rules := buildL7Rules("", gwv1.HTTPRouteMatch{}) + assert.Empty(t, rules) + }) + + t.Run("literal host produces EQUALS hostname rule", func(t *testing.T) { + rules := buildL7Rules("example.com", gwv1.HTTPRouteMatch{}) + assert.Len(t, rules, 1) + assert.Equal(t, v2.PolicyRuleTypeHOSTNAME, rules[0].RuleType) + assert.Equal(t, v2.PolicyCompareTypeEQUALS, rules[0].CompareType) + assert.Equal(t, "example.com", rules[0].RuleValue) + }) + + t.Run("wildcard host produces REGEX hostname rule", func(t *testing.T) { + rules := buildL7Rules("*.example.com", gwv1.HTTPRouteMatch{}) + assert.Len(t, rules, 1) + assert.Equal(t, v2.PolicyRuleTypeHOSTNAME, rules[0].RuleType) + assert.Equal(t, v2.PolicyCompareTypeREGEX, rules[0].CompareType) + assert.Equal(t, `^[^.]+\.example\.com$`, rules[0].RuleValue) + }) + + t.Run("path exact produces EQUALS path rule", func(t *testing.T) { + m := gwv1.HTTPRouteMatch{ + Path: &gwv1.HTTPPathMatch{ + Type: ptrPathType(gwv1.PathMatchExact), + Value: ptr.To("/exact"), + }, + } + rules := buildL7Rules("", m) + assert.Len(t, rules, 1) + assert.Equal(t, v2.PolicyRuleTypePATH, rules[0].RuleType) + assert.Equal(t, v2.PolicyCompareTypeEQUALS, rules[0].CompareType) + assert.Equal(t, "/exact", rules[0].RuleValue) + }) + + t.Run("path prefix produces STARTS_WITH path rule", func(t *testing.T) { + m := gwv1.HTTPRouteMatch{ + Path: &gwv1.HTTPPathMatch{ + Type: ptrPathType(gwv1.PathMatchPathPrefix), + Value: ptr.To("/prefix"), + }, + } + rules := buildL7Rules("", m) + assert.Len(t, rules, 1) + assert.Equal(t, v2.PolicyCompareTypeSTARTSWITH, rules[0].CompareType) + }) + + t.Run("path regex produces REGEX path rule", func(t *testing.T) { + m := gwv1.HTTPRouteMatch{ + Path: &gwv1.HTTPPathMatch{ + Type: ptrPathType(gwv1.PathMatchRegularExpression), + Value: ptr.To("^/api/.*"), + }, + } + rules := buildL7Rules("", m) + assert.Len(t, rules, 1) + assert.Equal(t, v2.PolicyCompareTypeREGEX, rules[0].CompareType) + }) + + t.Run("host + path produces two rules", func(t *testing.T) { + m := gwv1.HTTPRouteMatch{ + Path: &gwv1.HTTPPathMatch{ + Type: ptrPathType(gwv1.PathMatchExact), + Value: ptr.To("/api"), + }, + } + rules := buildL7Rules("foo.com", m) + assert.Len(t, rules, 2) + assert.Equal(t, v2.PolicyRuleTypeHOSTNAME, rules[0].RuleType) + assert.Equal(t, v2.PolicyRuleTypePATH, rules[1].RuleType) + }) + + t.Run("path with nil type defaults to STARTS_WITH", func(t *testing.T) { + m := gwv1.HTTPRouteMatch{ + Path: &gwv1.HTTPPathMatch{Value: ptr.To("/any")}, + } + rules := buildL7Rules("", m) + assert.Len(t, rules, 1) + assert.Equal(t, v2.PolicyCompareTypeSTARTSWITH, rules[0].CompareType) + }) +} + +func TestApplyRequestRedirectFilter(t *testing.T) { + t.Run("no filters returns false, no-op", func(t *testing.T) { + p := &v1alpha1.Policy{Action: v2.PolicyActionREDIRECTTOPOOL} + got := applyRequestRedirectFilter(p, nil) + assert.False(t, got) + assert.Equal(t, v2.PolicyActionREDIRECTTOPOOL, p.Action) + }) + + t.Run("non-redirect filter returns false", func(t *testing.T) { + p := &v1alpha1.Policy{Action: v2.PolicyActionREDIRECTTOPOOL} + filters := []gwv1.HTTPRouteFilter{{Type: gwv1.HTTPRouteFilterRequestHeaderModifier}} + got := applyRequestRedirectFilter(p, filters) + assert.False(t, got) + }) + + t.Run("redirect filter with nil RequestRedirect skipped", func(t *testing.T) { + p := &v1alpha1.Policy{Action: v2.PolicyActionREDIRECTTOPOOL} + filters := []gwv1.HTTPRouteFilter{{Type: gwv1.HTTPRouteFilterRequestRedirect, RequestRedirect: nil}} + got := applyRequestRedirectFilter(p, filters) + assert.False(t, got) + }) + + t.Run("redirect filter applied correctly", func(t *testing.T) { + p := &v1alpha1.Policy{Action: v2.PolicyActionREDIRECTTOPOOL} + host := gwv1.PreciseHostname("new.example.com") + code := 301 + filters := []gwv1.HTTPRouteFilter{{ + Type: gwv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gwv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("https"), + Hostname: &host, + StatusCode: &code, + }, + }} + got := applyRequestRedirectFilter(p, filters) + assert.True(t, got) + assert.Equal(t, v2.PolicyActionREDIRECTTOURL, p.Action) + assert.NotNil(t, p.RedirectUrl) + assert.Equal(t, "https://new.example.com", *p.RedirectUrl) + assert.Equal(t, int32(301), *p.RedirectHttpCode) + }) +} + +func TestBuildRedirectURL(t *testing.T) { + tests := []struct { + name string + filter gwv1.HTTPRequestRedirectFilter + want string + }{ + { + name: "scheme+host", + filter: gwv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("https"), + Hostname: ptrHostname("foo.com"), + }, + want: "https://foo.com", + }, + { + name: "default scheme is https", + filter: gwv1.HTTPRequestRedirectFilter{ + Hostname: ptrHostname("bar.com"), + }, + want: "https://bar.com", + }, + { + name: "scheme+host+port", + filter: gwv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("http"), + Hostname: ptrHostname("baz.com"), + Port: ptrPort(8080), + }, + want: "http://baz.com:8080", + }, + { + name: "scheme+host+path", + filter: gwv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("https"), + Hostname: ptrHostname("qux.com"), + Path: &gwv1.HTTPPathModifier{ReplaceFullPath: ptr.To("/new")}, + }, + want: "https://qux.com/new", + }, + { + name: "empty filter", + filter: gwv1.HTTPRequestRedirectFilter{}, + want: "https://", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, buildRedirectURL(&tt.filter)) + }) + } +} + +func ptrHostname(h string) *gwv1.PreciseHostname { + v := gwv1.PreciseHostname(h) + return &v +} + +func ptrPort(p int) *gwv1.PortNumber { + v := gwv1.PortNumber(p) + return &v +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_pool_overlays.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_pool_overlays.go new file mode 100644 index 00000000..fdc8f608 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_pool_overlays.go @@ -0,0 +1,254 @@ +package alb_gateway_uc + +import ( + "context" + "fmt" + + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// applyBackendPolicyToPool merges fields from a VKSBackendPolicy targeting +// the named Service into pool. The policy is resolved oldest-wins; conflict +// reporting is the validator controller's job. +// +// vngcloud Pool fields touched: Algorithm, Stickiness, TLSEncryption. +// VKSBackendPolicy.TargetNodeLabels and ManageDFPMembers are controller-side +// (not in LBC.Pool); they're handled when we add target-mode selection in +// a future phase. +func (t *defaultGatewayBuildTask) applyBackendPolicyToPool(ctx context.Context, pool *v1alpha1.Pool, ns, svcName string) error { + bp, err := t.resolveBackendPolicy(ctx, ns, svcName) + if err != nil { + return err + } + if bp == nil { + return nil + } + s := bp.Spec + if s.PoolAlgorithm != nil { + alg := v2.PoolAlgorithm(*s.PoolAlgorithm) + pool.Algorithm = &alg + } + if s.Stickiness != nil { + v := *s.Stickiness + pool.Stickiness = &v + } + if s.EnableTLSEncryption != nil { + v := *s.EnableTLSEncryption + pool.TLSEncryption = &v + } + return nil +} + +// applyHealthCheckPolicyToPool merges VKSHealthCheckPolicy fields into the +// pool's health monitor. Conflict resolution is oldest-wins. When two +// backendRefs in the same rule have conflicting policies, the rule fails +// translation; that's enforced one level up. +func (t *defaultGatewayBuildTask) applyHealthCheckPolicyToPool(ctx context.Context, pool *v1alpha1.Pool, ns, svcName string) error { + hp, err := t.resolveHealthCheckPolicy(ctx, ns, svcName) + if err != nil { + return err + } + if hp == nil { + return nil + } + s := hp.Spec + mon := v1alpha1.PoolHealthMonitor{ + Protocol: v2.HealthCheckProtocol(s.Protocol), + } + if s.Interval != nil { + mon.Interval = ptrInt(int(s.Interval.Seconds())) + } + if s.Timeout != nil { + mon.Timeout = ptrInt(int(s.Timeout.Seconds())) + } + if s.HealthyThreshold != nil { + mon.HealthyThreshold = ptrInt(int(*s.HealthyThreshold)) + } + if s.UnhealthyThreshold != nil { + mon.UnhealthyThreshold = ptrInt(int(*s.UnhealthyThreshold)) + } + // HTTP/HTTPS probes need healthCheckMethod + httpVersion set or the + // vngcloud API rejects CreatePool. Default to GET / 1.1 — same defaults + // the Ingress controller falls back to when annotations are absent — and + // let HTTPHealthCheck.Method / HTTPHealthCheck.HTTPVersion override them. + // These are HTTP-only; for TCP probes the whole block is skipped, so a + // method/version set on a TCP policy is silently ignored (matches Ingress). + if mon.Protocol == v2.HealthCheckProtocolHTTP || mon.Protocol == v2.HealthCheckProtocolHTTPs { + method := v2.HealthCheckMethodGET + if s.HTTPHealthCheck != nil && s.HTTPHealthCheck.Method != nil { + method = v2.HealthCheckMethod(*s.HTTPHealthCheck.Method) + } + mon.HealthCheckMethod = &method + ver := v2.HealthCheckHttpVersionHttp1Minor1 + if s.HTTPHealthCheck != nil && s.HTTPHealthCheck.HTTPVersion != nil { + ver = v2.HealthCheckHttpVersion(*s.HTTPHealthCheck.HTTPVersion) + } + mon.HttpVersion = &ver + // successCode is also required by the API; default to "200" when the + // user didn't supply ExpectedCodes. + if mon.SuccessCode == nil { + defaultCode := "200" + mon.SuccessCode = &defaultCode + } + // healthCheckPath is required too; default to "/". + if mon.HealthCheckPath == nil { + defaultPath := "/" + mon.HealthCheckPath = &defaultPath + } + } + if s.HTTPHealthCheck != nil { + if s.HTTPHealthCheck.Path != nil { + mon.HealthCheckPath = s.HTTPHealthCheck.Path + } + if s.HTTPHealthCheck.Host != nil { + mon.DomainName = s.HTTPHealthCheck.Host + } + if len(s.HTTPHealthCheck.ExpectedCodes) > 0 { + // LBC stores SuccessCode as a single string. Join with commas to + // preserve any multi-value expression the user wrote (e.g. "200-299,301"). + joined := joinExpectedCodes(s.HTTPHealthCheck.ExpectedCodes) + mon.SuccessCode = &joined + } + } + pool.HealthMonitor = mon + // Port overrides the monitor port on every member (protocol-agnostic). + // Matches the Ingress `healthcheck-port` annotation, which sets each + // member's MonitorPort regardless of instance/ip target type. + if s.Port != nil { + for i := range pool.Members { + pool.Members[i].MonitorPort = int(*s.Port) + } + } + return nil +} + +// resolveTargetType returns the effective member-resolution mode for a +// Service: VKSBackendPolicy.TargetType when set, else domain.TargetTypeInstance +// (matches the Ingress controller's default — works on overlay CNIs where +// pod IPs aren't routable from the cloud LB; user opts into "ip" mode if +// pods are directly routable). +func (t *defaultGatewayBuildTask) resolveTargetType(ctx context.Context, ns, svcName string) (domain.TargetType, error) { + bp, err := t.resolveBackendPolicy(ctx, ns, svcName) + if err != nil { + return "", err + } + if bp != nil && bp.Spec.TargetType != nil { + switch *bp.Spec.TargetType { + case string(domain.TargetTypeIP): + return domain.TargetTypeIP, nil + case string(domain.TargetTypeInstance): + return domain.TargetTypeInstance, nil + } + } + return domain.TargetTypeInstance, nil +} + +// resolveTargetNodeLabels returns the VKSBackendPolicy.TargetNodeLabels for +// the named Service. Empty map → "every node" (labels.Everything()) which +// is the only sensible default when no policy is attached. +func (t *defaultGatewayBuildTask) resolveTargetNodeLabels(ctx context.Context, ns, svcName string) (map[string]string, error) { + bp, err := t.resolveBackendPolicy(ctx, ns, svcName) + if err != nil { + return nil, err + } + if bp == nil { + return nil, nil + } + return bp.Spec.TargetNodeLabels, nil +} + +// ruleBackendPoliciesDiverge reports whether a rule's same-namespace backends +// resolve to different VKSBackendPolicy / VKSHealthCheckPolicy objects. A +// synthetic pool aggregates all of a rule's backends but can carry only one +// overlay, so divergence must fail the rule closed (rather than silently +// applying the first backend's policy). Single-backend rules never diverge. +func (t *defaultGatewayBuildTask) ruleBackendPoliciesDiverge(ctx context.Context, route *gwv1.HTTPRoute, rule gwv1.HTTPRouteRule) (bool, error) { + hcKeys := map[string]struct{}{} + bpKeys := map[string]struct{}{} + considered := 0 + for i := range rule.BackendRefs { + br := &rule.BackendRefs[i].BackendRef + ns := route.Namespace + if br.Namespace != nil { + ns = string(*br.Namespace) + } + if ns != route.Namespace { // cross-ns handled by ReferenceGrant; not compared here + continue + } + if br.Weight != nil && *br.Weight == 0 { + continue + } + hp, err := t.resolveHealthCheckPolicy(ctx, ns, string(br.Name)) + if err != nil { + return false, err + } + bp, err := t.resolveBackendPolicy(ctx, ns, string(br.Name)) + if err != nil { + return false, err + } + hcKey := "" + if hp != nil { + hcKey = hp.Namespace + "/" + hp.Name + } + bpKey := "" + if bp != nil { + bpKey = bp.Namespace + "/" + bp.Name + } + hcKeys[hcKey] = struct{}{} + bpKeys[bpKey] = struct{}{} + considered++ + } + if considered < 2 { + return false, nil + } + return len(hcKeys) > 1 || len(bpKeys) > 1, nil +} + +func (t *defaultGatewayBuildTask) resolveBackendPolicy(ctx context.Context, ns, svcName string) (*gwv1alpha1.VKSBackendPolicy, error) { + var list gwv1alpha1.VKSBackendPolicyList + if err := t.uc.k8sClient.List(ctx, &list, client.InNamespace(ns)); err != nil { + return nil, fmt.Errorf("list VKSBackendPolicy in %s: %w", ns, err) + } + cands := make([]*gwv1alpha1.VKSBackendPolicy, 0, len(list.Items)) + for i := range list.Items { + cands = append(cands, &list.Items[i]) + } + target := pkggw.PolicyTarget{Group: "", Kind: "Service", Namespace: ns, Name: svcName} + win, _ := sharedUC.ResolveDirectPolicy(cands, target) + return win, nil +} + +func (t *defaultGatewayBuildTask) resolveHealthCheckPolicy(ctx context.Context, ns, svcName string) (*gwv1alpha1.VKSHealthCheckPolicy, error) { + var list gwv1alpha1.VKSHealthCheckPolicyList + if err := t.uc.k8sClient.List(ctx, &list, client.InNamespace(ns)); err != nil { + return nil, fmt.Errorf("list VKSHealthCheckPolicy in %s: %w", ns, err) + } + cands := make([]*gwv1alpha1.VKSHealthCheckPolicy, 0, len(list.Items)) + for i := range list.Items { + cands = append(cands, &list.Items[i]) + } + target := pkggw.PolicyTarget{Group: "", Kind: "Service", Namespace: ns, Name: svcName} + win, _ := sharedUC.ResolveDirectPolicy(cands, target) + return win, nil +} + +func ptrInt(v int) *int { return &v } + +func joinExpectedCodes(codes []string) string { + out := "" + for i, c := range codes { + if i > 0 { + out += "," + } + out += c + } + return out +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_pool_overlays_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_pool_overlays_test.go new file mode 100644 index 00000000..a6432aa2 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_pool_overlays_test.go @@ -0,0 +1,343 @@ +package alb_gateway_uc + +import ( + "context" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +func newTestTaskWithObjs(t *testing.T, gw *gwv1.Gateway, objs ...runtime.Object) *defaultGatewayBuildTask { + s := newTestScheme() + builder := fake.NewClientBuilder().WithScheme(s) + for _, o := range objs { + builder = builder.WithRuntimeObjects(o) + } + fakeClient := builder.Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + defaultZone: "HCM03-1C", + defaultNetworkId: "net-1", + defaultSubnetId: "subnet-1", + defaultSubnetCIDR: "10.0.0.0/24", + clusterId: "cluster-1", + } + return &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name), + } +} + +func makeBackendPolicy(ns, name, svcName string, alg *string, sticky *bool, tlsEnc *bool, targetType *string) *gwv1alpha1.VKSBackendPolicy { + bp := &gwv1alpha1.VKSBackendPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: gwv1alpha1.VKSBackendPolicySpec{ + TargetRefs: []gwv1alpha2.LocalPolicyTargetReference{{ + Group: "", + Kind: "Service", + Name: gwv1alpha2.ObjectName(svcName), + }}, + PoolAlgorithm: alg, + Stickiness: sticky, + EnableTLSEncryption: tlsEnc, + TargetType: targetType, + }, + } + return bp +} + +func TestJoinExpectedCodes(t *testing.T) { + assert.Equal(t, "", joinExpectedCodes(nil)) + assert.Equal(t, "200", joinExpectedCodes([]string{"200"})) + assert.Equal(t, "200,301,404", joinExpectedCodes([]string{"200", "301", "404"})) +} + +func TestApplyBackendPolicyToPool_NilPolicy(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + pool := &v1alpha1.Pool{Protocol: v2.PoolProtocolHTTP} + err := task.applyBackendPolicyToPool(context.Background(), pool, "prod", "svc-no-policy") + assert.NoError(t, err) + assert.Nil(t, pool.Algorithm) + assert.Nil(t, pool.Stickiness) + assert.Nil(t, pool.TLSEncryption) +} + +func TestApplyBackendPolicyToPool_WithPolicy(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + + alg := "ROUND_ROBIN" + sticky := true + tlsEnc := false + bp := makeBackendPolicy("prod", "bp-1", "my-svc", &alg, &sticky, &tlsEnc, nil) + + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(bp).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{uc: uc, gw: gw, logger: logrus.NewEntry(logrus.New()), listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name)} + + pool := &v1alpha1.Pool{Protocol: v2.PoolProtocolHTTP} + err := task.applyBackendPolicyToPool(context.Background(), pool, "prod", "my-svc") + assert.NoError(t, err) + assert.NotNil(t, pool.Algorithm) + assert.Equal(t, v2.PoolAlgorithm("ROUND_ROBIN"), *pool.Algorithm) + assert.NotNil(t, pool.Stickiness) + assert.True(t, *pool.Stickiness) + assert.NotNil(t, pool.TLSEncryption) + assert.False(t, *pool.TLSEncryption) +} + +func TestResolveTargetType(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + + t.Run("no policy defaults to instance", func(t *testing.T) { + task := newTestTask(t, gw) + tt, err := task.resolveTargetType(context.Background(), "prod", "svc") + assert.NoError(t, err) + assert.Equal(t, domain.TargetTypeInstance, tt) + }) + + t.Run("policy with ip target type", func(t *testing.T) { + ipType := string(domain.TargetTypeIP) + bp := makeBackendPolicy("prod", "bp", "svc-ip", nil, nil, nil, &ipType) + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(bp).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{uc: uc, gw: gw, logger: logrus.NewEntry(logrus.New()), listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name)} + tt, err := task.resolveTargetType(context.Background(), "prod", "svc-ip") + assert.NoError(t, err) + assert.Equal(t, domain.TargetTypeIP, tt) + }) + + t.Run("policy with instance target type", func(t *testing.T) { + instType := string(domain.TargetTypeInstance) + bp := makeBackendPolicy("prod", "bp", "svc-inst", nil, nil, nil, &instType) + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(bp).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{uc: uc, gw: gw, logger: logrus.NewEntry(logrus.New()), listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name)} + tt, err := task.resolveTargetType(context.Background(), "prod", "svc-inst") + assert.NoError(t, err) + assert.Equal(t, domain.TargetTypeInstance, tt) + }) + + t.Run("policy with invalid target type falls back to instance", func(t *testing.T) { + invalid := "invalid-type" + bp := makeBackendPolicy("prod", "bp", "svc-bad", nil, nil, nil, &invalid) + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(bp).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{uc: uc, gw: gw, logger: logrus.NewEntry(logrus.New()), listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name)} + tt, err := task.resolveTargetType(context.Background(), "prod", "svc-bad") + assert.NoError(t, err) + assert.Equal(t, domain.TargetTypeInstance, tt) + }) +} + +func TestApplyHealthCheckPolicyToPool_NilPolicy(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + pool := &v1alpha1.Pool{ + HealthMonitor: v1alpha1.PoolHealthMonitor{Protocol: v2.HealthCheckProtocolTCP}, + } + err := task.applyHealthCheckPolicyToPool(context.Background(), pool, "prod", "svc-no-hc") + assert.NoError(t, err) + assert.Equal(t, v2.HealthCheckProtocolTCP, pool.HealthMonitor.Protocol) +} + +func TestApplyHealthCheckPolicyToPool_WithPolicy(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + + path := "/health" + host := "app.example.com" + interval := metav1.Duration{Duration: 10 * time.Second} + timeout := metav1.Duration{Duration: 5 * time.Second} + hThresh := int32(3) + uThresh := int32(3) + hp := &gwv1alpha1.VKSHealthCheckPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "hcp"}, + Spec: gwv1alpha1.VKSHealthCheckPolicySpec{ + TargetRefs: []gwv1alpha2.LocalPolicyTargetReference{{Group: "", Kind: "Service", Name: "svc-hc"}}, + Protocol: "HTTP", + Interval: &interval, + Timeout: &timeout, + HealthyThreshold: &hThresh, + UnhealthyThreshold: &uThresh, + HTTPHealthCheck: &gwv1alpha1.VKSHTTPHealthCheck{ + Path: &path, + Host: &host, + ExpectedCodes: []string{"200", "201"}, + }, + }, + } + + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(hp).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{uc: uc, gw: gw, logger: logrus.NewEntry(logrus.New()), listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name)} + + pool := &v1alpha1.Pool{HealthMonitor: v1alpha1.PoolHealthMonitor{Protocol: v2.HealthCheckProtocolTCP}} + err := task.applyHealthCheckPolicyToPool(context.Background(), pool, "prod", "svc-hc") + assert.NoError(t, err) + assert.Equal(t, v2.HealthCheckProtocol("HTTP"), pool.HealthMonitor.Protocol) + assert.NotNil(t, pool.HealthMonitor.Interval) + assert.Equal(t, 10, *pool.HealthMonitor.Interval) + assert.NotNil(t, pool.HealthMonitor.Timeout) + assert.Equal(t, 5, *pool.HealthMonitor.Timeout) + assert.NotNil(t, pool.HealthMonitor.HealthCheckPath) + assert.Equal(t, "/health", *pool.HealthMonitor.HealthCheckPath) + assert.NotNil(t, pool.HealthMonitor.SuccessCode) + assert.Equal(t, "200,201", *pool.HealthMonitor.SuccessCode) + assert.NotNil(t, pool.HealthMonitor.HealthyThreshold) + assert.Equal(t, 3, *pool.HealthMonitor.HealthyThreshold) +} + +func makeHCPolicy(ns, name, svcName, protocol string) *gwv1alpha1.VKSHealthCheckPolicy { + return &gwv1alpha1.VKSHealthCheckPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: gwv1alpha1.VKSHealthCheckPolicySpec{ + TargetRefs: []gwv1alpha2.LocalPolicyTargetReference{{Group: "", Kind: "Service", Name: gwv1alpha2.ObjectName(svcName)}}, + Protocol: protocol, + }, + } +} + +func TestApplyHealthCheckPolicyToPool_HTTPMethodVersionDefault(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + hp := makeHCPolicy("prod", "hcp", "svc-hc", "HTTP") + task := newTestTaskWithObjs(t, gw, hp) + + pool := &v1alpha1.Pool{HealthMonitor: v1alpha1.PoolHealthMonitor{Protocol: v2.HealthCheckProtocolTCP}} + err := task.applyHealthCheckPolicyToPool(context.Background(), pool, "prod", "svc-hc") + assert.NoError(t, err) + assert.NotNil(t, pool.HealthMonitor.HealthCheckMethod) + assert.Equal(t, v2.HealthCheckMethodGET, *pool.HealthMonitor.HealthCheckMethod) + assert.NotNil(t, pool.HealthMonitor.HttpVersion) + assert.Equal(t, v2.HealthCheckHttpVersionHttp1Minor1, *pool.HealthMonitor.HttpVersion) +} + +func TestApplyHealthCheckPolicyToPool_HTTPMethodVersionOverride(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + hp := makeHCPolicy("prod", "hcp", "svc-hc", "HTTP") + method := "POST" + version := "1.0" + hp.Spec.HTTPHealthCheck = &gwv1alpha1.VKSHTTPHealthCheck{Method: &method, HTTPVersion: &version} + task := newTestTaskWithObjs(t, gw, hp) + + pool := &v1alpha1.Pool{HealthMonitor: v1alpha1.PoolHealthMonitor{Protocol: v2.HealthCheckProtocolTCP}} + err := task.applyHealthCheckPolicyToPool(context.Background(), pool, "prod", "svc-hc") + assert.NoError(t, err) + assert.NotNil(t, pool.HealthMonitor.HealthCheckMethod) + assert.Equal(t, v2.HealthCheckMethodPOST, *pool.HealthMonitor.HealthCheckMethod) + assert.NotNil(t, pool.HealthMonitor.HttpVersion) + assert.Equal(t, v2.HealthCheckHttpVersionHttp1, *pool.HealthMonitor.HttpVersion) +} + +func TestApplyHealthCheckPolicyToPool_TCPIgnoresHTTPMethodVersion(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + hp := makeHCPolicy("prod", "hcp", "svc-hc", "TCP") + method := "POST" + version := "1.0" + hp.Spec.HTTPHealthCheck = &gwv1alpha1.VKSHTTPHealthCheck{Method: &method, HTTPVersion: &version} + task := newTestTaskWithObjs(t, gw, hp) + + pool := &v1alpha1.Pool{HealthMonitor: v1alpha1.PoolHealthMonitor{Protocol: v2.HealthCheckProtocolTCP}} + err := task.applyHealthCheckPolicyToPool(context.Background(), pool, "prod", "svc-hc") + assert.NoError(t, err) + assert.Equal(t, v2.HealthCheckProtocolTCP, pool.HealthMonitor.Protocol) + assert.Nil(t, pool.HealthMonitor.HealthCheckMethod) + assert.Nil(t, pool.HealthMonitor.HttpVersion) +} + +func TestApplyHealthCheckPolicyToPool_PortOverridesMonitorPort(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + hp := makeHCPolicy("prod", "hcp", "svc-hc", "TCP") + port := int32(8080) + hp.Spec.Port = &port + task := newTestTaskWithObjs(t, gw, hp) + + pool := &v1alpha1.Pool{ + HealthMonitor: v1alpha1.PoolHealthMonitor{Protocol: v2.HealthCheckProtocolTCP}, + Members: []v1alpha1.PoolMember{ + {Name: "a", IP: "10.0.0.1", Port: 30001, MonitorPort: 30001}, + {Name: "b", IP: "10.0.0.2", Port: 30002, MonitorPort: 30002}, + }, + } + err := task.applyHealthCheckPolicyToPool(context.Background(), pool, "prod", "svc-hc") + assert.NoError(t, err) + assert.Equal(t, 8080, pool.Members[0].MonitorPort) + assert.Equal(t, 8080, pool.Members[1].MonitorPort) + // Traffic ports untouched. + assert.Equal(t, 30001, pool.Members[0].Port) + assert.Equal(t, 30002, pool.Members[1].Port) +} + +func TestApplyHealthCheckPolicyToPool_NoPortLeavesMonitorPort(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + hp := makeHCPolicy("prod", "hcp", "svc-hc", "TCP") + task := newTestTaskWithObjs(t, gw, hp) + + pool := &v1alpha1.Pool{ + HealthMonitor: v1alpha1.PoolHealthMonitor{Protocol: v2.HealthCheckProtocolTCP}, + Members: []v1alpha1.PoolMember{{Name: "a", IP: "10.0.0.1", Port: 30001, MonitorPort: 30001}}, + } + err := task.applyHealthCheckPolicyToPool(context.Background(), pool, "prod", "svc-hc") + assert.NoError(t, err) + assert.Equal(t, 30001, pool.Members[0].MonitorPort) +} + +func TestResolveTargetNodeLabels(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + + t.Run("no policy returns nil", func(t *testing.T) { + task := newTestTask(t, gw) + labels, err := task.resolveTargetNodeLabels(context.Background(), "prod", "svc") + assert.NoError(t, err) + assert.Nil(t, labels) + }) + + t.Run("policy with node labels returns them", func(t *testing.T) { + bp := makeBackendPolicy("prod", "bp", "svc-with-labels", nil, nil, nil, nil) + bp.Spec.TargetNodeLabels = map[string]string{"node-role": "worker"} + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(bp).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{uc: uc, gw: gw, logger: logrus.NewEntry(logrus.New()), listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name)} + labels, err := task.resolveTargetNodeLabels(context.Background(), "prod", "svc-with-labels") + assert.NoError(t, err) + assert.Equal(t, map[string]string{"node-role": "worker"}, labels) + }) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_pools.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_pools.go new file mode 100644 index 00000000..5aeb400c --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_pools.go @@ -0,0 +1,169 @@ +package alb_gateway_uc + +import ( + "context" + "fmt" + "sort" + + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +// synthesizePool builds one v1alpha1.Pool from one HTTPRoute rule. Members +// resolve to pod IPs ("ip" target type) or node IPs + nodePort ("instance" +// target type) per the backend's VKSBackendPolicy.TargetType, defaulting to +// "instance" — matches the Ingress controller's default and works on overlay +// CNIs where pod IPs aren't routable from the cloud LB. Weights are computed +// via pkggw.ScaleWeights so that a 1:99 traffic split with an uneven +// readyEndpoints count still lands in the API's accepted [1..100] range. +// Pool name is deterministic via pkggw.SynthPoolName. +// +// Same-namespace backendRefs only. Cross-namespace via ReferenceGrant lands +// in a separate follow-up. +func (t *defaultGatewayBuildTask) synthesizePool(ctx context.Context, route *gwv1.HTTPRoute, ruleIdx int, rule gwv1.HTTPRouteRule) (*v1alpha1.Pool, error) { + if len(rule.BackendRefs) == 0 { + return nil, fmt.Errorf("rule %d on HTTPRoute %s/%s has no backendRefs", ruleIdx, route.Namespace, route.Name) + } + + keys := make([]pkggw.BackendKey, 0, len(rule.BackendRefs)) + weights := make([]pkggw.BackendWeight, 0, len(rule.BackendRefs)) + memberAddrs := make([][]utils.EndpointAddress, 0, len(rule.BackendRefs)) + + for i := range rule.BackendRefs { + br := &rule.BackendRefs[i].BackendRef + ns := route.Namespace + if br.Namespace != nil { + ns = string(*br.Namespace) + } + if ns != route.Namespace { + // Cross-namespace backendRef: include it only when a ReferenceGrant + // in the backend's namespace permits HTTPRoutes from the route's + // namespace. Otherwise drop it (the route's ResolvedRefs status + // reports RefNotPermitted). + allowed, err := sharedUC.RefGrantAllowed(ctx, t.uc.k8sClient, + sharedUC.Ref{Group: "", Kind: "Service", Namespace: ns, Name: string(br.Name)}, + sharedUC.Ref{Group: gwv1.GroupName, Kind: "HTTPRoute", Namespace: route.Namespace, Name: route.Name}) + if err != nil { + return nil, fmt.Errorf("check ReferenceGrant for backendRef %s/%s: %w", ns, br.Name, err) + } + if !allowed { + t.logger.Warnf("backendRef %s/%s on HTTPRoute %s/%s is cross-namespace and not permitted by any ReferenceGrant; skipping", + ns, br.Name, route.Namespace, route.Name) + continue + } + } + port := int32(0) + if br.Port != nil { + port = int32(*br.Port) + } + weight := int32(1) + if br.Weight != nil { + weight = *br.Weight + } + // Skip explicit-zero-weight backends (Gateway-API spec convention for + // "kept in spec but no traffic"). + if weight == 0 { + continue + } + + targetType, err := t.resolveTargetType(ctx, ns, string(br.Name)) + if err != nil { + return nil, err + } + nodeLabels, err := t.resolveTargetNodeLabels(ctx, ns, string(br.Name)) + if err != nil { + return nil, err + } + svcKey := types.NamespacedName{Namespace: ns, Name: string(br.Name)} + resolveOpts := []utils.EndpointResolveOption{ + utils.WithNodeSelector(labels.SelectorFromSet(labels.Set(nodeLabels))), + } + var addrs []utils.EndpointAddress + switch targetType { + case domain.TargetTypeInstance: + addrs, err = t.uc.endpointResolver.ResolveNodePortEndpoints(ctx, svcKey, intstr.FromInt(int(port)), resolveOpts...) + default: // domain.TargetTypeIP + addrs, err = t.uc.endpointResolver.ResolvePodEndpoints(ctx, svcKey, intstr.FromInt(int(port)), resolveOpts...) + } + if err != nil { + return nil, fmt.Errorf("resolve endpoints for backendRef %s/%s (mode=%s): %w", ns, br.Name, targetType, err) + } + + ready := int32(len(addrs)) + if ready == 0 { + // 0 ready endpoints would set ScaleWeights's denominator to 1 + // (its own internal floor); leaving it at 0 here is fine and lets + // the pool still be created, members come back when pods become + // Ready. Avoids reconcile churn during rollouts. + ready = 0 + } + keys = append(keys, pkggw.BackendKey{Namespace: ns, Name: string(br.Name), Port: port, Weight: weight}) + weights = append(weights, pkggw.BackendWeight{Weight: weight, Ready: ready}) + memberAddrs = append(memberAddrs, addrs) + } + + if len(keys) == 0 { + return nil, fmt.Errorf("rule %d on HTTPRoute %s/%s: all backends were dropped (cross-ns or zero-weight)", + ruleIdx, route.Namespace, route.Name) + } + + scaled := pkggw.ScaleWeights(weights) + + // Build Members. Each backend's resolved addresses share the backend's + // scaled weight. Ports come from the resolved EndpointAddress (covers + // named-port resolution by EndpointResolver). Member names get the + // project-wide "vks_" prefix so every cloud-side resource is greppable; + // truncated to 50 chars to satisfy the cloud's name limit. + members := make([]v1alpha1.PoolMember, 0) + for i, addrs := range memberAddrs { + w := int(scaled[i]) + for _, a := range addrs { + members = append(members, v1alpha1.PoolMember{ + Name: memberName(a.Name), + IP: a.IP, + Port: a.Port, + MonitorPort: a.Port, + Weight: &w, + }) + } + } + // Stable order so DeepEqual on Spec is meaningful between reconciles. + sort.SliceStable(members, func(i, j int) bool { + if members[i].IP != members[j].IP { + return members[i].IP < members[j].IP + } + return members[i].Port < members[j].Port + }) + + pool := &v1alpha1.Pool{ + Name: pkggw.SynthPoolName(string(route.UID), ruleIdx, keys), + Protocol: v2.PoolProtocolHTTP, + Members: members, + HealthMonitor: v1alpha1.PoolHealthMonitor{ + Protocol: v2.HealthCheckProtocolTCP, + }, + } + + // Apply VKSBackendPolicy / VKSHealthCheckPolicy overlays for the first + // backend. When a rule has multiple backendRefs targeting different + // Services, this picks the policy attached to the first one — Phase F's + // status work will surface a "conflicting per-backend policies" warning + // when other backendRefs would have produced a different overlay. + first := keys[0] + if err := t.applyBackendPolicyToPool(ctx, pool, first.Namespace, first.Name); err != nil { + return nil, err + } + if err := t.applyHealthCheckPolicyToPool(ctx, pool, first.Namespace, first.Name); err != nil { + return nil, err + } + return pool, nil +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_pools_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_pools_test.go new file mode 100644 index 00000000..12f3589d --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_pools_test.go @@ -0,0 +1,374 @@ +package alb_gateway_uc + +import ( + "context" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +// newTaskWithEndpointResolver builds a task where uc.endpointResolver is set +// to the supplied mock, and the fake client has the HTTPRoute field index. +func newTaskWithEndpointResolver( + t *testing.T, + gw *gwv1.Gateway, + epResolver *utils.MockEndpointResolver, + objs ...interface{}, +) *defaultGatewayBuildTask { + s := newTestScheme() + builder := newFakeClientWithHTTPRouteIndex(gw) + _ = objs // extra objects not needed for pool tests — routes are added to the index separately + + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: builder, + endpointResolver: epResolver, + clusterId: "cluster-1", + defaultZone: "HCM03-1C", + defaultNetworkId: "net-1", + defaultSubnetId: "subnet-1", + } + _ = s + return &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name), + } +} + +// TestSynthesizePool_NoBackendRefs tests that a rule with no backendRefs returns error. +func TestSynthesizePool_NoBackendRefs(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + route := &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r", UID: "uid-1"}} + rule := gwv1.HTTPRouteRule{} + _, err := task.synthesizePool(context.Background(), route, 0, rule) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no backendRefs") +} + +// TestSynthesizePool_AllZeroWeightDropped tests that zero-weight backends are all dropped. +func TestSynthesizePool_AllZeroWeightDropped(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + route := &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r", UID: "uid-1"}} + zeroWeight := int32(0) + port := gwv1.PortNumber(80) + rule := gwv1.HTTPRouteRule{ + BackendRefs: []gwv1.HTTPBackendRef{{ + BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{Name: "svc", Port: &port}, + Weight: &zeroWeight, + }, + }}, + } + _, err := task.synthesizePool(context.Background(), route, 0, rule) + assert.Error(t, err) + assert.Contains(t, err.Error(), "all backends were dropped") +} + +// TestSynthesizePool_HappyPath_InstanceMode tests the instance-mode pool synthesis +// where the backend gets resolved via ResolveNodePortEndpoints. +func TestSynthesizePool_HappyPath_InstanceMode(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + mockEP := utils.NewMockEndpointResolver(t) + + // No VKSBackendPolicy or VKSHealthCheckPolicy present for "svc1" → resolveTargetType + // defaults to instance, resolveTargetNodeLabels returns nil. Then ResolveNodePortEndpoints. + mockEP.EXPECT(). + ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(80), mock.Anything). + Return([]utils.EndpointAddress{ + {IP: "10.0.0.1", Port: 30080, Name: "node-1"}, + {IP: "10.0.0.2", Port: 30080, Name: "node-2"}, + }, nil). + Once() + + task := newTaskWithEndpointResolver(t, gw, mockEP) + route := &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r", UID: "uid-42"}} + port := gwv1.PortNumber(80) + rule := gwv1.HTTPRouteRule{ + BackendRefs: []gwv1.HTTPBackendRef{{ + BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{Name: "svc1", Port: &port}, + }, + }}, + } + + pool, err := task.synthesizePool(context.Background(), route, 0, rule) + assert.NoError(t, err) + assert.NotNil(t, pool) + assert.Len(t, pool.Members, 2) + assert.Equal(t, "10.0.0.1", pool.Members[0].IP) +} + +// resolveTargetNodeLabels must reach the resolver: synthesizePool turns +// VKSBackendPolicy.TargetNodeLabels into a node selector passed to +// ResolveNodePortEndpoints (instance mode). Mirrors the NLB wiring test. +func TestSynthesizePool_TargetNodeLabelsBecomeSelector(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + bp := makeBackendPolicy("prod", "bp", "svc1", nil, nil, nil, nil) + bp.Spec.TargetNodeLabels = map[string]string{"role": "lb", "zone": "a"} + + var captured utils.EndpointResolveOptions + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT(). + ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(80), mock.Anything). + Run(func(_ context.Context, _ types.NamespacedName, _ intstr.IntOrString, opts ...utils.EndpointResolveOption) { + captured.ApplyOptions(opts) + }). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 30080, Name: "node-1"}}, nil).Once() + + uc := &albGatewayUseCase{ + k8sRepo: repository.NewMockK8sRepository(t), + vngcloudRepo: repository.NewMockVngCloudRepository(t), + k8sClient: fake.NewClientBuilder().WithScheme(newTestScheme()).WithRuntimeObjects(bp).Build(), + endpointResolver: mockEP, + clusterId: "cluster-1", + } + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name), + } + + port := gwv1.PortNumber(80) + route := &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r", UID: "uid-nl"}} + rule := gwv1.HTTPRouteRule{ + BackendRefs: []gwv1.HTTPBackendRef{{ + BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{Name: "svc1", Port: &port}, + }, + }}, + } + _, err := task.synthesizePool(context.Background(), route, 0, rule) + assert.NoError(t, err) + assert.NotNil(t, captured.NodeSelector) + // Selector ANDs the labels: a node must carry BOTH to be an LB member. + assert.True(t, captured.NodeSelector.Matches(labels.Set{"role": "lb", "zone": "a"})) + assert.False(t, captured.NodeSelector.Matches(labels.Set{"role": "lb"}), "partial match must be rejected") +} + +// TestSynthesizePool_CrossNamespaceSkipped tests that cross-namespace backends +// are skipped with a warning. +func TestSynthesizePool_CrossNamespaceSkipped(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + route := &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r", UID: "uid-1"}} + otherNs := gwv1.Namespace("other-ns") + port := gwv1.PortNumber(80) + rule := gwv1.HTTPRouteRule{ + BackendRefs: []gwv1.HTTPBackendRef{{ + BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: "svc-cross-ns", + Port: &port, + Namespace: &otherNs, + }, + }, + }}, + } + _, err := task.synthesizePool(context.Background(), route, 0, rule) + assert.Error(t, err) + assert.Contains(t, err.Error(), "all backends were dropped") +} + +// TestBuildPoolsAndPolicies_NoRoutes tests the empty-routes case. +func TestBuildPoolsAndPolicies_NoRoutes(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + // Build task using a fake client with the HTTPRoute index (no routes added). + s := newTestScheme() + _ = s + fakeClient := newFakeClientWithHTTPRouteIndex(gw) + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + } + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name), + } + + pools, policies, err := task.buildPoolsAndPolicies(context.Background()) + assert.NoError(t, err) + assert.Empty(t, pools) + assert.Empty(t, policies) +} + +// TestBuildPoolsAndPolicies_WithRoute tests the case where one HTTPRoute with +// one rule is attached to the gateway. +func TestBuildPoolsAndPolicies_WithRoute(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + // Build an HTTPRoute that references this gateway. + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "route-1", UID: "uid-r1"}, + } + port := gwv1.PortNumber(80) + route.Spec.ParentRefs = []gwv1.ParentReference{{Name: "gw"}} + route.Spec.Rules = []gwv1.HTTPRouteRule{{ + BackendRefs: []gwv1.HTTPBackendRef{{ + BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{Name: "svc1", Port: &port}, + }, + }}, + }} + + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT(). + ResolveNodePortEndpoints(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 30080, Name: "node-1"}}, nil). + Once() + + // Build the indexed fake client with the route. + s := newTestScheme() + _ = s + fakeClient := newFakeClientWithHTTPRouteIndex(gw, route) + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + endpointResolver: mockEP, + } + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name), + } + + pools, policies, err := task.buildPoolsAndPolicies(context.Background()) + assert.NoError(t, err) + assert.Len(t, pools, 1) + // One listener (http) accepted the route → at least one policy entry. + assert.NotEmpty(t, policies["http"]) +} + +// TestBuildPoolsAndPolicies_UnsupportedMatch tests that rules with header/method +// matches are skipped with a warning. +func TestBuildPoolsAndPolicies_UnsupportedMatch(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "route-bad", UID: "uid-bad"}, + } + route.Spec.ParentRefs = []gwv1.ParentReference{{Name: "gw"}} + method := gwv1.HTTPMethod("GET") + route.Spec.Rules = []gwv1.HTTPRouteRule{{ + Matches: []gwv1.HTTPRouteMatch{{Method: &method}}, + BackendRefs: []gwv1.HTTPBackendRef{{}}, + }} + + fakeClient := newFakeClientWithHTTPRouteIndex(gw, route) + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name), + } + + pools, policies, err := task.buildPoolsAndPolicies(context.Background()) + // Skipped with warning; no error, but no pools or policies produced. + assert.NoError(t, err) + assert.Empty(t, pools) + assert.Empty(t, policies) +} + +// TestListAttachedHTTPRoutes_WithAndWithoutIndex exercises the route filtering +// logic in listAttachedHTTPRoutes. Routes not referencing the gateway are filtered. +func TestListAttachedHTTPRoutes_WithRoutes(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + + // Route targeting this gateway in same namespace. + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r1"}, + } + route.Spec.ParentRefs = []gwv1.ParentReference{{Name: gwv1.ObjectName("gw")}} + + // Route in a different namespace — routeAcceptableNamespace should drop it + // since the listener has default (same-namespace) policy. + routeOtherNS := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "staging", Name: "r2"}, + } + routeOtherNS.Spec.ParentRefs = []gwv1.ParentReference{{Name: gwv1.ObjectName("gw")}} + + _ = shared.IndexHTTPRouteByParentGateway // ensure constant is accessible + fakeClient := newFakeClientWithHTTPRouteIndex(gw, route, routeOtherNS) + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name), + } + + routes, err := task.listAttachedHTTPRoutes(context.Background()) + assert.NoError(t, err) + // Only the same-namespace route should pass the namespace filter. + assert.Len(t, routes, 1) + assert.Equal(t, "r1", routes[0].Name) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_route_policy.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_route_policy.go new file mode 100644 index 00000000..78582fe6 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_route_policy.go @@ -0,0 +1,136 @@ +package alb_gateway_uc + +import ( + "context" + "fmt" + + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// applyRoutePolicyToPolicies overlays VKSRoutePolicy fields onto the policies +// generated for a single (route, rule) pair. Two scopes are checked: the +// rule's own sectionName, then the route as a whole (no sectionName); both +// overlays apply, with rule-scoped winning on per-field conflict. +// +// Phase 1 honors: +// - Actions[Reject|Redirect] → vngcloud Action override (REJECT / REDIRECT_TO_URL) +// - Position → policy position override +// +// FixedResponse and AdditionalMatches are not in the trimmed VKSRoutePolicy +// schema (vngcloud LB doesn't support them); see design spec §1.6. +func (t *defaultGatewayBuildTask) applyRoutePolicyToPolicies(ctx context.Context, policies []v1alpha1.Policy, route *gwv1.HTTPRoute, ruleName string) ([]v1alpha1.Policy, error) { + overlays, err := t.resolveRoutePolicies(ctx, route, ruleName) + if err != nil { + return policies, err + } + if len(overlays) == 0 { + return policies, nil + } + for i := range policies { + for _, p := range overlays { + applyOneRouteOverlay(&policies[i], p) + } + } + return policies, nil +} + +// applyRoutePolicyToRankedPolicies is the rankedPolicy-aware variant. Same +// overlay rules; preserves the rank metadata so the caller can sort + assign +// Position values afterwards. When a VKSRoutePolicy supplies Position, it's +// recorded on rankedPolicy.userPosition so sortAndAssignPositions honors +// the user's value verbatim instead of overwriting with the auto-assigned one. +func (t *defaultGatewayBuildTask) applyRoutePolicyToRankedPolicies(ctx context.Context, items []rankedPolicy, route *gwv1.HTTPRoute, ruleName string) ([]rankedPolicy, error) { + overlays, err := t.resolveRoutePolicies(ctx, route, ruleName) + if err != nil { + return items, err + } + if len(overlays) == 0 { + return items, nil + } + for i := range items { + for _, p := range overlays { + applyOneRouteOverlay(&items[i].policy, p) + if p.Spec.Position != nil { + v := *p.Spec.Position + items[i].userPosition = &v + } + } + } + return items, nil +} + +func applyOneRouteOverlay(p *v1alpha1.Policy, rp *gwv1alpha1.VKSRoutePolicy) { + if rp.Spec.Position != nil { + v := *rp.Spec.Position + p.Position = &v + } + if len(rp.Spec.Actions) == 0 { + return + } + // One action wins (the first one). VKSRoutePolicy.Actions[0] supersedes + // the rule's default REDIRECT_TO_POOL. + a := rp.Spec.Actions[0] + switch a.Type { + case "Reject": + p.Action = v2.PolicyActionREJECT + p.RedirectPoolName = nil + p.RedirectUrl = nil + p.RedirectHttpCode = nil + case "Redirect": + if a.Redirect == nil { + return + } + p.Action = v2.PolicyActionREDIRECTTOURL + p.RedirectPoolName = nil + p.RedirectUrl = ptr.To(a.Redirect.URL) + if a.Redirect.HTTPCode != nil { + code := *a.Redirect.HTTPCode + p.RedirectHttpCode = &code + } + if a.Redirect.KeepQueryString != nil { + v := *a.Redirect.KeepQueryString + p.KeepQueryString = &v + } + } +} + +// resolveRoutePolicies returns the (rule-scoped, route-scoped) winners for +// a given (route, rule). Order matters: rule-scoped is applied last so it +// overrides route-scoped on per-field conflicts. +func (t *defaultGatewayBuildTask) resolveRoutePolicies(ctx context.Context, route *gwv1.HTTPRoute, ruleName string) ([]*gwv1alpha1.VKSRoutePolicy, error) { + var list gwv1alpha1.VKSRoutePolicyList + if err := t.uc.k8sClient.List(ctx, &list, client.InNamespace(route.Namespace)); err != nil { + return nil, fmt.Errorf("list VKSRoutePolicy in %s: %w", route.Namespace, err) + } + cands := make([]*gwv1alpha1.VKSRoutePolicy, 0, len(list.Items)) + for i := range list.Items { + cands = append(cands, &list.Items[i]) + } + + out := make([]*gwv1alpha1.VKSRoutePolicy, 0, 2) + // Route-scoped (no sectionName) — applied first so rule-scoped wins. + if win, _ := sharedUC.ResolveDirectPolicy(cands, pkggw.PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", + Namespace: route.Namespace, Name: route.Name, + }); win != nil { + out = append(out, win) + } + // Rule-scoped (sectionName=ruleName). + if ruleName != "" { + if win, _ := sharedUC.ResolveDirectPolicy(cands, pkggw.PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", + Namespace: route.Namespace, Name: route.Name, SectionName: ruleName, + }); win != nil { + out = append(out, win) + } + } + return out, nil +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/build_route_policy_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/build_route_policy_test.go new file mode 100644 index 00000000..70501d8a --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/build_route_policy_test.go @@ -0,0 +1,184 @@ +package alb_gateway_uc + +import ( + "context" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +func makeRoutePolicy(ns, name, routeName string, ruleName string, actionType string, redirectURL string) *gwv1alpha1.VKSRoutePolicy { + rp := &gwv1alpha1.VKSRoutePolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: gwv1alpha1.VKSRoutePolicySpec{ + TargetRefs: []gwv1alpha2.LocalPolicyTargetReferenceWithSectionName{{ + LocalPolicyTargetReference: gwv1alpha2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "HTTPRoute", + Name: gwv1alpha2.ObjectName(routeName), + }, + }}, + }, + } + if ruleName != "" { + sn := gwv1alpha2.SectionName(ruleName) + rp.Spec.TargetRefs[0].SectionName = &sn + } + if actionType != "" { + action := gwv1alpha1.VKSRuleAction{Type: actionType} + if actionType == "Redirect" { + action.Redirect = &gwv1alpha1.VKSRedirectAction{URL: redirectURL} + } + rp.Spec.Actions = []gwv1alpha1.VKSRuleAction{action} + } + return rp +} + +func TestApplyOneRouteOverlay_Reject(t *testing.T) { + p := &v1alpha1.Policy{ + Action: v2.PolicyActionREDIRECTTOPOOL, + RedirectPoolName: ptr.To("pool-1"), + } + rp := &gwv1alpha1.VKSRoutePolicy{ + Spec: gwv1alpha1.VKSRoutePolicySpec{ + Actions: []gwv1alpha1.VKSRuleAction{{Type: "Reject"}}, + }, + } + applyOneRouteOverlay(p, rp) + assert.Equal(t, v2.PolicyActionREJECT, p.Action) + assert.Nil(t, p.RedirectPoolName) + assert.Nil(t, p.RedirectUrl) +} + +func TestApplyOneRouteOverlay_Redirect(t *testing.T) { + p := &v1alpha1.Policy{ + Action: v2.PolicyActionREDIRECTTOPOOL, + RedirectPoolName: ptr.To("pool-1"), + } + code := int32(301) + keepQS := true + rp := &gwv1alpha1.VKSRoutePolicy{ + Spec: gwv1alpha1.VKSRoutePolicySpec{ + Actions: []gwv1alpha1.VKSRuleAction{{ + Type: "Redirect", + Redirect: &gwv1alpha1.VKSRedirectAction{ + URL: "https://new.example.com", + HTTPCode: &code, + KeepQueryString: &keepQS, + }, + }}, + }, + } + applyOneRouteOverlay(p, rp) + assert.Equal(t, v2.PolicyActionREDIRECTTOURL, p.Action) + assert.Nil(t, p.RedirectPoolName) + assert.Equal(t, "https://new.example.com", *p.RedirectUrl) + assert.Equal(t, int32(301), *p.RedirectHttpCode) + assert.True(t, *p.KeepQueryString) +} + +func TestApplyOneRouteOverlay_RedirectNilAction(t *testing.T) { + p := &v1alpha1.Policy{Action: v2.PolicyActionREDIRECTTOPOOL} + rp := &gwv1alpha1.VKSRoutePolicy{ + Spec: gwv1alpha1.VKSRoutePolicySpec{ + Actions: []gwv1alpha1.VKSRuleAction{{Type: "Redirect", Redirect: nil}}, + }, + } + applyOneRouteOverlay(p, rp) + // nil Redirect → no change + assert.Equal(t, v2.PolicyActionREDIRECTTOPOOL, p.Action) +} + +func TestApplyOneRouteOverlay_Position(t *testing.T) { + p := &v1alpha1.Policy{} + pos := int32(5) + rp := &gwv1alpha1.VKSRoutePolicy{ + Spec: gwv1alpha1.VKSRoutePolicySpec{ + Position: &pos, + }, + } + applyOneRouteOverlay(p, rp) + assert.NotNil(t, p.Position) + assert.Equal(t, int32(5), *p.Position) +} + +func TestApplyOneRouteOverlay_NoActions(t *testing.T) { + p := &v1alpha1.Policy{Action: v2.PolicyActionREDIRECTTOPOOL} + rp := &gwv1alpha1.VKSRoutePolicy{Spec: gwv1alpha1.VKSRoutePolicySpec{}} + applyOneRouteOverlay(p, rp) + // No actions → no-op on action + assert.Equal(t, v2.PolicyActionREDIRECTTOPOOL, p.Action) +} + +func TestApplyRoutePolicyToPolicies_NoPolicies(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTestTask(t, gw) + + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "rt"}, + } + in := []v1alpha1.Policy{{Name: "p1", Action: v2.PolicyActionREDIRECTTOPOOL}} + out, err := task.applyRoutePolicyToPolicies(context.Background(), in, route, "") + assert.NoError(t, err) + assert.Equal(t, in, out) +} + +func TestApplyRoutePolicyToPolicies_WithRouteScopedPolicy(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + + rp := makeRoutePolicy("prod", "rp1", "my-route", "", "Reject", "") + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(rp).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{uc: uc, gw: gw, logger: logrus.NewEntry(logrus.New()), listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name)} + + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-route"}, + } + in := []v1alpha1.Policy{{Name: "p1", Action: v2.PolicyActionREDIRECTTOPOOL, RedirectPoolName: ptr.To("pool-1")}} + out, err := task.applyRoutePolicyToPolicies(context.Background(), in, route, "") + assert.NoError(t, err) + assert.Len(t, out, 1) + assert.Equal(t, v2.PolicyActionREJECT, out[0].Action) +} + +func TestApplyRoutePolicyToPolicies_RuleScopedWins(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + + // Route-scoped: Reject + rpRoute := makeRoutePolicy("prod", "rp-route", "my-route", "", "Reject", "") + // Rule-scoped: Redirect (should win) + rpRule := makeRoutePolicy("prod", "rp-rule", "my-route", "my-rule", "Redirect", "https://winner.com") + + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(rpRoute, rpRule).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + task := &defaultGatewayBuildTask{uc: uc, gw: gw, logger: logrus.NewEntry(logrus.New()), listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), nameHelper: utils.NewNameHelper("c1", "gateway", gw.Namespace, gw.Name)} + + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-route"}, + } + in := []v1alpha1.Policy{{Name: "p1", Action: v2.PolicyActionREDIRECTTOPOOL, RedirectPoolName: ptr.To("pool-1")}} + out, err := task.applyRoutePolicyToPolicies(context.Background(), in, route, "my-rule") + assert.NoError(t, err) + assert.Len(t, out, 1) + // Last overlay (rule-scoped Redirect) wins + assert.Equal(t, v2.PolicyActionREDIRECTTOURL, out[0].Action) + assert.Equal(t, "https://winner.com", *out[0].RedirectUrl) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/finalizer.go b/internal/usecase/gateway_uc/alb_gateway_uc/finalizer.go new file mode 100644 index 00000000..2b7c3485 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/finalizer.go @@ -0,0 +1,61 @@ +package alb_gateway_uc + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" +) + +func (uc *albGatewayUseCase) listNodesForNetworkProbe(ctx context.Context) ([]*corev1.Node, error) { + nodes := &corev1.NodeList{} + if err := uc.k8sRepo.ListNode(ctx, nodes); err != nil { + return nil, err + } + out := make([]*corev1.Node, 0, len(nodes.Items)) + for i := range nodes.Items { + out = append(out, &nodes.Items[i]) + } + return out, nil +} + +func (uc *albGatewayUseCase) ensureFinalizer(ctx context.Context, gw *gwv1.Gateway) error { + if err := uc.finalizerManager.AddFinalizers(ctx, gw, domain.GatewayFinalizer); err != nil { + return fmt.Errorf("add finalizer on Gateway %s/%s: %w", gw.Namespace, gw.Name, err) + } + return nil +} + +// handleDeletion removes the LBC owned by this Gateway and then drops the +// finalizer so the apiserver can complete the delete. The LBC controller's +// own finalizer ensures the cloud LB is gone before the LBC object is. +// +// RemoveFinalizers re-Gets the Gateway before patching and treats an +// already-deleted object as success, so a racing delete (the stale-UID +// StorageError we used to log) no longer surfaces as a reconcile error. +func (uc *albGatewayUseCase) handleDeletion(ctx context.Context, gw *gwv1.Gateway) error { + if err := uc.deleteOwnedLBC(ctx, gw); err != nil { + return err + } + if err := uc.finalizerManager.RemoveFinalizers(ctx, gw, domain.GatewayFinalizer); err != nil { + return fmt.Errorf("remove finalizer on Gateway %s/%s: %w", gw.Namespace, gw.Name, err) + } + return nil +} + +func (uc *albGatewayUseCase) deleteOwnedLBC(ctx context.Context, gw *gwv1.Gateway) error { + lbcs, err := uc.listOwnedLBCs(ctx, gw) + if err != nil { + return err + } + for i := range lbcs { + if err := uc.k8sRepo.DeleteLoadBalancerConfig(ctx, lbcs[i]); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("delete LBC %s/%s: %w", lbcs[i].Namespace, lbcs[i].Name, err) + } + } + return nil +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/finalizer_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/finalizer_test.go new file mode 100644 index 00000000..eac72aa7 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/finalizer_test.go @@ -0,0 +1,251 @@ +package alb_gateway_uc + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/k8s" +) + +func buildUCWithGW(t *testing.T, gw *gwv1.Gateway) (*albGatewayUseCase, *repository.MockK8sRepository) { + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + finalizerManager: k8s.NewDefaultFinalizerManager(fakeClient, logr.Discard()), + } + return uc, mockK8s +} + +func TestEnsureFinalizer_Idempotent(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw", + Finalizers: []string{domain.GatewayFinalizer}, + }, + } + uc, _ := buildUCWithGW(t, gw) + // Already has finalizer → no-op + err := uc.ensureFinalizer(context.Background(), gw) + assert.NoError(t, err) + assert.Contains(t, gw.Finalizers, domain.GatewayFinalizer) +} + +func TestEnsureFinalizer_AddsFinalizer(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw", + }, + } + uc, _ := buildUCWithGW(t, gw) + err := uc.ensureFinalizer(context.Background(), gw) + assert.NoError(t, err) + assert.Contains(t, gw.Finalizers, domain.GatewayFinalizer) +} + +func TestHandleDeletion_NoOwnedLBCs(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw", + Finalizers: []string{domain.GatewayFinalizer}, + DeletionTimestamp: &metav1.Time{Time: metav1.Now().Time}, + }, + } + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + // No owned LBCs + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + finalizerManager: k8s.NewDefaultFinalizerManager(fakeClient, logr.Discard()), + } + err := uc.handleDeletion(context.Background(), gw) + assert.NoError(t, err) + // Finalizer should be removed + assert.NotContains(t, gw.Finalizers, domain.GatewayFinalizer) +} + +func TestHandleDeletion_DeletesOwnedLBC(t *testing.T) { + gwUID := types.UID("test-uid-123") + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw", + UID: gwUID, + Finalizers: []string{domain.GatewayFinalizer}, + DeletionTimestamp: &metav1.Time{Time: metav1.Now().Time}, + }, + } + lbc := &v1alpha1.LoadBalancerConfig{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw-lbc", + Labels: map[string]string{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceUid: string(gwUID), + }, + }, + } + + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + // ListLoadBalancerConfig returns our LBC + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, list *v1alpha1.LoadBalancerConfigList, opts ...client.ListOption) error { + list.Items = []v1alpha1.LoadBalancerConfig{*lbc} + return nil + }) + + // DeleteLoadBalancerConfig is called for the LBC + mockK8s.EXPECT(). + DeleteLoadBalancerConfig(mock.Anything, mock.Anything). + Return(nil) + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + finalizerManager: k8s.NewDefaultFinalizerManager(fakeClient, logr.Discard()), + } + err := uc.handleDeletion(context.Background(), gw) + assert.NoError(t, err) +} + +func TestHandleDeletion_GatewayAlreadyGone(t *testing.T) { + // Reconciler races deletion: the Gateway no longer exists in the apiserver + // by the time we try to drop the finalizer, so RemoveFinalizers' re-Get + // returns NotFound. Cleanup is already complete — handleDeletion must not + // error/re-queue. + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "gone-gw", + Finalizers: []string{domain.GatewayFinalizer}, + DeletionTimestamp: &metav1.Time{Time: metav1.Now().Time}, + }, + } + s := newTestScheme() + // Build the client WITHOUT the Gateway → the re-Get returns NotFound. + fakeClient := fake.NewClientBuilder().WithScheme(s).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + finalizerManager: k8s.NewDefaultFinalizerManager(fakeClient, logr.Discard()), + } + err := uc.handleDeletion(context.Background(), gw) + assert.NoError(t, err) +} + +func TestDeleteOwnedLBC_AlreadyGone(t *testing.T) { + // deleteOwnedLBC should silently ignore IsNotFound errors. + gwUID := types.UID("uid-gone") + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw", UID: gwUID}, + } + lbc := &v1alpha1.LoadBalancerConfig{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "lbc-gone"}, + } + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, list *v1alpha1.LoadBalancerConfigList, _ ...client.ListOption) error { + list.Items = []v1alpha1.LoadBalancerConfig{*lbc} + return nil + }) + + // DeleteLoadBalancerConfig returns not-found — should be swallowed + mockK8s.EXPECT(). + DeleteLoadBalancerConfig(mock.Anything, mock.Anything). + Return(apierrors.NewNotFound(v1alpha1.GroupVersion.WithResource("loadbalancerconfigs").GroupResource(), "lbc-gone")) + + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + err := uc.deleteOwnedLBC(context.Background(), gw) + assert.NoError(t, err) +} + +func TestListOwnedLBCs(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw", + UID: "gw-uid-1", + }, + } + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + ownedLBC := &v1alpha1.LoadBalancerConfig{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "owned-lbc", + Labels: map[string]string{ + domain.LabelOwnerResourceKind: domain.OwnerKindGateway, + domain.LabelOwnerResourceUid: "gw-uid-1", + }, + }, + } + + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, list *v1alpha1.LoadBalancerConfigList, opts ...client.ListOption) error { + list.Items = []v1alpha1.LoadBalancerConfig{*ownedLBC} + return nil + }) + + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).Build() + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + } + + lbcs, err := uc.listOwnedLBCs(context.Background(), gw) + assert.NoError(t, err) + assert.Len(t, lbcs, 1) + assert.Equal(t, "owned-lbc", lbcs[0].Name) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/helpers.go b/internal/usecase/gateway_uc/alb_gateway_uc/helpers.go new file mode 100644 index 00000000..42d1dad5 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/helpers.go @@ -0,0 +1,93 @@ +package alb_gateway_uc + +import ( + "sort" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" +) + +// durationLike is a tiny wrapper so callers can express "is this set?" without +// re-deciding nil-handling at every call site. We wrap *metav1.Duration in +// build_listeners.go via wrapDur. +type durationLike struct { + Duration time.Duration +} + +func wrapDur(d *metav1.Duration) *durationLike { + if d == nil { + return nil + } + return &durationLike{Duration: d.Duration} +} + +// firstNonNilDuration walks policies in priority order and returns the first +// duration that any policy supplies. Nil policies in the slice are skipped. +func firstNonNilDuration(policies []*gwv1alpha1.VKSGatewayPolicy, get func(*gwv1alpha1.VKSGatewayPolicy) *durationLike) *durationLike { + for _, p := range policies { + if p == nil { + continue + } + if v := get(p); v != nil { + return v + } + } + return nil +} + +func firstNonNilString(policies []*gwv1alpha1.VKSGatewayPolicy, get func(*gwv1alpha1.VKSGatewayPolicy) *string) *string { + for _, p := range policies { + if p == nil { + continue + } + if v := get(p); v != nil { + return v + } + } + return nil +} + +func firstNonEmptyStringSlice(policies []*gwv1alpha1.VKSGatewayPolicy, get func(*gwv1alpha1.VKSGatewayPolicy) []string) []string { + for _, p := range policies { + if p == nil { + continue + } + if v := get(p); len(v) > 0 { + return v + } + } + return nil +} + +func firstNonEmptyStringMap(policies []*gwv1alpha1.VKSGatewayPolicy, get func(*gwv1alpha1.VKSGatewayPolicy) map[string]string) map[string]string { + for _, p := range policies { + if p == nil { + continue + } + if v := get(p); len(v) > 0 { + return v + } + } + return nil +} + +func ptr32(v int32) *int32 { return &v } + +func sortStrings(s []string) { sort.Strings(s) } + +// memberName returns the cloud-side pool-member name for a resolved endpoint. +// Adds the project-wide "vks_" prefix so every cloud-side resource the +// controller creates is greppable; truncates to the cloud's 50-char limit. +// Empty input falls back to the prefix alone (the cloud will reject it as +// too short, surfacing the unusual case rather than silently masking it). +func memberName(raw string) string { + const max = 50 + out := domain.VKSResourceNamePrefix + raw + if len(out) > max { + out = out[:max] + } + return out +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/helpers_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/helpers_test.go new file mode 100644 index 00000000..7f1b1980 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/helpers_test.go @@ -0,0 +1,149 @@ +package alb_gateway_uc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" +) + +func policyWithTimeout(clientSec, memberSec, connSec float64) *gwv1alpha1.VKSGatewayPolicy { + p := &gwv1alpha1.VKSGatewayPolicy{} + if clientSec >= 0 { + d := metav1.Duration{Duration: time.Duration(clientSec) * time.Second} + p.Spec.TimeoutClient = &d + } + if memberSec >= 0 { + d := metav1.Duration{Duration: time.Duration(memberSec) * time.Second} + p.Spec.TimeoutMember = &d + } + if connSec >= 0 { + d := metav1.Duration{Duration: time.Duration(connSec) * time.Second} + p.Spec.TimeoutConnection = &d + } + return p +} + +func TestWrapDur(t *testing.T) { + t.Run("nil returns nil", func(t *testing.T) { + assert.Nil(t, wrapDur(nil)) + }) + t.Run("non-nil wraps duration", func(t *testing.T) { + d := &metav1.Duration{Duration: 5 * time.Second} + got := wrapDur(d) + assert.NotNil(t, got) + assert.Equal(t, 5*time.Second, got.Duration) + }) +} + +func TestFirstNonNilDuration(t *testing.T) { + t.Run("empty slice returns nil", func(t *testing.T) { + got := firstNonNilDuration(nil, func(p *gwv1alpha1.VKSGatewayPolicy) *durationLike { + return wrapDur(p.Spec.TimeoutClient) + }) + assert.Nil(t, got) + }) + t.Run("all nil policies skipped", func(t *testing.T) { + got := firstNonNilDuration([]*gwv1alpha1.VKSGatewayPolicy{nil, nil}, func(p *gwv1alpha1.VKSGatewayPolicy) *durationLike { + return wrapDur(p.Spec.TimeoutClient) + }) + assert.Nil(t, got) + }) + t.Run("returns first non-nil value", func(t *testing.T) { + p1 := policyWithTimeout(10, -1, -1) + p2 := policyWithTimeout(20, -1, -1) + got := firstNonNilDuration([]*gwv1alpha1.VKSGatewayPolicy{nil, p1, p2}, func(p *gwv1alpha1.VKSGatewayPolicy) *durationLike { + return wrapDur(p.Spec.TimeoutClient) + }) + assert.NotNil(t, got) + assert.Equal(t, 10*time.Second, got.Duration) + }) + t.Run("skips policy with nil field, returns next", func(t *testing.T) { + noTimeout := &gwv1alpha1.VKSGatewayPolicy{} // TimeoutClient is nil + withTimeout := policyWithTimeout(30, -1, -1) + got := firstNonNilDuration([]*gwv1alpha1.VKSGatewayPolicy{noTimeout, withTimeout}, func(p *gwv1alpha1.VKSGatewayPolicy) *durationLike { + return wrapDur(p.Spec.TimeoutClient) + }) + assert.NotNil(t, got) + assert.Equal(t, 30*time.Second, got.Duration) + }) +} + +func TestFirstNonNilString(t *testing.T) { + str := func(s string) *string { return &s } + + t.Run("all nil returns nil", func(t *testing.T) { + p := &gwv1alpha1.VKSGatewayPolicy{} + got := firstNonNilString([]*gwv1alpha1.VKSGatewayPolicy{nil, p}, func(p *gwv1alpha1.VKSGatewayPolicy) *string { + return p.Spec.ClientCertificateID + }) + assert.Nil(t, got) + }) + t.Run("returns first non-nil", func(t *testing.T) { + p1 := &gwv1alpha1.VKSGatewayPolicy{} + s := "cert-1" + p1.Spec.ClientCertificateID = &s + p2 := &gwv1alpha1.VKSGatewayPolicy{} + p2.Spec.ClientCertificateID = str("cert-2") + got := firstNonNilString([]*gwv1alpha1.VKSGatewayPolicy{nil, p1, p2}, func(p *gwv1alpha1.VKSGatewayPolicy) *string { + return p.Spec.ClientCertificateID + }) + assert.Equal(t, "cert-1", *got) + }) +} + +func TestFirstNonEmptyStringSlice(t *testing.T) { + t.Run("all nil/empty returns nil", func(t *testing.T) { + p := &gwv1alpha1.VKSGatewayPolicy{} + got := firstNonEmptyStringSlice([]*gwv1alpha1.VKSGatewayPolicy{nil, p}, func(p *gwv1alpha1.VKSGatewayPolicy) []string { + return p.Spec.AllowedCIDRs + }) + assert.Nil(t, got) + }) + t.Run("returns first non-empty slice", func(t *testing.T) { + empty := &gwv1alpha1.VKSGatewayPolicy{} + p2 := &gwv1alpha1.VKSGatewayPolicy{} + p2.Spec.AllowedCIDRs = []string{"10.0.0.0/8", "192.168.0.0/16"} + p3 := &gwv1alpha1.VKSGatewayPolicy{} + p3.Spec.AllowedCIDRs = []string{"172.16.0.0/12"} + got := firstNonEmptyStringSlice([]*gwv1alpha1.VKSGatewayPolicy{nil, empty, p2, p3}, func(p *gwv1alpha1.VKSGatewayPolicy) []string { + return p.Spec.AllowedCIDRs + }) + assert.Equal(t, []string{"10.0.0.0/8", "192.168.0.0/16"}, got) + }) +} + +func TestFirstNonEmptyStringMap(t *testing.T) { + t.Run("all nil/empty returns nil", func(t *testing.T) { + p := &gwv1alpha1.VKSGatewayPolicy{} + got := firstNonEmptyStringMap([]*gwv1alpha1.VKSGatewayPolicy{nil, p}, func(p *gwv1alpha1.VKSGatewayPolicy) map[string]string { + return p.Spec.InsertHeaders + }) + assert.Nil(t, got) + }) + t.Run("returns first non-empty map", func(t *testing.T) { + empty := &gwv1alpha1.VKSGatewayPolicy{} + p2 := &gwv1alpha1.VKSGatewayPolicy{} + p2.Spec.InsertHeaders = map[string]string{"X-A": "1"} + p3 := &gwv1alpha1.VKSGatewayPolicy{} + p3.Spec.InsertHeaders = map[string]string{"X-B": "2"} + got := firstNonEmptyStringMap([]*gwv1alpha1.VKSGatewayPolicy{nil, empty, p2, p3}, func(p *gwv1alpha1.VKSGatewayPolicy) map[string]string { + return p.Spec.InsertHeaders + }) + assert.Equal(t, map[string]string{"X-A": "1"}, got) + }) +} + +func TestSortStrings(t *testing.T) { + s := []string{"c", "a", "b"} + sortStrings(s) + assert.Equal(t, []string{"a", "b", "c"}, s) +} + +func TestPtr32(t *testing.T) { + v := ptr32(42) + assert.Equal(t, int32(42), *v) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/list_routes.go b/internal/usecase/gateway_uc/alb_gateway_uc/list_routes.go new file mode 100644 index 00000000..fcb109bb --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/list_routes.go @@ -0,0 +1,180 @@ +package alb_gateway_uc + +import ( + "context" + "fmt" + "sort" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// listAttachedHTTPRoutes returns HTTPRoutes whose parentRefs reference the +// Gateway and whose namespace + hostname satisfy at least one listener's +// allowedRoutes. Caller still has to check per-listener attachment when +// generating policies. +func (t *defaultGatewayBuildTask) listAttachedHTTPRoutes(ctx context.Context) ([]gwv1.HTTPRoute, error) { + var list gwv1.HTTPRouteList + sel := fields.OneTermEqualSelector(shared.IndexHTTPRouteByParentGateway, + t.gw.Namespace+"/"+t.gw.Name) + if err := t.uc.k8sClient.List(ctx, &list, client.MatchingFieldsSelector{Selector: sel}); err != nil { + return nil, fmt.Errorf("list HTTPRoutes attached to %s/%s: %w", t.gw.Namespace, t.gw.Name, err) + } + out := make([]gwv1.HTTPRoute, 0, len(list.Items)) + for i := range list.Items { + nsLabels, err := t.namespaceLabels(ctx, list.Items[i].Namespace) + if err != nil { + return nil, err + } + if !routeAcceptableNamespace(&list.Items[i], t.gw, nsLabels) { + continue + } + out = append(out, list.Items[i]) + } + // Stable iteration order. controller-runtime cache returns List items in + // non-deterministic map order; without this, downstream stable-sort by + // match specificity inherits that order on ties, and Position assignments + // flip between reconciles → LBC.Spec churn → reorder loop. + sort.SliceStable(out, func(i, j int) bool { + if out[i].Namespace != out[j].Namespace { + return out[i].Namespace < out[j].Namespace + } + return out[i].Name < out[j].Name + }) + return out, nil +} + +// routeAcceptableNamespace runs the listener-level allowedRoutes.namespaces +// gate at the Gateway level — if no listener allows the route's namespace, the +// route is dropped entirely. Per-listener attachment (hostname / sectionName) +// is finer-grained and runs in policy generation. +// +// Honors NamespacesFromAll, NamespacesFromSame, and NamespacesFromSelector +// (the route's namespace labels, passed in, are matched against the listener's +// selector). +func routeAcceptableNamespace(r *gwv1.HTTPRoute, gw *gwv1.Gateway, routeNsLabels map[string]string) bool { + for i := range gw.Spec.Listeners { + l := &gw.Spec.Listeners[i] + if l.Protocol != gwv1.HTTPProtocolType && l.Protocol != gwv1.HTTPSProtocolType { + continue + } + if l.AllowedRoutes == nil || l.AllowedRoutes.Namespaces == nil || l.AllowedRoutes.Namespaces.From == nil { + // Default: same-namespace. + if r.Namespace == gw.Namespace { + return true + } + continue + } + switch *l.AllowedRoutes.Namespaces.From { + case gwv1.NamespacesFromAll: + return true + case gwv1.NamespacesFromSame: + if r.Namespace == gw.Namespace { + return true + } + case gwv1.NamespacesFromSelector: + if l.AllowedRoutes.Namespaces.Selector == nil { + continue + } + sel, err := metav1.LabelSelectorAsSelector(l.AllowedRoutes.Namespaces.Selector) + if err != nil { + continue + } + if sel.Matches(labels.Set(routeNsLabels)) { + return true + } + } + } + return false +} + +// namespaceLabels returns the labels on namespace ns (nil if it doesn't exist), +// used for NamespacesFromSelector matching. +func (t *defaultGatewayBuildTask) namespaceLabels(ctx context.Context, ns string) (map[string]string, error) { + var n corev1.Namespace + if err := t.uc.k8sClient.Get(ctx, types.NamespacedName{Name: ns}, &n); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return n.Labels, nil +} + +// listenerAcceptsRoute is the per-listener attachment check. It returns true +// if a listener's hostname constraint allows at least one of the route's +// hostnames (or both are empty), and the parentRef sectionName (if any) names +// this listener. +func listenerAcceptsRoute(l *gwv1.Listener, route *gwv1.HTTPRoute, parentRef *gwv1.ParentReference) bool { + if parentRef.SectionName != nil && string(*parentRef.SectionName) != string(l.Name) { + return false + } + if l.Hostname == nil || string(*l.Hostname) == "" { + return true + } + if len(route.Spec.Hostnames) == 0 { + return true + } + for _, h := range route.Spec.Hostnames { + if pkggw.HostnameMatches(string(*l.Hostname), string(h)) { + return true + } + } + return false +} + +// matchingRouteHostnames returns the route hostnames that match the listener. +// If the listener has no hostname, route hostnames are returned verbatim +// (empty slice means "accept any host" → caller emits one policy with +// no HOST_NAME rule). +func matchingRouteHostnames(l *gwv1.Listener, route *gwv1.HTTPRoute) []string { + if l.Hostname == nil || string(*l.Hostname) == "" { + out := make([]string, 0, len(route.Spec.Hostnames)) + for _, h := range route.Spec.Hostnames { + out = append(out, string(h)) + } + return out + } + listenerHost := string(*l.Hostname) + if len(route.Spec.Hostnames) == 0 { + return []string{listenerHost} + } + out := make([]string, 0, len(route.Spec.Hostnames)) + for _, h := range route.Spec.Hostnames { + if pkggw.HostnameMatches(listenerHost, string(h)) { + out = append(out, string(h)) + } + } + return out +} + +// parentRefsForGateway returns the ParentReference entries on a route that +// point at gw. Routes can have multiple parent gateways; we only emit +// policies for the parent matching this reconcile. +func parentRefsForGateway(route *gwv1.HTTPRoute, gw *gwv1.Gateway) []gwv1.ParentReference { + out := make([]gwv1.ParentReference, 0, len(route.Spec.ParentRefs)) + for i := range route.Spec.ParentRefs { + p := &route.Spec.ParentRefs[i] + if p.Kind != nil && *p.Kind != "Gateway" { + continue + } + ns := route.Namespace + if p.Namespace != nil { + ns = string(*p.Namespace) + } + if ns != gw.Namespace || string(p.Name) != gw.Name { + continue + } + out = append(out, *p) + } + return out +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/list_routes_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/list_routes_test.go new file mode 100644 index 00000000..fee9a835 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/list_routes_test.go @@ -0,0 +1,318 @@ +package alb_gateway_uc + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func makeGateway(ns, name string, protocols ...gwv1.ProtocolType) *gwv1.Gateway { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + } + for i, p := range protocols { + gw.Spec.Listeners = append(gw.Spec.Listeners, gwv1.Listener{ + Name: gwv1.SectionName("listener-" + string(rune('a'+i))), + Protocol: p, + Port: gwv1.PortNumber(80 + i), + }) + } + return gw +} + +func makeRoute(ns, name string, parentNs, parentName string, hostnames ...string) *gwv1.HTTPRoute { + parentNsTyped := gwv1.Namespace(parentNs) + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + } + route.Spec.ParentRefs = []gwv1.ParentReference{{ + Namespace: &parentNsTyped, + Name: gwv1.ObjectName(parentName), + }} + for _, h := range hostnames { + route.Spec.Hostnames = append(route.Spec.Hostnames, gwv1.Hostname(h)) + } + return route +} + +func TestRouteAcceptableNamespace(t *testing.T) { + gw := makeGateway("prod", "my-gw", gwv1.HTTPProtocolType) + + t.Run("same namespace accepted by default", func(t *testing.T) { + route := makeRoute("prod", "r", "prod", "my-gw") + assert.True(t, routeAcceptableNamespace(route, gw, nil)) + }) + t.Run("different namespace rejected by default", func(t *testing.T) { + route := makeRoute("staging", "r", "prod", "my-gw") + assert.False(t, routeAcceptableNamespace(route, gw, nil)) + }) + t.Run("NamespacesFromAll accepts any namespace", func(t *testing.T) { + gwAll := makeGateway("prod", "my-gw", gwv1.HTTPProtocolType) + from := gwv1.NamespacesFromAll + gwAll.Spec.Listeners[0].AllowedRoutes = &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{From: &from}, + } + route := makeRoute("other-ns", "r", "prod", "my-gw") + assert.True(t, routeAcceptableNamespace(route, gwAll, nil)) + }) + t.Run("NamespacesFromSame rejects other namespace", func(t *testing.T) { + gwSame := makeGateway("prod", "my-gw", gwv1.HTTPProtocolType) + from := gwv1.NamespacesFromSame + gwSame.Spec.Listeners[0].AllowedRoutes = &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{From: &from}, + } + route := makeRoute("other-ns", "r", "prod", "my-gw") + assert.False(t, routeAcceptableNamespace(route, gwSame, nil)) + }) + t.Run("NamespacesFromSame accepts same namespace", func(t *testing.T) { + gwSame := makeGateway("prod", "my-gw", gwv1.HTTPProtocolType) + from := gwv1.NamespacesFromSame + gwSame.Spec.Listeners[0].AllowedRoutes = &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{From: &from}, + } + route := makeRoute("prod", "r", "prod", "my-gw") + assert.True(t, routeAcceptableNamespace(route, gwSame, nil)) + }) + t.Run("NamespacesFromSelector matches the route namespace labels", func(t *testing.T) { + gwSel := makeGateway("prod", "my-gw", gwv1.HTTPProtocolType) + from := gwv1.NamespacesFromSelector + gwSel.Spec.Listeners[0].AllowedRoutes = &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{ + From: &from, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "blue"}}, + }, + } + route := makeRoute("any-ns", "r", "prod", "my-gw") + assert.True(t, routeAcceptableNamespace(route, gwSel, map[string]string{"team": "blue"}), "matching labels accepted") + assert.False(t, routeAcceptableNamespace(route, gwSel, map[string]string{"team": "red"}), "non-matching labels rejected") + assert.False(t, routeAcceptableNamespace(route, gwSel, nil), "no labels rejected") + }) + t.Run("non-HTTP listeners are skipped", func(t *testing.T) { + gwTCP := makeGateway("prod", "my-gw", gwv1.TCPProtocolType) + route := makeRoute("prod", "r", "prod", "my-gw") + // No HTTP/HTTPS listeners → no listener can accept + assert.False(t, routeAcceptableNamespace(route, gwTCP, nil)) + }) +} + +func TestListenerAcceptsRoute(t *testing.T) { + listener := &gwv1.Listener{ + Name: "http", + Protocol: gwv1.HTTPProtocolType, + Port: 80, + } + parentRef := gwv1.ParentReference{Name: "my-gw"} + + t.Run("no sectionName and no hostname constraint: accepted", func(t *testing.T) { + route := makeRoute("prod", "r", "prod", "my-gw", "example.com") + assert.True(t, listenerAcceptsRoute(listener, route, &parentRef)) + }) + + t.Run("sectionName matches listener name: accepted", func(t *testing.T) { + sn := gwv1.SectionName("http") + ref := gwv1.ParentReference{Name: "my-gw", SectionName: &sn} + route := makeRoute("prod", "r", "prod", "my-gw") + assert.True(t, listenerAcceptsRoute(listener, route, &ref)) + }) + + t.Run("sectionName mismatch: rejected", func(t *testing.T) { + sn := gwv1.SectionName("https") + ref := gwv1.ParentReference{Name: "my-gw", SectionName: &sn} + route := makeRoute("prod", "r", "prod", "my-gw") + assert.False(t, listenerAcceptsRoute(listener, route, &ref)) + }) + + t.Run("listener hostname constraint matches route hostname", func(t *testing.T) { + h := gwv1.Hostname("example.com") + l := &gwv1.Listener{Hostname: &h, Protocol: gwv1.HTTPProtocolType} + route := makeRoute("prod", "r", "prod", "my-gw", "example.com") + assert.True(t, listenerAcceptsRoute(l, route, &parentRef)) + }) + + t.Run("listener hostname constraint does not match route hostname: rejected", func(t *testing.T) { + h := gwv1.Hostname("other.com") + l := &gwv1.Listener{Hostname: &h, Protocol: gwv1.HTTPProtocolType} + route := makeRoute("prod", "r", "prod", "my-gw", "example.com") + assert.False(t, listenerAcceptsRoute(l, route, &parentRef)) + }) + + t.Run("listener has hostname but route has no hostnames: accepted (route inherits)", func(t *testing.T) { + h := gwv1.Hostname("example.com") + l := &gwv1.Listener{Hostname: &h, Protocol: gwv1.HTTPProtocolType} + route := makeRoute("prod", "r", "prod", "my-gw") // no hostnames + assert.True(t, listenerAcceptsRoute(l, route, &parentRef)) + }) + + t.Run("no listener hostname: accepted regardless of route hostnames", func(t *testing.T) { + route := makeRoute("prod", "r", "prod", "my-gw", "foo.com", "bar.com") + assert.True(t, listenerAcceptsRoute(listener, route, &parentRef)) + }) +} + +func TestMatchingRouteHostnames(t *testing.T) { + t.Run("no listener hostname, no route hostnames: empty", func(t *testing.T) { + l := &gwv1.Listener{Protocol: gwv1.HTTPProtocolType} + route := makeRoute("prod", "r", "prod", "gw") + got := matchingRouteHostnames(l, route) + assert.Empty(t, got) + }) + t.Run("no listener hostname, route has hostnames: all returned", func(t *testing.T) { + l := &gwv1.Listener{Protocol: gwv1.HTTPProtocolType} + route := makeRoute("prod", "r", "prod", "gw", "a.com", "b.com") + got := matchingRouteHostnames(l, route) + assert.ElementsMatch(t, []string{"a.com", "b.com"}, got) + }) + t.Run("listener hostname, no route hostnames: listener hostname returned", func(t *testing.T) { + h := gwv1.Hostname("example.com") + l := &gwv1.Listener{Hostname: &h, Protocol: gwv1.HTTPProtocolType} + route := makeRoute("prod", "r", "prod", "gw") + got := matchingRouteHostnames(l, route) + assert.Equal(t, []string{"example.com"}, got) + }) + t.Run("literal listener matches exact route hostname", func(t *testing.T) { + h := gwv1.Hostname("example.com") + l := &gwv1.Listener{Hostname: &h, Protocol: gwv1.HTTPProtocolType} + route := makeRoute("prod", "r", "prod", "gw", "example.com", "other.com") + got := matchingRouteHostnames(l, route) + assert.Equal(t, []string{"example.com"}, got) + }) + t.Run("wildcard listener matches wildcard route hostname", func(t *testing.T) { + h := gwv1.Hostname("*.example.com") + l := &gwv1.Listener{Hostname: &h, Protocol: gwv1.HTTPProtocolType} + route := makeRoute("prod", "r", "prod", "gw", "*.example.com") + got := matchingRouteHostnames(l, route) + assert.Contains(t, got, "*.example.com") + }) +} + +// makeRouteWithParentRef builds an HTTPRoute with an explicit ParentReference. +// ParentRefs lives in the embedded CommonRouteSpec so must be set after construction. +func makeRouteWithParentRef(ns, name string, refs ...gwv1.ParentReference) *gwv1.HTTPRoute { + r := &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}} + r.Spec.ParentRefs = refs + return r +} + +func TestParentRefsForGateway(t *testing.T) { + gw := makeGateway("prod", "my-gw", gwv1.HTTPProtocolType) + gw.Name = "my-gw" + gw.Namespace = "prod" + + t.Run("matching ref included", func(t *testing.T) { + ns := gwv1.Namespace("prod") + route := makeRouteWithParentRef("prod", "r", gwv1.ParentReference{ + Namespace: &ns, + Name: "my-gw", + }) + refs := parentRefsForGateway(route, gw) + assert.Len(t, refs, 1) + }) + + t.Run("different gateway name excluded", func(t *testing.T) { + ns := gwv1.Namespace("prod") + route := makeRouteWithParentRef("prod", "r", gwv1.ParentReference{ + Namespace: &ns, + Name: "other-gw", + }) + refs := parentRefsForGateway(route, gw) + assert.Empty(t, refs) + }) + + t.Run("different namespace excluded", func(t *testing.T) { + ns := gwv1.Namespace("staging") + route := makeRouteWithParentRef("prod", "r", gwv1.ParentReference{ + Namespace: &ns, + Name: "my-gw", + }) + refs := parentRefsForGateway(route, gw) + assert.Empty(t, refs) + }) + + t.Run("non-Gateway kind excluded", func(t *testing.T) { + ns := gwv1.Namespace("prod") + kindService := gwv1.Kind("Service") + route := makeRouteWithParentRef("prod", "r", gwv1.ParentReference{ + Namespace: &ns, + Name: "my-gw", + Kind: &kindService, + }) + refs := parentRefsForGateway(route, gw) + assert.Empty(t, refs) + }) + + t.Run("nil namespace uses route namespace", func(t *testing.T) { + // route.Namespace == gw.Namespace → matches + route := makeRouteWithParentRef("prod", "r", gwv1.ParentReference{ + Name: "my-gw", + }) + refs := parentRefsForGateway(route, gw) + assert.Len(t, refs, 1) + }) +} + +func TestParentRefsForGateway_WithSectionName(t *testing.T) { + gw := makeGateway("prod", "my-gw", gwv1.HTTPProtocolType) + sn := gwv1.SectionName("http") + ns := gwv1.Namespace("prod") + route := makeRouteWithParentRef("prod", "r", gwv1.ParentReference{ + Namespace: &ns, + Name: "my-gw", + SectionName: &sn, + }) + refs := parentRefsForGateway(route, gw) + assert.Len(t, refs, 1) + assert.Equal(t, gwv1.SectionName("http"), *refs[0].SectionName) +} + +func TestBuildListenerPolicies_RedirectFilter(t *testing.T) { + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r"}, + } + route.UID = "test-uid-1234" + + host := gwv1.PreciseHostname("new.com") + rule := gwv1.HTTPRouteRule{ + Filters: []gwv1.HTTPRouteFilter{{ + Type: gwv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gwv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("https"), + Hostname: &host, + }, + }}, + } + + policies := newTestTask(t, &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "gw"}}).buildListenerPolicies(route, 0, rule, []string{"example.com"}, "pool-1") + assert.Len(t, policies, 1) + // RedirectPoolName should be nil when redirect filter is applied + assert.Nil(t, policies[0].policy.RedirectPoolName) + assert.NotNil(t, policies[0].policy.RedirectUrl) +} + +func TestBuildListenerPolicies_DefaultPoolRedirect(t *testing.T) { + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r"}, + } + route.UID = "test-uid-1234" + + rule := gwv1.HTTPRouteRule{ + Matches: []gwv1.HTTPRouteMatch{{Path: &gwv1.HTTPPathMatch{Value: ptr.To("/api")}}}, + } + + policies := newTestTask(t, &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "gw"}}).buildListenerPolicies(route, 0, rule, []string{"example.com"}, "pool-1") + assert.Len(t, policies, 1) + assert.NotNil(t, policies[0].policy.RedirectPoolName) + assert.Equal(t, "pool-1", *policies[0].policy.RedirectPoolName) +} + +func TestBuildListenerPolicies_EmptyMatch(t *testing.T) { + route := &gwv1.HTTPRoute{} + route.UID = "uid" + // empty Matches → treated as single default match + rule := gwv1.HTTPRouteRule{} + policies := newTestTask(t, &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "gw"}}).buildListenerPolicies(route, 0, rule, []string{}, "my-pool") + // no hosts + no matches → 1 policy (empty host, empty match) + assert.Len(t, policies, 1) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/route_status.go b/internal/usecase/gateway_uc/alb_gateway_uc/route_status.go new file mode 100644 index 00000000..195a2170 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/route_status.go @@ -0,0 +1,241 @@ +package alb_gateway_uc + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +// albRouteController is this controller's name, used to scope the +// RouteParentStatus entries we own (other controllers' entries are preserved). +var albRouteController = gwv1.GatewayController(consts.GatewayClassControllerNameALB) + +// writeRouteStatuses computes and persists per-parent status (Accepted, +// ResolvedRefs, PartiallyInvalid) on every HTTPRoute attached to this Gateway. +// +// Route attachment + backend resolution don't depend on cloud provisioning, so +// this runs every reconcile regardless of LB readiness. It is idempotent — the +// status is only patched when it actually changes — so the status write does +// not feed back into a reconcile loop through the Gateway's HTTPRoute watch. +func (t *defaultGatewayBuildTask) writeRouteStatuses(ctx context.Context) error { + routes, err := t.listAttachedHTTPRoutes(ctx) + if err != nil { + return err + } + for i := range routes { + if err := t.writeOneRouteStatus(ctx, &routes[i]); err != nil { + // Best-effort per route — one route's status failure must not block + // the others or mask the reconcile result. + t.logger.Warnf("write status for HTTPRoute %s/%s: %v", routes[i].Namespace, routes[i].Name, err) + } + } + return nil +} + +func (t *defaultGatewayBuildTask) writeOneRouteStatus(ctx context.Context, route *gwv1.HTTPRoute) error { + parents := parentRefsForGateway(route, t.gw) + if len(parents) == 0 { + return nil + } + cur := route.DeepCopy() + for pi := range parents { + conds := t.routeParentConditions(ctx, route, parents[pi]) + upsertRouteParentStatus(&cur.Status.Parents, parents[pi], conds) + } + if routeParentsEqual(cur.Status.Parents, route.Status.Parents) { + return nil + } + if err := t.uc.k8sClient.Status().Patch(ctx, cur, client.MergeFrom(route)); err != nil { + return fmt.Errorf("patch HTTPRoute status: %w", err) + } + return nil +} + +// routeParentConditions builds the Accepted / ResolvedRefs (+ PartiallyInvalid) +// conditions for one (route, parentRef) pair. +func (t *defaultGatewayBuildTask) routeParentConditions(ctx context.Context, route *gwv1.HTTPRoute, parent gwv1.ParentReference) []metav1.Condition { + gen := route.Generation + conds := []metav1.Condition{ + routeAcceptedCondition(t.gw, route, parent, gen), + t.routeResolvedRefsCondition(ctx, route, gen), + } + if c, ok := routePartiallyInvalidCondition(route, gen); ok { + conds = append(conds, c) + } + return conds +} + +// routeAcceptedCondition reports whether the route attaches to at least one +// supported listener on the Gateway for the given parentRef. +func routeAcceptedCondition(gw *gwv1.Gateway, route *gwv1.HTTPRoute, parent gwv1.ParentReference, gen int64) metav1.Condition { + for i := range gw.Spec.Listeners { + l := &gw.Spec.Listeners[i] + if _, ok := mapListenerProtocol(l.Protocol); !ok { + continue + } + if listenerAcceptsRoute(l, route, &parent) { + return routeCond(string(gwv1.RouteConditionAccepted), metav1.ConditionTrue, + string(gwv1.RouteReasonAccepted), "Route accepted by listener", gen) + } + } + // In the list, namespace already passed; the remaining reason is a + // hostname/sectionName mismatch on every listener. + reason := gwv1.RouteReasonNotAllowedByListeners + if len(route.Spec.Hostnames) > 0 { + reason = gwv1.RouteReasonNoMatchingListenerHostname + } + return routeCond(string(gwv1.RouteConditionAccepted), metav1.ConditionFalse, + string(reason), "No listener accepts this route", gen) +} + +// routeResolvedRefsCondition checks each backendRef resolves to an existing +// same-namespace Service. Cross-namespace refs are reported RefNotPermitted +// (ReferenceGrant evaluation is not wired in Phase 1, matching the build path, +// which drops them). +func (t *defaultGatewayBuildTask) routeResolvedRefsCondition(ctx context.Context, route *gwv1.HTTPRoute, gen int64) metav1.Condition { + ok := func(reason gwv1.RouteConditionReason, msg string, status metav1.ConditionStatus) metav1.Condition { + return routeCond(string(gwv1.RouteConditionResolvedRefs), status, string(reason), msg, gen) + } + for ri := range route.Spec.Rules { + for bi := range route.Spec.Rules[ri].BackendRefs { + br := &route.Spec.Rules[ri].BackendRefs[bi].BackendRef + group := "" + if br.Group != nil { + group = string(*br.Group) + } + kind := "Service" + if br.Kind != nil { + kind = string(*br.Kind) + } + if group != "" || kind != "Service" { + return ok(gwv1.RouteReasonInvalidKind, fmt.Sprintf("backendRef %s/%s unsupported (Service only)", group, kind), metav1.ConditionFalse) + } + ns := route.Namespace + if br.Namespace != nil { + ns = string(*br.Namespace) + } + if ns != route.Namespace { + allowed, err := sharedUC.RefGrantAllowed(ctx, t.uc.k8sClient, + sharedUC.Ref{Group: "", Kind: "Service", Namespace: ns, Name: string(br.Name)}, + sharedUC.Ref{Group: gwv1.GroupName, Kind: "HTTPRoute", Namespace: route.Namespace, Name: route.Name}) + if err != nil || !allowed { + return ok(gwv1.RouteReasonRefNotPermitted, fmt.Sprintf("cross-namespace backendRef %s/%s not permitted by any ReferenceGrant", ns, br.Name), metav1.ConditionFalse) + } + } + var svc corev1.Service + if err := t.uc.k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: string(br.Name)}, &svc); err != nil { + if apierrors.IsNotFound(err) { + return ok(gwv1.RouteReasonBackendNotFound, fmt.Sprintf("backend Service %s/%s not found", ns, br.Name), metav1.ConditionFalse) + } + return ok(gwv1.RouteReasonBackendNotFound, fmt.Sprintf("backend Service %s/%s: %v", ns, br.Name, err), metav1.ConditionFalse) + } + } + } + // Divergent per-backend policies in a rule fail closed (the rule is dropped + // from the LBC); surface it so the user sees why traffic isn't served. + for ri := range route.Spec.Rules { + if diverge, err := t.ruleBackendPoliciesDiverge(ctx, route, route.Spec.Rules[ri]); err == nil && diverge { + return ok(reasonBackendConfigMismatch, "a rule's backends carry divergent VKSBackend/VKSHealthCheck policies", metav1.ConditionFalse) + } + } + return ok(gwv1.RouteReasonResolvedRefs, "All backend references resolved", metav1.ConditionTrue) +} + +// reasonBackendConfigMismatch is a vngcloud-specific ResolvedRefs reason: a +// rule's backends merge into one pool but carry conflicting per-backend policy. +const reasonBackendConfigMismatch gwv1.RouteConditionReason = "BackendConfigMismatch" + +// routePartiallyInvalidCondition flags routes that have at least one rule the +// controller silently drops (match dimensions VNGCloud LB can't express). +func routePartiallyInvalidCondition(route *gwv1.HTTPRoute, gen int64) (metav1.Condition, bool) { + for ri := range route.Spec.Rules { + if hasUnsupportedMatchDimension(route.Spec.Rules[ri].Matches) { + return routeCond(string(gwv1.RouteConditionPartiallyInvalid), metav1.ConditionTrue, + string(gwv1.RouteReasonUnsupportedValue), + "One or more rules use match dimensions (header/queryParam/method) VNGCloud LB does not support and were dropped", gen), true + } + } + return metav1.Condition{}, false +} + +func routeCond(condType string, status metav1.ConditionStatus, reason, msg string, gen int64) metav1.Condition { + return metav1.Condition{ + Type: condType, + Status: status, + Reason: reason, + Message: msg, + ObservedGeneration: gen, + LastTransitionTime: metav1.Now(), + } +} + +// upsertRouteParentStatus sets conds on the RouteParentStatus entry this +// controller owns for parentRef, creating it if absent and preserving entries +// owned by other controllers / other parents. +func upsertRouteParentStatus(parents *[]gwv1.RouteParentStatus, parentRef gwv1.ParentReference, conds []metav1.Condition) { + for i := range *parents { + if (*parents)[i].ControllerName == albRouteController && parentRefEqual((*parents)[i].ParentRef, parentRef) { + for _, c := range conds { + shared.SetCondition(&(*parents)[i].Conditions, c) + } + return + } + } + entry := gwv1.RouteParentStatus{ParentRef: parentRef, ControllerName: albRouteController} + for _, c := range conds { + shared.SetCondition(&entry.Conditions, c) + } + *parents = append(*parents, entry) +} + +func parentRefEqual(a, b gwv1.ParentReference) bool { + return a.Name == b.Name && + ptrStrEqual((*string)(a.Namespace), (*string)(b.Namespace)) && + ptrStrEqual((*string)(a.SectionName), (*string)(b.SectionName)) && + portEqual(a.Port, b.Port) +} + +func ptrStrEqual(a, b *string) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +func portEqual(a, b *gwv1.PortNumber) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +// routeParentsEqual compares two parent-status lists ignoring condition +// timestamps, so an unchanged status is not re-patched (avoids a reconcile loop). +func routeParentsEqual(a, b []gwv1.RouteParentStatus) bool { + if len(a) != len(b) { + return false + } + for i := range a { + var match *gwv1.RouteParentStatus + for j := range b { + if b[j].ControllerName == a[i].ControllerName && parentRefEqual(b[j].ParentRef, a[i].ParentRef) { + match = &b[j] + break + } + } + if match == nil || !conditionsEqual(a[i].Conditions, match.Conditions) { + return false + } + } + return true +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/route_status_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/route_status_test.go new file mode 100644 index 00000000..831acf20 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/route_status_test.go @@ -0,0 +1,241 @@ +package alb_gateway_uc + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" +) + +func httpListener(name, hostname string) gwv1.Listener { + l := gwv1.Listener{Name: gwv1.SectionName(name), Protocol: gwv1.HTTPProtocolType, Port: 80} + if hostname != "" { + h := gwv1.Hostname(hostname) + l.Hostname = &h + } + return l +} + +func gwWithListeners(ns, name string, ls ...gwv1.Listener) *gwv1.Gateway { + return &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: gwv1.GatewaySpec{Listeners: ls}, + } +} + +func routeWith(ns, name string, hostnames []string, rules []gwv1.HTTPRouteRule) *gwv1.HTTPRoute { + hs := make([]gwv1.Hostname, 0, len(hostnames)) + for _, h := range hostnames { + hs = append(hs, gwv1.Hostname(h)) + } + return &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: gwv1.ObjectName("gw")}}}, + Hostnames: hs, + Rules: rules, + }, + } +} + +func backendRule(svcName string, opts ...func(*gwv1.BackendObjectReference)) gwv1.HTTPRouteRule { + ref := gwv1.BackendObjectReference{Name: gwv1.ObjectName(svcName), Port: ptr.To(gwv1.PortNumber(80))} + for _, o := range opts { + o(&ref) + } + return gwv1.HTTPRouteRule{ + Matches: []gwv1.HTTPRouteMatch{{Path: &gwv1.HTTPPathMatch{Type: ptr.To(gwv1.PathMatchPathPrefix), Value: ptr.To("/")}}}, + BackendRefs: []gwv1.HTTPBackendRef{{BackendRef: gwv1.BackendRef{BackendObjectReference: ref}}}, + } +} + +func TestRouteAcceptedCondition(t *testing.T) { + parent := gwv1.ParentReference{Name: "gw"} + + t.Run("accepts when a listener matches", func(t *testing.T) { + gw := gwWithListeners("prod", "gw", httpListener("http", "")) + r := routeWith("prod", "r", nil, []gwv1.HTTPRouteRule{backendRule("echo")}) + c := routeAcceptedCondition(gw, r, parent, 1) + assert.Equal(t, metav1.ConditionTrue, c.Status) + assert.Equal(t, string(gwv1.RouteReasonAccepted), c.Reason) + }) + + t.Run("hostname mismatch -> NoMatchingListenerHostname", func(t *testing.T) { + gw := gwWithListeners("prod", "gw", httpListener("http", "app.example.com")) + r := routeWith("prod", "r", []string{"other.example.org"}, []gwv1.HTTPRouteRule{backendRule("echo")}) + c := routeAcceptedCondition(gw, r, parent, 1) + assert.Equal(t, metav1.ConditionFalse, c.Status) + assert.Equal(t, string(gwv1.RouteReasonNoMatchingListenerHostname), c.Reason) + }) + + t.Run("sectionName mismatch, no hostnames -> NotAllowedByListeners", func(t *testing.T) { + gw := gwWithListeners("prod", "gw", httpListener("web", "")) + sn := gwv1.SectionName("nope") + p := gwv1.ParentReference{Name: "gw", SectionName: &sn} + r := routeWith("prod", "r", nil, []gwv1.HTTPRouteRule{backendRule("echo")}) + c := routeAcceptedCondition(gw, r, p, 1) + assert.Equal(t, metav1.ConditionFalse, c.Status) + assert.Equal(t, string(gwv1.RouteReasonNotAllowedByListeners), c.Reason) + }) +} + +func TestRoutePartiallyInvalidCondition(t *testing.T) { + t.Run("header match -> PartiallyInvalid", func(t *testing.T) { + rule := backendRule("echo") + rule.Matches[0].Headers = []gwv1.HTTPHeaderMatch{{Name: "X-Env", Value: "canary"}} + r := routeWith("prod", "r", nil, []gwv1.HTTPRouteRule{rule}) + c, ok := routePartiallyInvalidCondition(r, 1) + assert.True(t, ok) + assert.Equal(t, metav1.ConditionTrue, c.Status) + assert.Equal(t, string(gwv1.RouteReasonUnsupportedValue), c.Reason) + }) + + t.Run("path-only -> not partially invalid", func(t *testing.T) { + r := routeWith("prod", "r", nil, []gwv1.HTTPRouteRule{backendRule("echo")}) + _, ok := routePartiallyInvalidCondition(r, 1) + assert.False(t, ok) + }) +} + +func TestRouteResolvedRefsCondition(t *testing.T) { + gw := gwWithListeners("prod", "gw", httpListener("http", "")) + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "echo"}} + + cond := func(t *testing.T, r *gwv1.HTTPRoute, objs ...runtime.Object) metav1.Condition { + task := newTestTaskWithObjs(t, gw, objs...) + return task.routeResolvedRefsCondition(context.Background(), r, 1) + } + + t.Run("existing same-ns Service -> ResolvedRefs", func(t *testing.T) { + r := routeWith("prod", "r", nil, []gwv1.HTTPRouteRule{backendRule("echo")}) + c := cond(t, r, svc) + assert.Equal(t, metav1.ConditionTrue, c.Status) + assert.Equal(t, string(gwv1.RouteReasonResolvedRefs), c.Reason) + }) + + t.Run("missing Service -> BackendNotFound", func(t *testing.T) { + r := routeWith("prod", "r", nil, []gwv1.HTTPRouteRule{backendRule("ghost")}) + c := cond(t, r) + assert.Equal(t, metav1.ConditionFalse, c.Status) + assert.Equal(t, string(gwv1.RouteReasonBackendNotFound), c.Reason) + }) + + t.Run("cross-namespace without grant -> RefNotPermitted", func(t *testing.T) { + rule := backendRule("echo", func(ref *gwv1.BackendObjectReference) { + ns := gwv1.Namespace("other") + ref.Namespace = &ns + }) + r := routeWith("prod", "r", nil, []gwv1.HTTPRouteRule{rule}) + c := cond(t, r, svc) + assert.Equal(t, metav1.ConditionFalse, c.Status) + assert.Equal(t, string(gwv1.RouteReasonRefNotPermitted), c.Reason) + }) + + t.Run("cross-namespace WITH ReferenceGrant -> ResolvedRefs", func(t *testing.T) { + rule := backendRule("echo", func(ref *gwv1.BackendObjectReference) { + ns := gwv1.Namespace("other") + ref.Namespace = &ns + }) + r := routeWith("prod", "r", nil, []gwv1.HTTPRouteRule{rule}) + svcOther := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "other", Name: "echo"}} + grant := &gwv1beta1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{Namespace: "other", Name: "grant"}, + Spec: gwv1beta1.ReferenceGrantSpec{ + From: []gwv1beta1.ReferenceGrantFrom{{Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Namespace: "prod"}}, + To: []gwv1beta1.ReferenceGrantTo{{Group: "", Kind: "Service"}}, + }, + } + c := cond(t, r, svcOther, grant) + assert.Equal(t, metav1.ConditionTrue, c.Status) + assert.Equal(t, string(gwv1.RouteReasonResolvedRefs), c.Reason) + }) + + t.Run("non-Service kind -> InvalidKind", func(t *testing.T) { + rule := backendRule("echo", func(ref *gwv1.BackendObjectReference) { + ref.Kind = ptr.To(gwv1.Kind("Secret")) + }) + r := routeWith("prod", "r", nil, []gwv1.HTTPRouteRule{rule}) + c := cond(t, r, svc) + assert.Equal(t, metav1.ConditionFalse, c.Status) + assert.Equal(t, string(gwv1.RouteReasonInvalidKind), c.Reason) + }) +} + +func TestRuleBackendPoliciesDiverge(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + route := &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r"}} + twoBackends := gwv1.HTTPRouteRule{BackendRefs: []gwv1.HTTPBackendRef{ + {BackendRef: gwv1.BackendRef{BackendObjectReference: gwv1.BackendObjectReference{Name: "svc-a", Port: ptr.To(gwv1.PortNumber(80))}}}, + {BackendRef: gwv1.BackendRef{BackendObjectReference: gwv1.BackendObjectReference{Name: "svc-b", Port: ptr.To(gwv1.PortNumber(80))}}}, + }} + + t.Run("no policies -> no divergence", func(t *testing.T) { + task := newTestTask(t, gw) + d, err := task.ruleBackendPoliciesDiverge(context.Background(), route, twoBackends) + assert.NoError(t, err) + assert.False(t, d) + }) + + t.Run("health-check policy on only one of two backends -> divergence", func(t *testing.T) { + hp := makeHCPolicy("prod", "hcp", "svc-a", "HTTP") // svc-b has none + task := newTestTaskWithObjs(t, gw, hp) + d, err := task.ruleBackendPoliciesDiverge(context.Background(), route, twoBackends) + assert.NoError(t, err) + assert.True(t, d) + }) + + t.Run("single backend never diverges", func(t *testing.T) { + hp := makeHCPolicy("prod", "hcp", "svc-a", "HTTP") + task := newTestTaskWithObjs(t, gw, hp) + one := gwv1.HTTPRouteRule{BackendRefs: twoBackends.BackendRefs[:1]} + d, err := task.ruleBackendPoliciesDiverge(context.Background(), route, one) + assert.NoError(t, err) + assert.False(t, d) + }) +} + +func TestUpsertRouteParentStatus_PreservesOtherControllers(t *testing.T) { + parent := gwv1.ParentReference{Name: "gw"} + other := gwv1.RouteParentStatus{ + ParentRef: gwv1.ParentReference{Name: "other-gw"}, + ControllerName: "example.com/other", + Conditions: []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted"}}, + } + parents := []gwv1.RouteParentStatus{other} + + upsertRouteParentStatus(&parents, parent, []metav1.Condition{ + routeCond("Accepted", metav1.ConditionTrue, "Accepted", "ok", 1), + }) + + assert.Len(t, parents, 2, "other controller entry preserved + ours appended") + // Our entry exists with our controller name. + var ours *gwv1.RouteParentStatus + for i := range parents { + if parents[i].ControllerName == albRouteController { + ours = &parents[i] + } + } + assert.NotNil(t, ours) + assert.Equal(t, gwv1.ObjectName("gw"), ours.ParentRef.Name) + // Other controller's entry untouched. + assert.Equal(t, gwv1.GatewayController("example.com/other"), parents[0].ControllerName) +} + +func TestRouteParentsEqual_IgnoresTime(t *testing.T) { + mk := func(status metav1.ConditionStatus) []gwv1.RouteParentStatus { + return []gwv1.RouteParentStatus{{ + ParentRef: gwv1.ParentReference{Name: "gw"}, + ControllerName: albRouteController, + Conditions: []metav1.Condition{routeCond("Accepted", status, "Accepted", "ok", 1)}, + }} + } + a, b := mk(metav1.ConditionTrue), mk(metav1.ConditionTrue) + assert.True(t, routeParentsEqual(a, b), "same status, different timestamps -> equal") + assert.False(t, routeParentsEqual(mk(metav1.ConditionTrue), mk(metav1.ConditionFalse)), "different status -> not equal") +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/status.go b/internal/usecase/gateway_uc/alb_gateway_uc/status.go new file mode 100644 index 00000000..ce509ad7 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/status.go @@ -0,0 +1,213 @@ +package alb_gateway_uc + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +// writeGatewayStatus persists Gateway.Status from translation outcome plus +// observed LBC.Status. Called as the last step of buildLoadBalancerConfig. +// +// We mirror the "spec/status discipline" of the LBC controller — Gateway is +// only patched when something actually changed, to avoid generation churn +// and reconcile feedback loops. +func (t *defaultGatewayBuildTask) writeGatewayStatus(ctx context.Context, lbc *v1alpha1.LoadBalancerConfig, translateErr error) error { + cur := t.gw.DeepCopy() + + accepted := metav1.Condition{ + Type: string(gwv1.GatewayConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gwv1.GatewayReasonAccepted), + Message: "Gateway accepted by controller " + consts.GatewayClassControllerNameALB, + ObservedGeneration: t.gw.Generation, + LastTransitionTime: metav1.Now(), + } + programmed := metav1.Condition{ + Type: string(gwv1.GatewayConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: string(gwv1.GatewayReasonProgrammed), + Message: "LoadBalancerConfig reconciled successfully", + ObservedGeneration: t.gw.Generation, + LastTransitionTime: metav1.Now(), + } + + switch { + case translateErr != nil: + programmed.Status = metav1.ConditionFalse + programmed.Reason = string(gwv1.GatewayReasonPending) + programmed.Message = translateErr.Error() + case lbc == nil: + programmed.Status = metav1.ConditionFalse + programmed.Reason = string(gwv1.GatewayReasonPending) + programmed.Message = "LoadBalancerConfig not yet created" + case !lbcReady(lbc): + programmed.Status = metav1.ConditionFalse + programmed.Reason = string(gwv1.GatewayReasonPending) + programmed.Message = lbcStatusMessage(lbc) + } + + shared.SetCondition(&cur.Status.Conditions, accepted) + shared.SetCondition(&cur.Status.Conditions, programmed) + + cur.Status.Addresses = gatewayAddressesFromLBC(lbc) + cur.Status.Listeners = listenerStatusesFromGateway(t.gw) + + if conditionsEqual(cur.Status.Conditions, t.gw.Status.Conditions) && + gatewayAddressesEqual(cur.Status.Addresses, t.gw.Status.Addresses) && + listenerStatusesEqual(cur.Status.Listeners, t.gw.Status.Listeners) { + return nil + } + if err := t.uc.k8sClient.Status().Patch(ctx, cur, client.MergeFrom(t.gw)); err != nil { + return fmt.Errorf("patch Gateway %s/%s status: %w", t.gw.Namespace, t.gw.Name, err) + } + return nil +} + +// lbcReady returns true if the LBC's Ready condition is True. +func lbcReady(lbc *v1alpha1.LoadBalancerConfig) bool { + for _, c := range lbc.Status.Conditions { + if c.Type == "Ready" { + return c.Status == metav1.ConditionTrue + } + } + return false +} + +func lbcStatusMessage(lbc *v1alpha1.LoadBalancerConfig) string { + if lbc.Status.LastReconcileMessage != "" { + return "LBC: " + lbc.Status.LastReconcileMessage + } + return "LoadBalancerConfig not yet ready" +} + +func gatewayAddressesFromLBC(lbc *v1alpha1.LoadBalancerConfig) []gwv1.GatewayStatusAddress { + if lbc == nil || lbc.Status.Address == nil || *lbc.Status.Address == "" { + return nil + } + t := gwv1.IPAddressType + return []gwv1.GatewayStatusAddress{{Type: &t, Value: *lbc.Status.Address}} +} + +// listenerStatusesFromGateway emits a baseline ListenerStatus per Spec listener. +// HTTPRoute Accepted/ResolvedRefs counts can be added in a follow-up; this +// minimum viable status keeps the listeners visible to `kubectl describe`. +func listenerStatusesFromGateway(gw *gwv1.Gateway) []gwv1.ListenerStatus { + out := make([]gwv1.ListenerStatus, 0, len(gw.Spec.Listeners)) + for i := range gw.Spec.Listeners { + l := &gw.Spec.Listeners[i] + ls := gwv1.ListenerStatus{ + Name: l.Name, + SupportedKinds: supportedKindsForProtocol(l.Protocol), + } + shared.SetCondition(&ls.Conditions, metav1.Condition{ + Type: string(gwv1.ListenerConditionAccepted), + Status: listenerAcceptedStatus(l), + Reason: listenerAcceptedReason(l), + ObservedGeneration: gw.Generation, + LastTransitionTime: metav1.Now(), + }) + out = append(out, ls) + } + return out +} + +func supportedKindsForProtocol(p gwv1.ProtocolType) []gwv1.RouteGroupKind { + switch p { + case gwv1.HTTPProtocolType, gwv1.HTTPSProtocolType: + group := gwv1.Group("gateway.networking.k8s.io") + return []gwv1.RouteGroupKind{{Group: &group, Kind: "HTTPRoute"}} + } + return nil +} + +func listenerAcceptedStatus(l *gwv1.Listener) metav1.ConditionStatus { + if _, ok := mapListenerProtocol(l.Protocol); ok { + return metav1.ConditionTrue + } + return metav1.ConditionFalse +} + +func listenerAcceptedReason(l *gwv1.Listener) string { + if _, ok := mapListenerProtocol(l.Protocol); ok { + return string(gwv1.ListenerReasonAccepted) + } + return string(gwv1.ListenerReasonUnsupportedProtocol) +} + +// conditionsEqual compares condition slices ignoring LastTransitionTime, so +// re-running the reconciler with the same outcome doesn't churn the patch. +func conditionsEqual(a, b []metav1.Condition) bool { + if len(a) != len(b) { + return false + } + idx := make(map[string]metav1.Condition, len(b)) + for i := range b { + idx[b[i].Type] = b[i] + } + for i := range a { + other, ok := idx[a[i].Type] + if !ok { + return false + } + if a[i].Status != other.Status || a[i].Reason != other.Reason || + a[i].Message != other.Message || a[i].ObservedGeneration != other.ObservedGeneration { + return false + } + } + return true +} + +func gatewayAddressesEqual(a, b []gwv1.GatewayStatusAddress) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Value != b[i].Value { + return false + } + ta, tb := "", "" + if a[i].Type != nil { + ta = string(*a[i].Type) + } + if b[i].Type != nil { + tb = string(*b[i].Type) + } + if ta != tb { + return false + } + } + return true +} + +func listenerStatusesEqual(a, b []gwv1.ListenerStatus) bool { + if len(a) != len(b) { + return false + } + idx := make(map[gwv1.SectionName]gwv1.ListenerStatus, len(b)) + for i := range b { + idx[b[i].Name] = b[i] + } + for i := range a { + other, ok := idx[a[i].Name] + if !ok { + return false + } + if !conditionsEqual(a[i].Conditions, other.Conditions) { + return false + } + } + return true +} + +// _unusedCorev1 keeps the import set forward-compatible with future status +// fields that need corev1.ConditionStatus / EndpointAddress shapes. +var _ corev1.ConditionStatus diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/status_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/status_test.go new file mode 100644 index 00000000..e7bf03f2 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/status_test.go @@ -0,0 +1,204 @@ +package alb_gateway_uc + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +func TestLbcReady(t *testing.T) { + t.Run("no conditions → not ready", func(t *testing.T) { + assert.False(t, lbcReady(&v1alpha1.LoadBalancerConfig{})) + }) + t.Run("Ready=True → ready", func(t *testing.T) { + lbc := &v1alpha1.LoadBalancerConfig{} + lbc.Status.Conditions = []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}} + assert.True(t, lbcReady(lbc)) + }) + t.Run("Ready=False → not ready", func(t *testing.T) { + lbc := &v1alpha1.LoadBalancerConfig{} + lbc.Status.Conditions = []metav1.Condition{{Type: "Ready", Status: metav1.ConditionFalse}} + assert.False(t, lbcReady(lbc)) + }) + t.Run("other condition → not ready", func(t *testing.T) { + lbc := &v1alpha1.LoadBalancerConfig{} + lbc.Status.Conditions = []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue}} + assert.False(t, lbcReady(lbc)) + }) +} + +func TestLbcStatusMessage(t *testing.T) { + t.Run("empty message returns default", func(t *testing.T) { + lbc := &v1alpha1.LoadBalancerConfig{} + assert.Equal(t, "LoadBalancerConfig not yet ready", lbcStatusMessage(lbc)) + }) + t.Run("non-empty message prefixed with LBC:", func(t *testing.T) { + lbc := &v1alpha1.LoadBalancerConfig{} + lbc.Status.LastReconcileMessage = "some error" + assert.Equal(t, "LBC: some error", lbcStatusMessage(lbc)) + }) +} + +func TestGatewayAddressesFromLBC(t *testing.T) { + t.Run("nil lbc returns nil", func(t *testing.T) { + assert.Nil(t, gatewayAddressesFromLBC(nil)) + }) + t.Run("nil Address returns nil", func(t *testing.T) { + assert.Nil(t, gatewayAddressesFromLBC(&v1alpha1.LoadBalancerConfig{})) + }) + t.Run("empty Address string returns nil", func(t *testing.T) { + lbc := &v1alpha1.LoadBalancerConfig{} + lbc.Status.Address = ptr.To("") + assert.Nil(t, gatewayAddressesFromLBC(lbc)) + }) + t.Run("valid address returns single IP address", func(t *testing.T) { + lbc := &v1alpha1.LoadBalancerConfig{} + lbc.Status.Address = ptr.To("10.1.2.3") + addrs := gatewayAddressesFromLBC(lbc) + assert.Len(t, addrs, 1) + assert.Equal(t, "10.1.2.3", addrs[0].Value) + assert.Equal(t, gwv1.IPAddressType, *addrs[0].Type) + }) +} + +func TestConditionsEqual(t *testing.T) { + t.Run("empty slices equal", func(t *testing.T) { + assert.True(t, conditionsEqual(nil, nil)) + }) + t.Run("different lengths not equal", func(t *testing.T) { + a := []metav1.Condition{{Type: "A", Status: metav1.ConditionTrue, Reason: "R", Message: "M"}} + assert.False(t, conditionsEqual(a, nil)) + }) + t.Run("same conditions equal (ignoring LastTransitionTime)", func(t *testing.T) { + t1 := metav1.Now() + t2 := metav1.NewTime(t1.Add(-1e9)) + a := []metav1.Condition{{ + Type: "Programmed", Status: metav1.ConditionTrue, + Reason: "Programmed", Message: "ok", + ObservedGeneration: 1, + LastTransitionTime: t1, + }} + b := []metav1.Condition{{ + Type: "Programmed", Status: metav1.ConditionTrue, + Reason: "Programmed", Message: "ok", + ObservedGeneration: 1, + LastTransitionTime: t2, + }} + assert.True(t, conditionsEqual(a, b)) + }) + t.Run("different status not equal", func(t *testing.T) { + a := []metav1.Condition{{Type: "P", Status: metav1.ConditionTrue, Reason: "R"}} + b := []metav1.Condition{{Type: "P", Status: metav1.ConditionFalse, Reason: "R"}} + assert.False(t, conditionsEqual(a, b)) + }) + t.Run("different reason not equal", func(t *testing.T) { + a := []metav1.Condition{{Type: "P", Status: metav1.ConditionTrue, Reason: "R1"}} + b := []metav1.Condition{{Type: "P", Status: metav1.ConditionTrue, Reason: "R2"}} + assert.False(t, conditionsEqual(a, b)) + }) + t.Run("different message not equal", func(t *testing.T) { + a := []metav1.Condition{{Type: "P", Status: metav1.ConditionTrue, Reason: "R", Message: "M1"}} + b := []metav1.Condition{{Type: "P", Status: metav1.ConditionTrue, Reason: "R", Message: "M2"}} + assert.False(t, conditionsEqual(a, b)) + }) + t.Run("type missing in b not equal", func(t *testing.T) { + a := []metav1.Condition{{Type: "A", Status: metav1.ConditionTrue, Reason: "R"}} + b := []metav1.Condition{{Type: "B", Status: metav1.ConditionTrue, Reason: "R"}} + assert.False(t, conditionsEqual(a, b)) + }) +} + +func TestGatewayAddressesEqual(t *testing.T) { + ipType := gwv1.IPAddressType + t.Run("empty equal", func(t *testing.T) { + assert.True(t, gatewayAddressesEqual(nil, nil)) + }) + t.Run("same address equal", func(t *testing.T) { + a := []gwv1.GatewayStatusAddress{{Type: &ipType, Value: "1.2.3.4"}} + b := []gwv1.GatewayStatusAddress{{Type: &ipType, Value: "1.2.3.4"}} + assert.True(t, gatewayAddressesEqual(a, b)) + }) + t.Run("different value not equal", func(t *testing.T) { + a := []gwv1.GatewayStatusAddress{{Type: &ipType, Value: "1.2.3.4"}} + b := []gwv1.GatewayStatusAddress{{Type: &ipType, Value: "5.6.7.8"}} + assert.False(t, gatewayAddressesEqual(a, b)) + }) + t.Run("different length not equal", func(t *testing.T) { + a := []gwv1.GatewayStatusAddress{{Value: "1.2.3.4"}} + assert.False(t, gatewayAddressesEqual(a, nil)) + }) + t.Run("nil type vs non-nil type handled", func(t *testing.T) { + a := []gwv1.GatewayStatusAddress{{Type: nil, Value: "1.2.3.4"}} + b := []gwv1.GatewayStatusAddress{{Type: &ipType, Value: "1.2.3.4"}} + assert.False(t, gatewayAddressesEqual(a, b)) + }) +} + +func TestListenerStatusesEqual(t *testing.T) { + t.Run("empty equal", func(t *testing.T) { + assert.True(t, listenerStatusesEqual(nil, nil)) + }) + t.Run("different length not equal", func(t *testing.T) { + a := []gwv1.ListenerStatus{{Name: "http"}} + assert.False(t, listenerStatusesEqual(a, nil)) + }) + t.Run("same name + conditions equal", func(t *testing.T) { + cond := metav1.Condition{Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted"} + a := []gwv1.ListenerStatus{{Name: "http", Conditions: []metav1.Condition{cond}}} + b := []gwv1.ListenerStatus{{Name: "http", Conditions: []metav1.Condition{cond}}} + assert.True(t, listenerStatusesEqual(a, b)) + }) + t.Run("name in a missing from b not equal", func(t *testing.T) { + a := []gwv1.ListenerStatus{{Name: "http"}} + b := []gwv1.ListenerStatus{{Name: "https"}} + assert.False(t, listenerStatusesEqual(a, b)) + }) +} + +func TestSupportedKindsForProtocol(t *testing.T) { + t.Run("HTTP returns HTTPRoute", func(t *testing.T) { + kinds := supportedKindsForProtocol(gwv1.HTTPProtocolType) + assert.Len(t, kinds, 1) + assert.Equal(t, gwv1.Kind("HTTPRoute"), kinds[0].Kind) + }) + t.Run("HTTPS returns HTTPRoute", func(t *testing.T) { + kinds := supportedKindsForProtocol(gwv1.HTTPSProtocolType) + assert.Len(t, kinds, 1) + assert.Equal(t, gwv1.Kind("HTTPRoute"), kinds[0].Kind) + }) + t.Run("TCP returns nil", func(t *testing.T) { + kinds := supportedKindsForProtocol(gwv1.TCPProtocolType) + assert.Nil(t, kinds) + }) +} + +func TestListenerAcceptedStatus(t *testing.T) { + t.Run("HTTP → True", func(t *testing.T) { + l := &gwv1.Listener{Protocol: gwv1.HTTPProtocolType} + assert.Equal(t, metav1.ConditionTrue, listenerAcceptedStatus(l)) + }) + t.Run("HTTPS → True", func(t *testing.T) { + l := &gwv1.Listener{Protocol: gwv1.HTTPSProtocolType} + assert.Equal(t, metav1.ConditionTrue, listenerAcceptedStatus(l)) + }) + t.Run("TCP → False", func(t *testing.T) { + l := &gwv1.Listener{Protocol: gwv1.TCPProtocolType} + assert.Equal(t, metav1.ConditionFalse, listenerAcceptedStatus(l)) + }) +} + +func TestListenerAcceptedReason(t *testing.T) { + t.Run("HTTP → Accepted", func(t *testing.T) { + l := &gwv1.Listener{Protocol: gwv1.HTTPProtocolType} + assert.Equal(t, string(gwv1.ListenerReasonAccepted), listenerAcceptedReason(l)) + }) + t.Run("TLS → UnsupportedProtocol", func(t *testing.T) { + l := &gwv1.Listener{Protocol: gwv1.TLSProtocolType} + assert.Equal(t, string(gwv1.ListenerReasonUnsupportedProtocol), listenerAcceptedReason(l)) + }) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/status_write_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/status_write_test.go new file mode 100644 index 00000000..36b7d943 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/status_write_test.go @@ -0,0 +1,186 @@ +package alb_gateway_uc + +import ( + "context" + "errors" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +func newTaskWithGWInClient(t *testing.T, gw *gwv1.Gateway) *defaultGatewayBuildTask { + s := newTestScheme() + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(gw). + WithStatusSubresource(&gwv1.Gateway{}). + Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fakeClient, + } + return &defaultGatewayBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + listenerPolicies: make(map[string]*gwv1alpha1.VKSGatewayPolicy), + } +} + +func TestWriteGatewayStatus_TranslateError(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + task := newTaskWithGWInClient(t, gw) + err := task.writeGatewayStatus(context.Background(), nil, errors.New("translation failed")) + // Should not error — status patch may succeed or be a no-op + // The important thing is that the Programmed condition is set to False + _ = err +} + +func TestWriteGatewayStatus_NilLBC(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + task := newTaskWithGWInClient(t, gw) + err := task.writeGatewayStatus(context.Background(), nil, nil) + _ = err // nil lbc → Programmed=False with Pending reason +} + +func TestWriteGatewayStatus_LBCNotReady(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + task := newTaskWithGWInClient(t, gw) + lbc := &v1alpha1.LoadBalancerConfig{} + lbc.Status.Conditions = []metav1.Condition{{Type: "Ready", Status: metav1.ConditionFalse}} + lbc.Status.LastReconcileMessage = "provisioning" + err := task.writeGatewayStatus(context.Background(), lbc, nil) + _ = err +} + +func TestWriteGatewayStatus_LBCReady(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + } + task := newTaskWithGWInClient(t, gw) + lbc := &v1alpha1.LoadBalancerConfig{} + lbc.Status.Conditions = []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}} + lbc.Status.Address = ptr.To("10.1.2.3") + err := task.writeGatewayStatus(context.Background(), lbc, nil) + _ = err +} + +func TestWriteGatewayStatus_NoChange(t *testing.T) { + // Pre-set Gateway with conditions that match what writeGatewayStatus would produce, + // so the early-equal exit is exercised. + controllerName := consts.GatewayClassControllerNameALB + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }, + }, + Status: gwv1.GatewayStatus{ + Conditions: []metav1.Condition{ + { + Type: string(gwv1.GatewayConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gwv1.GatewayReasonAccepted), + Message: "Gateway accepted by controller " + controllerName, + ObservedGeneration: 0, + }, + { + Type: string(gwv1.GatewayConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: string(gwv1.GatewayReasonPending), + Message: "LoadBalancerConfig not yet created", + ObservedGeneration: 0, + }, + }, + Listeners: []gwv1.ListenerStatus{ + { + Name: "http", + Conditions: []metav1.Condition{{ + Type: string(gwv1.ListenerConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gwv1.ListenerReasonAccepted), + ObservedGeneration: 0, + }}, + }, + }, + }, + } + task := newTaskWithGWInClient(t, gw) + // nil lbc, nil err → Programmed=False/Pending + err := task.writeGatewayStatus(context.Background(), nil, nil) + // Should be a no-op (conditions already match) — no error + assert.NoError(t, err) +} + +func TestListenerStatusesFromGateway(t *testing.T) { + gw := &gwv1.Gateway{ + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + {Name: "https", Protocol: gwv1.HTTPSProtocolType, Port: 443}, + {Name: "tcp", Protocol: gwv1.TCPProtocolType, Port: 9000}, + }, + }, + } + statuses := listenerStatusesFromGateway(gw) + assert.Len(t, statuses, 3) + + // HTTP listener → Accepted=True + assert.Equal(t, gwv1.SectionName("http"), statuses[0].Name) + assert.NotEmpty(t, statuses[0].Conditions) + assert.Equal(t, metav1.ConditionTrue, statuses[0].Conditions[0].Status) + + // HTTPS listener → Accepted=True + assert.Equal(t, gwv1.SectionName("https"), statuses[1].Name) + assert.Equal(t, metav1.ConditionTrue, statuses[1].Conditions[0].Status) + + // TCP listener → Accepted=False + assert.Equal(t, gwv1.SectionName("tcp"), statuses[2].Name) + assert.Equal(t, metav1.ConditionFalse, statuses[2].Conditions[0].Status) + + // HTTP and HTTPS have HTTPRoute supported kinds + assert.NotEmpty(t, statuses[0].SupportedKinds) + assert.NotEmpty(t, statuses[1].SupportedKinds) + // TCP has nil supported kinds + assert.Nil(t, statuses[2].SupportedKinds) +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/usecase.go b/internal/usecase/gateway_uc/alb_gateway_uc/usecase.go new file mode 100644 index 00000000..1bff5ba9 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/usecase.go @@ -0,0 +1,199 @@ +// Package alb_gateway_uc translates a Gateway-API Gateway under the +// vngcloud-alb GatewayClass into a LoadBalancerConfig CR that the existing +// LBC controller reconciles against the cloud. The Gateway use case is the +// only writer to the LBC's Spec; the LBC controller remains the only writer +// to the LBC's Status (and to the cloud LB itself). +package alb_gateway_uc + +import ( + "context" + "errors" + "fmt" + + "github.com/anngdinh/operator-helper/contexts" + "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/common" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/k8s" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +type albGatewayUseCase struct { + k8sRepo repository.K8sRepository + vngcloudRepo repository.VngCloudRepository + endpointResolver utils.EndpointResolver + + // k8sClient is a controller-runtime client used for Gateway-API resources + // (Gateway, HTTPRoute, VKS*Policy) that don't have typed accessors on + // repository.K8sRepository. Passed in alongside k8sRepo at construction. + k8sClient client.Client + + // finalizerManager owns the add/remove-finalizer flow (re-Get + optimistic + // Patch + retry-on-conflict), shared with the non-Gateway controllers. + finalizerManager k8s.FinalizerManager + + clusterId string + defaultNetworkId string + defaultSubnetId string + defaultSubnetCIDR string + defaultZone common.Zone +} + +// NewALBGatewayUseCase wires the dependencies the Gateway translator needs. +func NewALBGatewayUseCase( + clusterId string, + k8sRepo repository.K8sRepository, + vngcloudRepo repository.VngCloudRepository, + endpointResolver utils.EndpointResolver, + k8sClient client.Client, + finalizerManager k8s.FinalizerManager, +) usecase.ALBGatewayUseCase { + return &albGatewayUseCase{ + clusterId: clusterId, + k8sRepo: k8sRepo, + vngcloudRepo: vngcloudRepo, + endpointResolver: endpointResolver, + k8sClient: k8sClient, + finalizerManager: finalizerManager, + } +} + +// InitALBGatewayUseCase prepares cluster-scoped state — same shape as the +// other use cases. We discover default network info from a node so that +// VKSGatewayPolicy.LoadBalancerSpec doesn't need to spell it out, and fall +// back to the node-label clusterID when one wasn't supplied at construction +// (matches ingress_uc.InitIngressUseCase / service_uc.InitServiceUseCase). +// +// Returns an error when the data isn't fully populated yet so the gateway +// controller's init retry loop will keep firing until the controller-runtime +// cache is ready and the cloud probe succeeds. This avoids a one-shot init +// that "succeeds" against a not-yet-ready cache and then never retries. +func (uc *albGatewayUseCase) InitALBGatewayUseCase(ctx context.Context) error { + logger := contexts.NewContext(ctx).Log() + + if uc.defaultNetworkId != "" && uc.defaultSubnetId != "" && uc.defaultSubnetCIDR != "" && uc.defaultZone != "" && uc.clusterId != "" { + return nil + } + + nodes, err := uc.listNodesForNetworkProbe(ctx) + if err != nil { + // Cache-not-ready is expected during the first few seconds of + // startup. Returning an error tells the caller to retry. + return fmt.Errorf("ALB Gateway init: list nodes: %w", err) + } + if len(nodes) == 0 { + return errors.New("ALB Gateway init: cluster has no nodes yet") + } + + if uc.defaultNetworkId == "" || uc.defaultSubnetId == "" || uc.defaultSubnetCIDR == "" || uc.defaultZone == "" { + providerID := utils.GetProviderIdFromNode(nodes[0]) + if providerID == "" { + return errors.New("ALB Gateway init: first node has no providerID") + } + zone, networkID, subnetID, cidr, err := uc.vngcloudRepo.GetServerNetworkInfo(ctx, providerID) + if err != nil { + return fmt.Errorf("ALB Gateway init: probe vngcloud network info: %w", err) + } + uc.defaultZone, uc.defaultNetworkId, uc.defaultSubnetId, uc.defaultSubnetCIDR = zone, networkID, subnetID, cidr + logger.Infof("ALB Gateway init: defaults zone=%s network=%s subnet=%s cidr=%s", zone, networkID, subnetID, cidr) + } + + // Same fallback ingress_uc / service_uc use: if --cluster-id wasn't + // supplied via config, read it off the first node carrying the + // vks.vngcloud.vn/cluster-id label. + if uc.clusterId == "" { + clusterID := "" + for _, n := range nodes { + if n != nil && n.Labels != nil && n.Labels["vks.vngcloud.vn/cluster-id"] != "" { + clusterID = n.Labels["vks.vngcloud.vn/cluster-id"] + break + } + } + if clusterID == "" { + return errors.New("ALB Gateway init: no clusterID found; specify --cluster-id or label a node with vks.vngcloud.vn/cluster-id") + } + uc.clusterId = clusterID + logger.Infof("ALB Gateway init: clusterID empty in config, picked up from node label: %s", uc.clusterId) + } + return nil +} + +// EnsureALBGatewayUseCase is the entry called by the Gateway reconciler. +func (uc *albGatewayUseCase) EnsureALBGatewayUseCase(ctx context.Context, req ctrl.Request) error { + logger := contexts.NewContext(ctx).Log() + + gw := &gwv1.Gateway{} + if err := uc.k8sClient.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: req.Name}, gw); err != nil { + if apierrors.IsNotFound(err) { + // The reconciler queued this Gateway and it disappeared in the meantime. + // Nothing to do — finalizer-driven cleanup runs on the previous tombstone. + return nil + } + return fmt.Errorf("get Gateway %s/%s: %w", req.Namespace, req.Name, err) + } + + // Phase 1 only handles the ALB GatewayClass. + gc, err := uc.fetchGatewayClass(ctx, gw) + if err != nil { + return err + } + if gc == nil || string(gc.Spec.ControllerName) != consts.GatewayClassControllerNameALB { + // Not ours — leave it alone. Other controllers (or a future NLB + // reconciler) own different GatewayClasses. + return nil + } + + // Deletion path: finalizer protects orderly cleanup. + if !gw.DeletionTimestamp.IsZero() { + return uc.handleDeletion(ctx, gw) + } + if err := uc.ensureFinalizer(ctx, gw); err != nil { + return err + } + + task := &defaultGatewayBuildTask{ + uc: uc, + logger: logger.WithField("gateway", req.Namespace+"/"+req.Name), + gw: gw, + nameHelper: utils.NewNameHelper(uc.clusterId, "gateway", gw.Namespace, gw.Name), + } + return task.run(ctx) +} + +// DeleteALBGatewayUseCase is the explicit-delete path; the deletion-finalizer +// path inside Ensure also handles cleanup. +func (uc *albGatewayUseCase) DeleteALBGatewayUseCase(ctx context.Context, req ctrl.Request) error { + gw := &gwv1.Gateway{} + if err := uc.k8sClient.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: req.Name}, gw); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + if meta.IsNoMatchError(err) { + return nil + } + return fmt.Errorf("get Gateway %s/%s: %w", req.Namespace, req.Name, err) + } + return uc.handleDeletion(ctx, gw) +} + +func (uc *albGatewayUseCase) fetchGatewayClass(ctx context.Context, gw *gwv1.Gateway) (*gwv1.GatewayClass, error) { + if gw.Spec.GatewayClassName == "" { + return nil, errors.New("gateway has empty gatewayClassName") + } + gc := &gwv1.GatewayClass{} + if err := uc.k8sClient.Get(ctx, types.NamespacedName{Name: string(gw.Spec.GatewayClassName)}, gc); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get GatewayClass %s: %w", gw.Spec.GatewayClassName, err) + } + return gc, nil +} diff --git a/internal/usecase/gateway_uc/alb_gateway_uc/usecase_test.go b/internal/usecase/gateway_uc/alb_gateway_uc/usecase_test.go new file mode 100644 index 00000000..67c85761 --- /dev/null +++ b/internal/usecase/gateway_uc/alb_gateway_uc/usecase_test.go @@ -0,0 +1,528 @@ +package alb_gateway_uc + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/common" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/k8s" +) + +// makeNode creates a Node with a given providerID. +func makeNode(name, providerID string) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1.NodeSpec{ProviderID: providerID}, + } +} + +// --- NewALBGatewayUseCase tests --- + +func TestNewALBGatewayUseCase(t *testing.T) { + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).Build() + + uc := NewALBGatewayUseCase("cluster-1", mockK8s, mockVng, nil, fakeClient, nil) + assert.NotNil(t, uc) +} + +// --- InitALBGatewayUseCase tests --- + +func TestInitALBGatewayUseCase_Idempotent(t *testing.T) { + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fake.NewClientBuilder().WithScheme(newTestScheme()).Build(), + clusterId: "cluster-1", + defaultZone: "HCM03-1C", + defaultNetworkId: "net-1", + defaultSubnetId: "subnet-1", + defaultSubnetCIDR: "10.0.0.0/24", + } + // Already initialised — should return nil without calling anything + err := uc.InitALBGatewayUseCase(context.Background()) + assert.NoError(t, err) +} + +func TestInitALBGatewayUseCase_ListNodeError(t *testing.T) { + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListNode(mock.Anything, mock.Anything). + Return(errors.New("cache not ready")) + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fake.NewClientBuilder().WithScheme(newTestScheme()).Build(), + } + err := uc.InitALBGatewayUseCase(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "list nodes") +} + +func TestInitALBGatewayUseCase_NoNodes(t *testing.T) { + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListNode(mock.Anything, mock.Anything). + Return(nil) // leaves NodeList empty + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fake.NewClientBuilder().WithScheme(newTestScheme()).Build(), + } + err := uc.InitALBGatewayUseCase(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no nodes") +} + +func TestInitALBGatewayUseCase_GetServerNetworkInfoError(t *testing.T) { + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListNode(mock.Anything, mock.AnythingOfType("*v1.NodeList")). + RunAndReturn(func(_ context.Context, list *corev1.NodeList, _ ...client.ListOption) error { + list.Items = []corev1.Node{*makeNode("node-1", "vngcloud://ins-00000000-0000-0000-0000-000000000001")} + return nil + }) + + mockVng.EXPECT(). + GetServerNetworkInfo(mock.Anything, "ins-00000000-0000-0000-0000-000000000001"). + Return(common.Zone(""), "", "", "", errors.New("cloud api error")) + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fake.NewClientBuilder().WithScheme(newTestScheme()).Build(), + } + err := uc.InitALBGatewayUseCase(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "probe vngcloud network info") +} + +func TestInitALBGatewayUseCase_Success(t *testing.T) { + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListNode(mock.Anything, mock.AnythingOfType("*v1.NodeList")). + RunAndReturn(func(_ context.Context, list *corev1.NodeList, _ ...client.ListOption) error { + list.Items = []corev1.Node{*makeNode("node-1", "vngcloud://ins-00000000-0000-0000-0000-000000000001")} + return nil + }) + + mockVng.EXPECT(). + GetServerNetworkInfo(mock.Anything, "ins-00000000-0000-0000-0000-000000000001"). + Return(common.Zone("HCM03-1C"), "net-abc", "subnet-abc", "10.0.0.0/24", nil) + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fake.NewClientBuilder().WithScheme(newTestScheme()).Build(), + clusterId: "cluster-1", + } + err := uc.InitALBGatewayUseCase(context.Background()) + assert.NoError(t, err) + assert.Equal(t, common.Zone("HCM03-1C"), uc.defaultZone) + assert.Equal(t, "net-abc", uc.defaultNetworkId) + assert.Equal(t, "subnet-abc", uc.defaultSubnetId) + assert.Equal(t, "10.0.0.0/24", uc.defaultSubnetCIDR) +} + +// TestInitALBGatewayUseCase_ClusterIdFromNodeLabel verifies the same +// fallback ingress_uc/service_uc do: when the controller wasn't given a +// --cluster-id, it picks the value off the first node carrying the +// vks.vngcloud.vn/cluster-id label. +func TestInitALBGatewayUseCase_ClusterIdFromNodeLabel(t *testing.T) { + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + node := makeNode("node-1", "vngcloud://ins-00000000-0000-0000-0000-000000000001") + node.Labels = map[string]string{"vks.vngcloud.vn/cluster-id": "cluster-from-label"} + + mockK8s.EXPECT(). + ListNode(mock.Anything, mock.AnythingOfType("*v1.NodeList")). + RunAndReturn(func(_ context.Context, list *corev1.NodeList, _ ...client.ListOption) error { + list.Items = []corev1.Node{*node} + return nil + }) + + mockVng.EXPECT(). + GetServerNetworkInfo(mock.Anything, "ins-00000000-0000-0000-0000-000000000001"). + Return(common.Zone("HCM03-1C"), "net-abc", "subnet-abc", "10.0.0.0/24", nil) + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fake.NewClientBuilder().WithScheme(newTestScheme()).Build(), + // clusterId left empty intentionally + } + err := uc.InitALBGatewayUseCase(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "cluster-from-label", uc.clusterId) +} + +// TestInitALBGatewayUseCase_NoClusterIdAvailable: if --cluster-id wasn't +// passed AND no node carries the label, init must fail loudly so the user +// sees the misconfiguration instead of getting silent name collisions. +func TestInitALBGatewayUseCase_NoClusterIdAvailable(t *testing.T) { + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + mockK8s.EXPECT(). + ListNode(mock.Anything, mock.AnythingOfType("*v1.NodeList")). + RunAndReturn(func(_ context.Context, list *corev1.NodeList, _ ...client.ListOption) error { + list.Items = []corev1.Node{*makeNode("node-1", "vngcloud://ins-00000000-0000-0000-0000-000000000001")} + return nil + }) + + mockVng.EXPECT(). + GetServerNetworkInfo(mock.Anything, "ins-00000000-0000-0000-0000-000000000001"). + Return(common.Zone("HCM03-1C"), "net-abc", "subnet-abc", "10.0.0.0/24", nil) + + uc := &albGatewayUseCase{ + k8sRepo: mockK8s, + vngcloudRepo: mockVng, + k8sClient: fake.NewClientBuilder().WithScheme(newTestScheme()).Build(), + } + err := uc.InitALBGatewayUseCase(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no clusterID found") +} + +// --- fetchGatewayClass tests --- + +func TestFetchGatewayClass_EmptyClassName(t *testing.T) { + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).Build() + uc := &albGatewayUseCase{k8sClient: fakeClient} + gw := &gwv1.Gateway{} // empty class name + gc, err := uc.fetchGatewayClass(context.Background(), gw) + assert.Error(t, err) + assert.Nil(t, gc) +} + +func TestFetchGatewayClass_NotFound(t *testing.T) { + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).Build() + uc := &albGatewayUseCase{k8sClient: fakeClient} + gw := &gwv1.Gateway{} + gw.Spec.GatewayClassName = "nonexistent" + gc, err := uc.fetchGatewayClass(context.Background(), gw) + assert.NoError(t, err) // not-found returns nil, nil + assert.Nil(t, gc) +} + +func TestFetchGatewayClass_Found(t *testing.T) { + s := newTestScheme() + existingGC := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-alb"}, + Spec: gwv1.GatewayClassSpec{ControllerName: consts.GatewayClassControllerNameALB}, + } + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(existingGC).Build() + uc := &albGatewayUseCase{k8sClient: fakeClient} + gw := &gwv1.Gateway{} + gw.Spec.GatewayClassName = "vngcloud-alb" + gc, err := uc.fetchGatewayClass(context.Background(), gw) + assert.NoError(t, err) + assert.NotNil(t, gc) + assert.Equal(t, gwv1.ObjectName("vngcloud-alb"), gwv1.ObjectName(gc.Name)) +} + +// --- EnsureALBGatewayUseCase tests --- + +func TestEnsureALBGatewayUseCase_NotFound(t *testing.T) { + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "prod", Name: "missing-gw"}} + err := uc.EnsureALBGatewayUseCase(context.Background(), req) + assert.NoError(t, err) +} + +func TestEnsureALBGatewayUseCase_MismatchedClass(t *testing.T) { + s := newTestScheme() + // Gateway exists, but no GatewayClass of that name → fetchGatewayClass returns nil + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{GatewayClassName: "other-class"}, + } + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "prod", Name: "my-gw"}} + err := uc.EnsureALBGatewayUseCase(context.Background(), req) + // gc == nil → return nil + assert.NoError(t, err) +} + +func TestEnsureALBGatewayUseCase_WrongControllerName(t *testing.T) { + s := newTestScheme() + gc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "nlb-class"}, + Spec: gwv1.GatewayClassSpec{ControllerName: "gateway.vks.vngcloud.vn/nlb"}, + } + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{GatewayClassName: "nlb-class"}, + } + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gc, gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "prod", Name: "my-gw"}} + err := uc.EnsureALBGatewayUseCase(context.Background(), req) + assert.NoError(t, err) // not our class → ignore +} + +func TestEnsureALBGatewayUseCase_DeletionPath(t *testing.T) { + s := newTestScheme() + gc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-alb"}, + Spec: gwv1.GatewayClassSpec{ControllerName: consts.GatewayClassControllerNameALB}, + } + now := metav1.Now() + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gw", + Finalizers: []string{domain.GatewayFinalizer}, + DeletionTimestamp: &now, + }, + Spec: gwv1.GatewaySpec{GatewayClassName: "vngcloud-alb"}, + } + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gc, gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + // handleDeletion → listOwnedLBCs (empty) → remove finalizer + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient, + finalizerManager: k8s.NewDefaultFinalizerManager(fakeClient, logr.Discard())} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "prod", Name: "my-gw"}} + err := uc.EnsureALBGatewayUseCase(context.Background(), req) + assert.NoError(t, err) +} + +// --- DeleteALBGatewayUseCase tests --- + +func TestDeleteALBGatewayUseCase_NotFound(t *testing.T) { + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "prod", Name: "gone-gw"}} + err := uc.DeleteALBGatewayUseCase(context.Background(), req) + assert.NoError(t, err) +} + +func TestDeleteALBGatewayUseCase_ExistingGateway(t *testing.T) { + s := newTestScheme() + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "existing-gw", + Finalizers: []string{domain.GatewayFinalizer}, + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + // handleDeletion → listOwnedLBCs (empty) → remove finalizer + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient, + finalizerManager: k8s.NewDefaultFinalizerManager(fakeClient, logr.Discard())} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "prod", Name: "existing-gw"}} + err := uc.DeleteALBGatewayUseCase(context.Background(), req) + assert.NoError(t, err) +} + +// --- buildLoadBalancerName tests --- + +// TestLoadBalancerName checks the LB name produced via NameHelper. The actual +// name shape (vks____) is owned by NameHelper — +// this test verifies the Gateway use case feeds it the right inputs and the +// result is deterministic + length-bounded. +func TestLoadBalancerName(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "my-gateway", + UID: "abcd1234-dead-beef-0000-111122223333", + }, + } + task := newTestTask(t, gw) + name := task.nameHelper.GetLoadBalancerDefaultName() + assert.LessOrEqual(t, len(name), 50) + // NameHelper.ValidateName replaces underscores with dashes (cloud's + // loadBalancerName regex doesn't allow `_`), so we expect "vks-". + assert.True(t, strings.HasPrefix(name, "vks-"), "expected vks- prefix in %q", name) + // Deterministic + assert.Equal(t, name, task.nameHelper.GetLoadBalancerDefaultName()) +} + +func TestLoadBalancerName_LongGatewayName(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "this-is-a-very-long-gateway-name-that-exceeds-limits", + UID: "abcd1234", + }, + } + task := newTestTask(t, gw) + name := task.nameHelper.GetLoadBalancerDefaultName() + assert.LessOrEqual(t, len(name), 50) +} + +// --- resolveSubnetAndZone tests --- + +func TestResolveSubnetAndZone_Defaults(t *testing.T) { + gw := &gwv1.Gateway{} + task := newTestTask(t, gw) + // No policy → defaults + subnet, network, zone, cidr, err := task.resolveSubnetAndZone(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "subnet-1", subnet) + assert.Equal(t, "net-1", network) + assert.Equal(t, "HCM03-1C", zone) + assert.Equal(t, "10.0.0.0/24", cidr) +} + +func TestResolveSubnetAndZone_PolicyOverridesSubnet(t *testing.T) { + gw := &gwv1.Gateway{} + task := newTestTask(t, gw) + overrideSubnet := "subnet-override" + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{} + task.unscopedPolicy.Spec.LoadBalancerSpec = &gwv1alpha1.VKSLoadBalancerSpec{ + SubnetID: &overrideSubnet, + } + subnet, network, _, _, err := task.resolveSubnetAndZone(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "subnet-override", subnet) + assert.Equal(t, "net-1", network) // network stays from default +} + +// TestEnsureALBGatewayUseCase_GetError tests the path where fetchGatewayClass returns +// an error due to an empty gatewayClassName. +func TestEnsureALBGatewayUseCase_GetError(t *testing.T) { + s := newTestScheme() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + // GatewayClass with empty name → fetchGatewayClass returns error. + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{GatewayClassName: ""}, // empty → error + } + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "prod", Name: "my-gw"}} + err := uc.EnsureALBGatewayUseCase(context.Background(), req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty gatewayClassName") +} + +// TestDeleteALBGatewayUseCase_GetRealError tests that a genuine (non-NotFound) Get error is propagated. +func TestDeleteALBGatewayUseCase_GetRealError(t *testing.T) { + // We can trigger a non-NotFound error by pointing Delete at a resource + // that requires a scheme not registered. However fake client with correct scheme + // only returns NotFound. So instead we test the meta.IsNoMatchError branch: + // that is hit when the GVK is not registered in the scheme (no-match). + // In practice with the correct scheme and a missing object we get NotFound. + // This test exercises the path where the object exists but handleDeletion errors. + s := newTestScheme() + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "prod", + Name: "del-gw", + Finalizers: []string{domain.GatewayFinalizer}, + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + + // deleteOwnedLBC → listOwnedLBCs fails + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(errors.New("list failed")).Once() + + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "prod", Name: "del-gw"}} + err := uc.DeleteALBGatewayUseCase(context.Background(), req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "list failed") +} + +// TestEnsureALBGatewayUseCase_EnsureFinalizerThenRun tests the branch where +// ensureFinalizer succeeds and then task.run() is called. Without subnet info +// buildLoadBalancerConfig returns an error. +func TestEnsureALBGatewayUseCase_EnsureFinalizerThenRun(t *testing.T) { + s := newTestScheme() + gc := &gwv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "vngcloud-alb"}, + Spec: gwv1.GatewayClassSpec{ControllerName: consts.GatewayClassControllerNameALB}, + } + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "my-gw"}, + Spec: gwv1.GatewaySpec{GatewayClassName: "vngcloud-alb"}, + } + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(gc, gw).Build() + mockK8s := repository.NewMockK8sRepository(t) + mockVng := repository.NewMockVngCloudRepository(t) + uc := &albGatewayUseCase{k8sRepo: mockK8s, vngcloudRepo: mockVng, k8sClient: fakeClient, + finalizerManager: k8s.NewDefaultFinalizerManager(fakeClient, logr.Discard())} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "prod", Name: "my-gw"}} + // ensureFinalizer calls AddFinalizers which should succeed (fake client). + // Then task.run() will be called. Without subnet info, buildLoadBalancerConfig + // will fail with "could not resolve default subnet". + // The list call is needed for buildLoadBalancerConfig → listOwnedLBCs. + mockK8s.EXPECT(). + ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + err := uc.EnsureALBGatewayUseCase(context.Background(), req) + // Without subnet/zone set, buildLoadBalancerConfig fails. + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not resolve subnet/zone") +} diff --git a/internal/usecase/gateway_uc/nlb_gateway_uc/build_lbc.go b/internal/usecase/gateway_uc/nlb_gateway_uc/build_lbc.go new file mode 100644 index 00000000..c4c5b0a6 --- /dev/null +++ b/internal/usecase/gateway_uc/nlb_gateway_uc/build_lbc.go @@ -0,0 +1,263 @@ +package nlb_gateway_uc + +import ( + "context" + "fmt" + "reflect" + + "github.com/sirupsen/logrus" + "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/common" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +// nlbBuildTask carries per-reconcile state. One task per Gateway reconcile. +type nlbBuildTask struct { + uc *nlbGatewayUseCase + logger *logrus.Entry + gw *gwv1.Gateway + nameHelper utils.NameHelper + + unscopedPolicy *gwv1alpha1.VKSGatewayPolicy // LB-level fields + listener defaults +} + +func (t *nlbBuildTask) run(ctx context.Context) error { + if err := t.resolveGatewayPolicies(ctx); err != nil { + return err + } + buildErr := t.buildLoadBalancerConfig(ctx) + if err := t.writeRouteStatuses(ctx); err != nil { + t.logger.Warnf("write L4 route statuses for Gateway %s/%s: %v", t.gw.Namespace, t.gw.Name, err) + } + return buildErr +} + +// resolveGatewayPolicies finds the unscoped VKSGatewayPolicy (LB-level fields). +// NLB has no per-listener L7 settings, so only the unscoped policy is read. +func (t *nlbBuildTask) resolveGatewayPolicies(ctx context.Context) error { + var list gwv1alpha1.VKSGatewayPolicyList + if err := t.uc.k8sClient.List(ctx, &list, client.InNamespace(t.gw.Namespace)); err != nil { + return fmt.Errorf("list VKSGatewayPolicy: %w", err) + } + cands := make([]*gwv1alpha1.VKSGatewayPolicy, 0, len(list.Items)) + for i := range list.Items { + cands = append(cands, &list.Items[i]) + } + t.unscopedPolicy, _ = sharedUC.ResolveDirectPolicy(cands, pkggw.PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", + Namespace: t.gw.Namespace, Name: t.gw.Name, + }) + return nil +} + +// buildLoadBalancerConfig finds (or creates) the L4 LBC owned by this Gateway +// and rewrites the fields the Gateway controller owns. Mirrors the ALB path but +// emits Type=Network with TCP/UDP listeners, each pointing at one default pool. +func (t *nlbBuildTask) buildLoadBalancerConfig(ctx context.Context) error { + lbcs, err := t.uc.listOwnedLBCs(ctx, t.gw) + if err != nil { + return err + } + if len(lbcs) > 1 { + return fmt.Errorf("multiple LBCs found for Gateway %s/%s; cannot reconcile", t.gw.Namespace, t.gw.Name) + } + + var ( + lbc *v1alpha1.LoadBalancerConfig + oldLBC *v1alpha1.LoadBalancerConfig + isCreated bool + ) + if len(lbcs) == 1 { + lbc = lbcs[0] + oldLBC = lbc.DeepCopy() + isCreated = true + } else { + lbc = &v1alpha1.LoadBalancerConfig{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: t.gw.Namespace, + GenerateName: t.gw.Name + "-", + }, + Spec: v1alpha1.LoadBalancerConfigSpec{}, + } + } + + if lbc.Labels == nil { + lbc.Labels = make(map[string]string) + } + lbc.Labels[domain.LabelOwnerResourceKind] = domain.OwnerKindGateway + lbc.Labels[domain.LabelOwnerResourceName] = t.gw.Name + lbc.Labels[domain.LabelOwnerResourceUid] = string(t.gw.UID) + lbc.Labels[domain.OwnerLabelGatewayUID] = string(t.gw.UID) + + // Type/Subnet/Zone/Name are create-only (mirrored from cloud by the LBC + // controller; writing them every reconcile fights that sync → loop). + lbc.Spec.VpcId = t.uc.defaultNetworkId + if !isCreated { + subnetID, networkID, zone, _, err := t.resolveSubnetAndZone(ctx) + if err != nil { + return err + } + if subnetID == "" || networkID == "" || zone == "" { + return fmt.Errorf("could not resolve subnet/zone for Gateway %s/%s", t.gw.Namespace, t.gw.Name) + } + lbc.Spec.VpcId = networkID + lbc.Spec.Type = v2.LoadBalancerTypeLayer4 + lbc.Spec.SubnetId = subnetID + lbc.Spec.ZoneId = common.Zone(zone) + lbc.Spec.LoadBalancerName = t.resolveLoadBalancerName() + } + + if t.uc.clusterId != "" { + lbc.Spec.ClusterId = &t.uc.clusterId + } + + t.applyLoadBalancerSpec(lbc) + + pools, listeners, err := t.buildListenersAndPools(ctx) + if err != nil { + _ = t.writeGatewayStatus(ctx, nil, err) + return err + } + lbc.Spec.Pools = pools + lbc.Spec.Listeners = listeners + + if !isCreated { + if err := t.uc.k8sRepo.CreateLoadBalancerConfig(ctx, lbc); err != nil { + _ = t.writeGatewayStatus(ctx, nil, err) + return fmt.Errorf("create LBC for Gateway %s/%s: %w", t.gw.Namespace, t.gw.Name, err) + } + t.logger.Infof("created L4 LBC %s/%s", lbc.Namespace, lbc.Name) + return t.writeGatewayStatus(ctx, lbc, nil) + } + if !reflect.DeepEqual(oldLBC.Spec, lbc.Spec) || !reflect.DeepEqual(oldLBC.Labels, lbc.Labels) { + if err := t.uc.k8sRepo.PatchLoadBalancerConfig(ctx, lbc, client.MergeFrom(oldLBC)); err != nil { + _ = t.writeGatewayStatus(ctx, oldLBC, err) + return fmt.Errorf("patch LBC %s/%s: %w", lbc.Namespace, lbc.Name, err) + } + t.logger.Infof("patched L4 LBC %s/%s", lbc.Namespace, lbc.Name) + } + return t.writeGatewayStatus(ctx, lbc, nil) +} + +// applyLoadBalancerSpec writes the LB-level fields the unscoped VKSGatewayPolicy +// contributes. Each is overwritten unconditionally (incl. nil) so removing a +// field from the policy un-sets it on the LBC. Same pattern as the ALB path. +func (t *nlbBuildTask) applyLoadBalancerSpec(lbc *v1alpha1.LoadBalancerConfig) { + var s *gwv1alpha1.VKSLoadBalancerSpec + if t.unscopedPolicy != nil { + s = t.unscopedPolicy.Spec.LoadBalancerSpec + } + + lbc.Spec.Scheme = nil + lbc.Spec.PackageId = nil + lbc.Spec.LoadBalancerId = nil + lbc.Spec.PrivateSubnetId = nil + lbc.Spec.PrivateZoneId = nil + lbc.Spec.EnableAutoscale = nil + lbc.Spec.IsPoc = nil + lbc.Spec.Tags = nil + + if s == nil { + return + } + if s.Scheme != nil { + scheme := v2.LoadBalancerScheme(*s.Scheme) + lbc.Spec.Scheme = &scheme + } + if s.PackageID != nil { + lbc.Spec.PackageId = s.PackageID + } + if s.LoadBalancerID != nil { + lbc.Spec.LoadBalancerId = s.LoadBalancerID + } + if s.PrivateSubnetID != nil { + lbc.Spec.PrivateSubnetId = s.PrivateSubnetID + } + if s.PrivateZoneID != nil { + zone := common.Zone(*s.PrivateZoneID) + lbc.Spec.PrivateZoneId = &zone + } + if s.EnableAutoscale != nil { + lbc.Spec.EnableAutoscale = s.EnableAutoscale + } + if s.IsPOC != nil { + lbc.Spec.IsPoc = s.IsPOC + } + if len(s.Tags) > 0 { + lbc.Spec.Tags = make(map[string]string, len(s.Tags)) + for k, v := range s.Tags { + lbc.Spec.Tags[k] = v + } + } +} + +// resolveSubnetAndZone mirrors the ALB resolver: LoadBalancerID adoption → +// SubnetID → PreferZoneID (node-scan) → cluster default. Fails closed on an +// unresolvable explicit value. +func (t *nlbBuildTask) resolveSubnetAndZone(ctx context.Context) (string, string, string, string, error) { + var s *gwv1alpha1.VKSLoadBalancerSpec + if t.unscopedPolicy != nil { + s = t.unscopedPolicy.Spec.LoadBalancerSpec + } + + switch { + case s != nil && s.LoadBalancerID != nil && *s.LoadBalancerID != "": + lb, err := t.uc.vngcloudRepo.GetLoadBalancerByID(ctx, *s.LoadBalancerID) + if err != nil || lb == nil { + return "", "", "", "", fmt.Errorf("get load balancer by id %s for adoption: %w", *s.LoadBalancerID, err) + } + if lb.BackendSubnetID == "" || lb.BackendSubnetID == t.uc.defaultSubnetId { + return t.uc.defaultSubnetId, t.uc.defaultNetworkId, string(t.uc.defaultZone), t.uc.defaultSubnetCIDR, nil + } + subnet, err := t.uc.vngcloudRepo.GetSubnetByID(ctx, t.uc.defaultNetworkId, lb.BackendSubnetID) + if err != nil || subnet == nil { + return "", "", "", "", fmt.Errorf("get subnet %s of adopted load balancer %s: %w", lb.BackendSubnetID, *s.LoadBalancerID, err) + } + return subnet.Id, t.uc.defaultNetworkId, subnet.ZoneID, subnet.Cidr, nil + + case s != nil && s.SubnetID != nil && *s.SubnetID != "": + return *s.SubnetID, t.uc.defaultNetworkId, string(t.uc.defaultZone), t.uc.defaultSubnetCIDR, nil + + case s != nil && s.PreferZoneID != nil && *s.PreferZoneID != "": + if common.Zone(*s.PreferZoneID) == t.uc.defaultZone { + return t.uc.defaultSubnetId, t.uc.defaultNetworkId, string(t.uc.defaultZone), t.uc.defaultSubnetCIDR, nil + } + nodes := &corev1.NodeList{} + if err := t.uc.k8sRepo.ListNode(ctx, nodes); err != nil { + return "", "", "", "", fmt.Errorf("list nodes to resolve prefer zone %s: %w", *s.PreferZoneID, err) + } + for _, providerID := range utils.GetListProviderIdFromNodeList(nodes) { + z, n, sn, cidr, err := t.uc.vngcloudRepo.GetServerNetworkInfo(ctx, providerID) + if err != nil { + continue + } + if string(z) == *s.PreferZoneID { + return sn, n, string(z), cidr, nil + } + } + return "", "", "", "", fmt.Errorf("no cluster node found in prefer zone %s", *s.PreferZoneID) + + default: + return t.uc.defaultSubnetId, t.uc.defaultNetworkId, string(t.uc.defaultZone), t.uc.defaultSubnetCIDR, nil + } +} + +// resolveLoadBalancerName returns the cloud LB name to set at create time. +func (t *nlbBuildTask) resolveLoadBalancerName() string { + if t.unscopedPolicy != nil && t.unscopedPolicy.Spec.LoadBalancerSpec != nil { + if n := t.unscopedPolicy.Spec.LoadBalancerSpec.LoadBalancerName; n != nil && *n != "" { + return *n + } + } + return t.nameHelper.GetLoadBalancerDefaultName() +} diff --git a/internal/usecase/gateway_uc/nlb_gateway_uc/build_lbc_test.go b/internal/usecase/gateway_uc/nlb_gateway_uc/build_lbc_test.go new file mode 100644 index 00000000..e7bec753 --- /dev/null +++ b/internal/usecase/gateway_uc/nlb_gateway_uc/build_lbc_test.go @@ -0,0 +1,109 @@ +package nlb_gateway_uc + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +func TestRouteAttachesToListener(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + l := tcpListener("tcp", 6379) + sec := gwv1.SectionName("tcp") + otherSec := gwv1.SectionName("other") + p := gwv1.PortNumber(6379) + wrongP := gwv1.PortNumber(9999) + + cases := []struct { + name string + refs []gwv1.ParentReference + want bool + }{ + {"bare gateway ref", []gwv1.ParentReference{{Name: "gw"}}, true}, + {"matching sectionName", []gwv1.ParentReference{{Name: "gw", SectionName: &sec}}, true}, + {"matching port", []gwv1.ParentReference{{Name: "gw", Port: &p}}, true}, + {"wrong sectionName", []gwv1.ParentReference{{Name: "gw", SectionName: &otherSec}}, false}, + {"wrong port", []gwv1.ParentReference{{Name: "gw", Port: &wrongP}}, false}, + {"wrong gateway", []gwv1.ParentReference{{Name: "other-gw"}}, false}, + } + for _, c := range cases { + got := routeAttachesToListener(&l4Route{namespace: "prod", parentRefs: c.refs}, gw, &l) + assert.Equal(t, c.want, got, c.name) + } +} + +func TestOldestRouteForListener_OldestWins(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + l := tcpListener("tcp", 6379) + older := &l4Route{kind: "TCPRoute", namespace: "prod", name: "a", creation: 100, parentRefs: []gwv1.ParentReference{{Name: "gw"}}} + newer := &l4Route{kind: "TCPRoute", namespace: "prod", name: "b", creation: 200, parentRefs: []gwv1.ParentReference{{Name: "gw"}}} + udp := &l4Route{kind: "UDPRoute", namespace: "prod", name: "c", creation: 50, parentRefs: []gwv1.ParentReference{{Name: "gw"}}} + + got := oldestRouteForListener([]*l4Route{newer, older, udp}, gw, &l) + assert.NotNil(t, got) + assert.Equal(t, "a", got.name, "oldest TCP route wins; UDP route ignored for a TCP listener") +} + +func tcpRouteObj(ns, name, gwName, backend string, port gwv1.PortNumber) *gwv1a2.TCPRoute { + return &gwv1a2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name, UID: "r-uid"}, + Spec: gwv1a2.TCPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: gwv1.ObjectName(gwName)}}}, + Rules: []gwv1a2.TCPRouteRule{{ + BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: gwv1.ObjectName(backend), Port: &port}}}, + }}, + }, + } +} + +// buildLoadBalancerConfig creates a Type=Network LBC with the gateway-uid owner +// label when none exists, resolving subnet/zone to the cluster default. +func TestBuildLoadBalancerConfig_CreatesL4LBC(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw", UID: "gw-uid"}, + Spec: gwv1.GatewaySpec{Listeners: []gwv1.Listener{tcpListener("tcp", 6379)}}, + } + port := gwv1.PortNumber(6379) + route := tcpRouteObj("prod", "redis-route", "gw", "redis", port) + + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT(). + ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(6379), mock.Anything). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 31000, Name: "n1"}}, nil).Once() + + // Seed the Gateway into the fake client so writeGatewayStatus's status patch + // finds it; seed the route so listAttachedRoutes resolves the pool. + task := newTask(t, gw, mockEP, gw, route) + + mk := task.uc.k8sRepo.(*repository.MockK8sRepository) + mk.EXPECT().ListLoadBalancerConfig(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() // no existing LBC + var created *v1alpha1.LoadBalancerConfig + mk.EXPECT().CreateLoadBalancerConfig(mock.Anything, mock.Anything). + Run(func(_ context.Context, lbc *v1alpha1.LoadBalancerConfig, _ ...client.CreateOption) { + created = lbc + }).Return(nil).Once() + + err := task.buildLoadBalancerConfig(context.Background()) + assert.NoError(t, err) + assert.NotNil(t, created) + assert.Equal(t, v2.LoadBalancerTypeLayer4, created.Spec.Type) + assert.Equal(t, "subnet-1", created.Spec.SubnetId) + assert.Equal(t, "gw-uid", created.Labels[domain.OwnerLabelGatewayUID]) + assert.Equal(t, domain.OwnerKindGateway, created.Labels[domain.LabelOwnerResourceKind]) + assert.Len(t, created.Spec.Listeners, 1) + assert.Len(t, created.Spec.Pools, 1) +} diff --git a/internal/usecase/gateway_uc/nlb_gateway_uc/build_pools.go b/internal/usecase/gateway_uc/nlb_gateway_uc/build_pools.go new file mode 100644 index 00000000..1c1bc3b0 --- /dev/null +++ b/internal/usecase/gateway_uc/nlb_gateway_uc/build_pools.go @@ -0,0 +1,385 @@ +package nlb_gateway_uc + +import ( + "context" + "fmt" + "sort" + + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +// buildListenersAndPools walks the Gateway's TCP/UDP listeners, attaches the +// oldest matching L4 route to each, and builds one default pool per listener +// from that route's backendRefs. HTTP/HTTPS listeners (L7) on an NLB Gateway +// are unsupported and skipped here (reported UnsupportedProtocol in status). +// A listener with no attached route produces no LBC listener/pool — an L4 +// listener has nothing to forward without a backend. +func (t *nlbBuildTask) buildListenersAndPools(ctx context.Context) ([]v1alpha1.Pool, []v1alpha1.Listener, error) { + routes, err := t.listAttachedRoutes(ctx) + if err != nil { + return nil, nil, err + } + + pools := make([]v1alpha1.Pool, 0) + listeners := make([]v1alpha1.Listener, 0) + + for i := range t.gw.Spec.Listeners { + l := &t.gw.Spec.Listeners[i] + listenerProto, poolProto, hcProto, ok := mapL4Protocol(l.Protocol) + if !ok { + t.logger.Warnf("listener %q uses unsupported protocol %q for NLB; skipping", l.Name, l.Protocol) + continue + } + route := oldestRouteForListener(routes, t.gw, l) + if route == nil { + t.logger.Warnf("NLB listener %q (%s:%d) has no attached %s route; skipping", l.Name, l.Protocol, l.Port, l.Protocol) + continue + } + + pool, err := t.synthesizeL4Pool(ctx, route, poolProto, hcProto) + if err != nil { + return nil, nil, err + } + pools = append(pools, *pool) + + entry := v1alpha1.Listener{ + Name: t.cloudListenerName(l), + Protocol: listenerProto, + ProtocolPort: int32(l.Port), + DefaultPoolName: &pool.Name, + } + t.applyListenerPolicy(&entry) + listeners = append(listeners, entry) + } + + if len(listeners) == 0 { + return nil, nil, fmt.Errorf("gateway %s/%s has no NLB-supported listeners with an attached route", t.gw.Namespace, t.gw.Name) + } + + sort.SliceStable(pools, func(i, j int) bool { return pools[i].Name < pools[j].Name }) + sort.SliceStable(listeners, func(i, j int) bool { return listeners[i].Name < listeners[j].Name }) + return pools, listeners, nil +} + +// mapL4Protocol maps a Gateway listener protocol to the LBC listener/pool/HC +// enums. Only TCP and UDP are supported on the NLB path. +func mapL4Protocol(p gwv1.ProtocolType) (v2.ListenerProtocol, v2.PoolProtocol, v2.HealthCheckProtocol, bool) { + switch p { + case gwv1.TCPProtocolType: + return v2.ListenerProtocolTCP, v2.PoolProtocolTCP, v2.HealthCheckProtocolTCP, true + case gwv1.UDPProtocolType: + return v2.ListenerProtocolUDP, v2.PoolProtocolUDP, v2.HealthCheckProtocolPINGUDP, true + } + return "", "", "", false +} + +// synthesizeL4Pool builds one pool from an L4 route's backendRefs. Mirrors the +// service_uc L4 pool shape (TCP/PING-UDP health, instance/ip members) but +// sources backends from the route and tuning from VKS policies. +func (t *nlbBuildTask) synthesizeL4Pool(ctx context.Context, route *l4Route, poolProto v2.PoolProtocol, hcProto v2.HealthCheckProtocol) (*v1alpha1.Pool, error) { + if len(route.backendRefs) == 0 { + return nil, fmt.Errorf("%s %s/%s has no backendRefs", route.kind, route.namespace, route.name) + } + + keys := make([]pkggw.BackendKey, 0, len(route.backendRefs)) + weights := make([]pkggw.BackendWeight, 0, len(route.backendRefs)) + memberAddrs := make([][]utils.EndpointAddress, 0, len(route.backendRefs)) + + for i := range route.backendRefs { + br := &route.backendRefs[i] + ns := route.namespace + if br.Namespace != nil { + ns = string(*br.Namespace) + } + if ns != route.namespace { + allowed, err := sharedUC.RefGrantAllowed(ctx, t.uc.k8sClient, + sharedUC.Ref{Group: "", Kind: "Service", Namespace: ns, Name: string(br.Name)}, + sharedUC.Ref{Group: gwv1.GroupName, Kind: route.kind, Namespace: route.namespace, Name: route.name}) + if err != nil { + return nil, fmt.Errorf("check ReferenceGrant for backendRef %s/%s: %w", ns, br.Name, err) + } + if !allowed { + t.logger.Warnf("backendRef %s/%s on %s %s/%s is cross-namespace and not permitted; skipping", + ns, br.Name, route.kind, route.namespace, route.name) + continue + } + } + port := int32(0) + if br.Port != nil { + port = int32(*br.Port) + } + weight := int32(1) + if br.Weight != nil { + weight = *br.Weight + } + if weight == 0 { + continue + } + + targetType, err := t.resolveTargetType(ctx, ns, string(br.Name)) + if err != nil { + return nil, err + } + nodeLabels, err := t.resolveTargetNodeLabels(ctx, ns, string(br.Name)) + if err != nil { + return nil, err + } + svcKey := types.NamespacedName{Namespace: ns, Name: string(br.Name)} + resolveOpts := []utils.EndpointResolveOption{ + utils.WithNodeSelector(labels.SelectorFromSet(labels.Set(nodeLabels))), + } + var addrs []utils.EndpointAddress + switch targetType { + case domain.TargetTypeInstance: + addrs, err = t.uc.endpointResolver.ResolveNodePortEndpoints(ctx, svcKey, intstr.FromInt(int(port)), resolveOpts...) + default: + addrs, err = t.uc.endpointResolver.ResolvePodEndpoints(ctx, svcKey, intstr.FromInt(int(port)), resolveOpts...) + } + if err != nil { + return nil, fmt.Errorf("resolve endpoints for backendRef %s/%s (mode=%s): %w", ns, br.Name, targetType, err) + } + + keys = append(keys, pkggw.BackendKey{Namespace: ns, Name: string(br.Name), Port: port, Weight: weight}) + weights = append(weights, pkggw.BackendWeight{Weight: weight, Ready: int32(len(addrs))}) + memberAddrs = append(memberAddrs, addrs) + } + + if len(keys) == 0 { + return nil, fmt.Errorf("%s %s/%s: all backends were dropped (cross-ns or zero-weight)", route.kind, route.namespace, route.name) + } + + scaled := pkggw.ScaleWeights(weights) + members := make([]v1alpha1.PoolMember, 0) + for i, addrs := range memberAddrs { + w := int(scaled[i]) + for _, a := range addrs { + members = append(members, v1alpha1.PoolMember{ + Name: memberName(a.Name), + IP: a.IP, + Port: a.Port, + MonitorPort: a.Port, + Weight: &w, + }) + } + } + sort.SliceStable(members, func(i, j int) bool { + if members[i].IP != members[j].IP { + return members[i].IP < members[j].IP + } + return members[i].Port < members[j].Port + }) + + pool := &v1alpha1.Pool{ + Name: pkggw.SynthPoolName(route.uid, 0, keys), + Protocol: poolProto, + Members: members, + HealthMonitor: v1alpha1.PoolHealthMonitor{ + Protocol: hcProto, + }, + } + + first := keys[0] + if err := t.applyBackendPolicyToPool(ctx, pool, first.Namespace, first.Name); err != nil { + return nil, err + } + if err := t.applyHealthCheckPolicyToPool(ctx, pool, first.Namespace, first.Name); err != nil { + return nil, err + } + return pool, nil +} + +// --- VKS policy resolution + overlays (lean L4 versions) --- + +func (t *nlbBuildTask) resolveBackendPolicy(ctx context.Context, ns, svcName string) (*gwv1alpha1.VKSBackendPolicy, error) { + var list gwv1alpha1.VKSBackendPolicyList + if err := t.uc.k8sClient.List(ctx, &list, listInNamespace(ns)); err != nil { + return nil, fmt.Errorf("list VKSBackendPolicy in %s: %w", ns, err) + } + cands := make([]*gwv1alpha1.VKSBackendPolicy, 0, len(list.Items)) + for i := range list.Items { + cands = append(cands, &list.Items[i]) + } + win, _ := sharedUC.ResolveDirectPolicy(cands, pkggw.PolicyTarget{Group: "", Kind: "Service", Namespace: ns, Name: svcName}) + return win, nil +} + +func (t *nlbBuildTask) resolveHealthCheckPolicy(ctx context.Context, ns, svcName string) (*gwv1alpha1.VKSHealthCheckPolicy, error) { + var list gwv1alpha1.VKSHealthCheckPolicyList + if err := t.uc.k8sClient.List(ctx, &list, listInNamespace(ns)); err != nil { + return nil, fmt.Errorf("list VKSHealthCheckPolicy in %s: %w", ns, err) + } + cands := make([]*gwv1alpha1.VKSHealthCheckPolicy, 0, len(list.Items)) + for i := range list.Items { + cands = append(cands, &list.Items[i]) + } + win, _ := sharedUC.ResolveDirectPolicy(cands, pkggw.PolicyTarget{Group: "", Kind: "Service", Namespace: ns, Name: svcName}) + return win, nil +} + +func (t *nlbBuildTask) resolveTargetType(ctx context.Context, ns, svcName string) (domain.TargetType, error) { + bp, err := t.resolveBackendPolicy(ctx, ns, svcName) + if err != nil { + return "", err + } + if bp != nil && bp.Spec.TargetType != nil { + switch *bp.Spec.TargetType { + case string(domain.TargetTypeIP): + return domain.TargetTypeIP, nil + case string(domain.TargetTypeInstance): + return domain.TargetTypeInstance, nil + } + } + return domain.TargetTypeInstance, nil +} + +func (t *nlbBuildTask) resolveTargetNodeLabels(ctx context.Context, ns, svcName string) (map[string]string, error) { + bp, err := t.resolveBackendPolicy(ctx, ns, svcName) + if err != nil { + return nil, err + } + if bp == nil { + return nil, nil + } + return bp.Spec.TargetNodeLabels, nil +} + +// applyBackendPolicyToPool merges algorithm/stickiness onto the pool. L4 pools +// don't terminate TLS, so EnableTLSEncryption is ignored here. +func (t *nlbBuildTask) applyBackendPolicyToPool(ctx context.Context, pool *v1alpha1.Pool, ns, svcName string) error { + bp, err := t.resolveBackendPolicy(ctx, ns, svcName) + if err != nil { + return err + } + if bp == nil { + return nil + } + if bp.Spec.PoolAlgorithm != nil { + alg := v2.PoolAlgorithm(*bp.Spec.PoolAlgorithm) + pool.Algorithm = &alg + } + if bp.Spec.Stickiness != nil { + v := *bp.Spec.Stickiness + pool.Stickiness = &v + } + // PROXY protocol: switch a TCP pool to the PROXY pool protocol so the cloud + // LB prepends a PROXY header and an L4 backend (e.g. HAProxy/nginx ingress) + // can recover the real client IP. TCP-only — UDP pools are left untouched. + // Mirrors the Service controller's enable-proxy-protocol annotation. Set + // this before the Gateway is created; pool protocol is fixed at create time. + if bp.Spec.ProxyProtocol != nil && *bp.Spec.ProxyProtocol && pool.Protocol == v2.PoolProtocolTCP { + pool.Protocol = v2.PoolProtocolProxy + } + return nil +} + +// applyHealthCheckPolicyToPool overlays VKSHealthCheckPolicy onto the L4 pool's +// monitor. For TCP/PING-UDP the protocol stays as the listener default unless +// the policy explicitly asks for HTTP/HTTPS (an HTTP probe over a TCP pool is +// valid on the vngcloud API); thresholds/interval/timeout/port always apply. +func (t *nlbBuildTask) applyHealthCheckPolicyToPool(ctx context.Context, pool *v1alpha1.Pool, ns, svcName string) error { + hp, err := t.resolveHealthCheckPolicy(ctx, ns, svcName) + if err != nil { + return err + } + if hp == nil { + return nil + } + s := hp.Spec + mon := pool.HealthMonitor // keep the protocol default (TCP / PING-UDP) + switch v2.HealthCheckProtocol(s.Protocol) { + case v2.HealthCheckProtocolHTTP, v2.HealthCheckProtocolHTTPs, v2.HealthCheckProtocolTCP, v2.HealthCheckProtocolPINGUDP: + mon.Protocol = v2.HealthCheckProtocol(s.Protocol) + } + if s.Interval != nil { + mon.Interval = ptrInt(int(s.Interval.Seconds())) + } + if s.Timeout != nil { + mon.Timeout = ptrInt(int(s.Timeout.Seconds())) + } + if s.HealthyThreshold != nil { + mon.HealthyThreshold = ptrInt(int(*s.HealthyThreshold)) + } + if s.UnhealthyThreshold != nil { + mon.UnhealthyThreshold = ptrInt(int(*s.UnhealthyThreshold)) + } + if mon.Protocol == v2.HealthCheckProtocolHTTP || mon.Protocol == v2.HealthCheckProtocolHTTPs { + method := v2.HealthCheckMethodGET + ver := v2.HealthCheckHttpVersionHttp1Minor1 + path := "/" + code := "200" + if s.HTTPHealthCheck != nil { + if s.HTTPHealthCheck.Method != nil { + method = v2.HealthCheckMethod(*s.HTTPHealthCheck.Method) + } + if s.HTTPHealthCheck.HTTPVersion != nil { + ver = v2.HealthCheckHttpVersion(*s.HTTPHealthCheck.HTTPVersion) + } + if s.HTTPHealthCheck.Path != nil { + path = *s.HTTPHealthCheck.Path + } + if s.HTTPHealthCheck.Host != nil { + mon.DomainName = s.HTTPHealthCheck.Host + } + if len(s.HTTPHealthCheck.ExpectedCodes) > 0 { + code = joinExpectedCodes(s.HTTPHealthCheck.ExpectedCodes) + } + } + mon.HealthCheckMethod = &method + mon.HttpVersion = &ver + mon.HealthCheckPath = &path + mon.SuccessCode = &code + } + pool.HealthMonitor = mon + if s.Port != nil { + for i := range pool.Members { + pool.Members[i].MonitorPort = int(*s.Port) + } + } + return nil +} + +// applyListenerPolicy copies L4-relevant listener fields (timeouts, allowedCidrs) +// from the unscoped VKSGatewayPolicy. NLB listeners have no insertHeaders/certs. +func (t *nlbBuildTask) applyListenerPolicy(entry *v1alpha1.Listener) { + p := t.unscopedPolicy + if p == nil { + return + } + if p.Spec.TimeoutClient != nil { + entry.TimeoutClient = ptr32(int32(p.Spec.TimeoutClient.Seconds())) + } + if p.Spec.TimeoutMember != nil { + entry.TimeoutMember = ptr32(int32(p.Spec.TimeoutMember.Seconds())) + } + if p.Spec.TimeoutConnection != nil { + entry.TimeoutConnection = ptr32(int32(p.Spec.TimeoutConnection.Seconds())) + } + if len(p.Spec.AllowedCIDRs) > 0 { + s := joinCommaStrings(p.Spec.AllowedCIDRs) + entry.AllowedCidrs = &s + } +} + +// cloudListenerName: "vks_gw__", ≤50 chars. +func (t *nlbBuildTask) cloudListenerName(l *gwv1.Listener) string { + uid := string(t.gw.UID) + if len(uid) > 8 { + uid = uid[:8] + } + name := fmt.Sprintf("%sgw_%s_%s", domain.VKSResourceNamePrefix, uid, l.Name) + if len(name) > 50 { + name = name[:50] + } + return name +} diff --git a/internal/usecase/gateway_uc/nlb_gateway_uc/build_pools_test.go b/internal/usecase/gateway_uc/nlb_gateway_uc/build_pools_test.go new file mode 100644 index 00000000..0db296b0 --- /dev/null +++ b/internal/usecase/gateway_uc/nlb_gateway_uc/build_pools_test.go @@ -0,0 +1,261 @@ +package nlb_gateway_uc + +import ( + "context" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + gwshared "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +func newTestScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = gwv1.Install(s) + _ = gwv1a2.Install(s) + _ = gwv1beta1.Install(s) + _ = gwv1alpha1.AddToScheme(s) + _ = v1alpha1.AddToScheme(s) + _ = corev1.AddToScheme(s) + return s +} + +// newFakeClient builds a fake client with the L4 route field indexes the +// usecase relies on, seeded with objs. +func newFakeClient(objs ...client.Object) client.Client { + return fake.NewClientBuilder(). + WithScheme(newTestScheme()). + WithStatusSubresource(&gwv1.Gateway{}, &gwv1a2.TCPRoute{}, &gwv1a2.UDPRoute{}). + WithIndex(&gwv1a2.TCPRoute{}, gwshared.IndexTCPRouteByParentGateway, gwshared.IndexTCPRouteByParentFunc). + WithIndex(&gwv1a2.UDPRoute{}, gwshared.IndexUDPRouteByParentGateway, gwshared.IndexUDPRouteByParentFunc). + WithIndex(&gwv1a2.TCPRoute{}, gwshared.IndexTCPRouteByService, gwshared.IndexTCPRouteByServiceFunc). + WithIndex(&gwv1a2.UDPRoute{}, gwshared.IndexUDPRouteByService, gwshared.IndexUDPRouteByServiceFunc). + WithObjects(objs...). + Build() +} + +func newTask(t *testing.T, gw *gwv1.Gateway, ep utils.EndpointResolver, objs ...client.Object) *nlbBuildTask { + uc := &nlbGatewayUseCase{ + k8sRepo: repository.NewMockK8sRepository(t), + vngcloudRepo: repository.NewMockVngCloudRepository(t), + k8sClient: newFakeClient(objs...), + endpointResolver: ep, + clusterId: "cluster-1", + defaultZone: "HCM03-1C", + defaultNetworkId: "net-1", + defaultSubnetId: "subnet-1", + defaultSubnetCIDR: "10.0.0.0/24", + } + return &nlbBuildTask{ + uc: uc, + gw: gw, + logger: logrus.NewEntry(logrus.New()), + nameHelper: utils.NewNameHelper("cluster-1", "gateway", gw.Namespace, gw.Name), + } +} + +func tcpListener(name string, port int) gwv1.Listener { + return gwv1.Listener{Name: gwv1.SectionName(name), Protocol: gwv1.TCPProtocolType, Port: gwv1.PortNumber(port)} +} + +func TestMapL4Protocol(t *testing.T) { + tests := []struct { + proto gwv1.ProtocolType + ok bool + lproto v2.ListenerProtocol + hproto v2.HealthCheckProtocol + }{ + {gwv1.TCPProtocolType, true, v2.ListenerProtocolTCP, v2.HealthCheckProtocolTCP}, + {gwv1.UDPProtocolType, true, v2.ListenerProtocolUDP, v2.HealthCheckProtocolPINGUDP}, + {gwv1.HTTPProtocolType, false, "", ""}, + {gwv1.HTTPSProtocolType, false, "", ""}, + {gwv1.TLSProtocolType, false, "", ""}, + } + for _, tt := range tests { + l, _, h, ok := mapL4Protocol(tt.proto) + assert.Equal(t, tt.ok, ok, string(tt.proto)) + if ok { + assert.Equal(t, tt.lproto, l) + assert.Equal(t, tt.hproto, h) + } + } +} + +func TestSynthesizeL4Pool_NoBackendRefs(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + _, err := task.synthesizeL4Pool(context.Background(), + &l4Route{kind: "TCPRoute", namespace: "prod", name: "r", uid: "u1"}, + v2.PoolProtocolTCP, v2.HealthCheckProtocolTCP) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no backendRefs") +} + +func TestSynthesizeL4Pool_TCP_InstanceMembers(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT(). + ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(6379), mock.Anything). + Return([]utils.EndpointAddress{ + {IP: "10.0.0.1", Port: 31000, Name: "n1"}, + {IP: "10.0.0.2", Port: 31000, Name: "n2"}, + }, nil).Once() + + task := newTask(t, gw, mockEP) + port := gwv1.PortNumber(6379) + route := &l4Route{ + kind: "TCPRoute", namespace: "prod", name: "redis-route", uid: "u-tcp", + backendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "redis", Port: &port}}}, + } + pool, err := task.synthesizeL4Pool(context.Background(), route, v2.PoolProtocolTCP, v2.HealthCheckProtocolTCP) + assert.NoError(t, err) + assert.Equal(t, v2.PoolProtocolTCP, pool.Protocol) + assert.Equal(t, v2.HealthCheckProtocolTCP, pool.HealthMonitor.Protocol) + assert.Len(t, pool.Members, 2) + assert.Equal(t, "10.0.0.1", pool.Members[0].IP) + assert.Equal(t, 31000, pool.Members[0].Port) +} + +func TestSynthesizeL4Pool_UDP_PingHealthCheck(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT(). + ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(53), mock.Anything). + Return([]utils.EndpointAddress{{IP: "10.0.0.9", Port: 32000, Name: "n1"}}, nil).Once() + + task := newTask(t, gw, mockEP) + port := gwv1.PortNumber(53) + route := &l4Route{ + kind: "UDPRoute", namespace: "prod", name: "dns-route", uid: "u-udp", + backendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "coredns", Port: &port}}}, + } + pool, err := task.synthesizeL4Pool(context.Background(), route, v2.PoolProtocolUDP, v2.HealthCheckProtocolPINGUDP) + assert.NoError(t, err) + assert.Equal(t, v2.PoolProtocolUDP, pool.Protocol) + assert.Equal(t, v2.HealthCheckProtocolPINGUDP, pool.HealthMonitor.Protocol) + assert.Len(t, pool.Members, 1) +} + +// resolveTargetNodeLabels feeds VKSBackendPolicy.TargetNodeLabels into the +// endpoint resolver as a node selector, deciding which nodes become LB members +// in instance mode. Mirrors the Service controller's target-node-labels. +func TestSynthesizeL4Pool_TargetNodeLabelsBecomeSelector(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + bp := backendPolicy("prod", "node-pol", "redis") + bp.Spec.TargetNodeLabels = map[string]string{"role": "lb", "zone": "a"} + + var captured utils.EndpointResolveOptions + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT(). + ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(6379), mock.Anything). + Run(func(_ context.Context, _ types.NamespacedName, _ intstr.IntOrString, opts ...utils.EndpointResolveOption) { + captured.ApplyOptions(opts) + }). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 31000, Name: "n1"}}, nil).Once() + + task := newTask(t, gw, mockEP, bp) + port := gwv1.PortNumber(6379) + route := &l4Route{ + kind: "TCPRoute", namespace: "prod", name: "redis-route", uid: "u-tcp", + backendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "redis", Port: &port}}}, + } + _, err := task.synthesizeL4Pool(context.Background(), route, v2.PoolProtocolTCP, v2.HealthCheckProtocolTCP) + assert.NoError(t, err) + assert.NotNil(t, captured.NodeSelector) + // Selector ANDs the labels: a node must carry BOTH to be an LB member. + assert.True(t, captured.NodeSelector.Matches(labels.Set{"role": "lb", "zone": "a"})) + assert.False(t, captured.NodeSelector.Matches(labels.Set{"role": "lb"}), "partial match must be rejected") +} + +// With no VKSBackendPolicy for the backend, target labels are nil, so the +// selector matches every node (LB members = all nodes) — same default as the +// Service controller when target-node-labels is unset. +func TestSynthesizeL4Pool_NoPolicySelectsAllNodes(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + + var captured utils.EndpointResolveOptions + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT(). + ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(6379), mock.Anything). + Run(func(_ context.Context, _ types.NamespacedName, _ intstr.IntOrString, opts ...utils.EndpointResolveOption) { + captured.ApplyOptions(opts) + }). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 31000, Name: "n1"}}, nil).Once() + + task := newTask(t, gw, mockEP) // no VKSBackendPolicy seeded + port := gwv1.PortNumber(6379) + route := &l4Route{ + kind: "TCPRoute", namespace: "prod", name: "redis-route", uid: "u-tcp", + backendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "redis", Port: &port}}}, + } + _, err := task.synthesizeL4Pool(context.Background(), route, v2.PoolProtocolTCP, v2.HealthCheckProtocolTCP) + assert.NoError(t, err) + assert.NotNil(t, captured.NodeSelector) + assert.Empty(t, captured.NodeSelector.String(), "nil target labels -> empty selector") + assert.True(t, captured.NodeSelector.Matches(labels.Set{"any": "node"}), "empty selector matches all nodes") +} + +// A Gateway under the NLB class with only an HTTP (L7) listener has no +// NLB-supported listeners → buildListenersAndPools fails closed. +func TestBuildListenersAndPools_MixedRejectsL7Only(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw", UID: "gw-uid"}, + Spec: gwv1.GatewaySpec{Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }}, + } + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + _, _, err := task.buildListenersAndPools(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no NLB-supported listeners") +} + +// A TCP listener with an attached TCPRoute produces one listener + one pool. +func TestBuildListenersAndPools_TCPHappyPath(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw", UID: "gw-uid"}, + Spec: gwv1.GatewaySpec{Listeners: []gwv1.Listener{tcpListener("tcp", 6379)}}, + } + port := gwv1.PortNumber(6379) + route := &gwv1a2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "redis-route", UID: "r-uid"}, + Spec: gwv1a2.TCPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "gw"}}}, + Rules: []gwv1a2.TCPRouteRule{{ + BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "redis", Port: &port}}}, + }}, + }, + } + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT(). + ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(6379), mock.Anything). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 31000, Name: "n1"}}, nil).Once() + + task := newTask(t, gw, mockEP, route) + pools, listeners, err := task.buildListenersAndPools(context.Background()) + assert.NoError(t, err) + assert.Len(t, listeners, 1) + assert.Len(t, pools, 1) + assert.Equal(t, v2.ListenerProtocolTCP, listeners[0].Protocol) + assert.Equal(t, int32(6379), listeners[0].ProtocolPort) + assert.NotNil(t, listeners[0].DefaultPoolName) + assert.Equal(t, pools[0].Name, *listeners[0].DefaultPoolName) +} diff --git a/internal/usecase/gateway_uc/nlb_gateway_uc/coverage_test.go b/internal/usecase/gateway_uc/nlb_gateway_uc/coverage_test.go new file mode 100644 index 00000000..19360eaf --- /dev/null +++ b/internal/usecase/gateway_uc/nlb_gateway_uc/coverage_test.go @@ -0,0 +1,508 @@ +package nlb_gateway_uc + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + gwv1alpha1 "github.com/vngcloud/vngcloud-load-balancer-controller/api/gateway/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +// --- builders --- + +func svcTargetRef(svc string) gwv1a2.LocalPolicyTargetReference { + return gwv1a2.LocalPolicyTargetReference{Group: "", Kind: "Service", Name: gwv1a2.ObjectName(svc)} +} + +func backendPolicy(ns, name, svc string) *gwv1alpha1.VKSBackendPolicy { + return &gwv1alpha1.VKSBackendPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: gwv1alpha1.VKSBackendPolicySpec{ + TargetRefs: []gwv1alpha1.LocalPolicyTargetReference{svcTargetRef(svc)}, + }, + } +} + +func healthPolicy(ns, name, svc string) *gwv1alpha1.VKSHealthCheckPolicy { + return &gwv1alpha1.VKSHealthCheckPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: gwv1alpha1.VKSHealthCheckPolicySpec{ + TargetRefs: []gwv1alpha1.LocalPolicyTargetReference{svcTargetRef(svc)}, + }, + } +} + +func svcObj(ns, name string) *corev1.Service { + return &corev1.Service{ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}} +} + +// --- pool synthesis: weighted, zero-weight, ip target --- + +func TestSynthesizeL4Pool_WeightedBackends(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT().ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(80), mock.Anything). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 30001, Name: "a"}}, nil).Once() + mockEP.EXPECT().ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(81), mock.Anything). + Return([]utils.EndpointAddress{{IP: "10.0.0.2", Port: 30002, Name: "b"}}, nil).Once() + + task := newTask(t, gw, mockEP) + p80, p81 := gwv1.PortNumber(80), gwv1.PortNumber(81) + w90, w10 := int32(90), int32(10) + route := &l4Route{ + kind: "TCPRoute", namespace: "prod", name: "r", uid: "u", + backendRefs: []gwv1.BackendRef{ + {BackendObjectReference: gwv1.BackendObjectReference{Name: "a", Port: &p80}, Weight: &w90}, + {BackendObjectReference: gwv1.BackendObjectReference{Name: "b", Port: &p81}, Weight: &w10}, + }, + } + pool, err := task.synthesizeL4Pool(context.Background(), route, v2.PoolProtocolTCP, v2.HealthCheckProtocolTCP) + assert.NoError(t, err) + assert.Len(t, pool.Members, 2) + weights := map[int]bool{} + for _, m := range pool.Members { + assert.NotNil(t, m.Weight) + weights[*m.Weight] = true + } + assert.Len(t, weights, 2, "90:10 split -> two distinct scaled member weights") +} + +func TestSynthesizeL4Pool_ZeroWeightDropped(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + mockEP := utils.NewMockEndpointResolver(t) + // Only the non-zero-weight backend is resolved. + mockEP.EXPECT().ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(80), mock.Anything). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 30001, Name: "a"}}, nil).Once() + + task := newTask(t, gw, mockEP) + p80, p81 := gwv1.PortNumber(80), gwv1.PortNumber(81) + w0, w1 := int32(0), int32(1) + route := &l4Route{ + kind: "TCPRoute", namespace: "prod", name: "r", uid: "u", + backendRefs: []gwv1.BackendRef{ + {BackendObjectReference: gwv1.BackendObjectReference{Name: "a", Port: &p80}, Weight: &w1}, + {BackendObjectReference: gwv1.BackendObjectReference{Name: "z", Port: &p81}, Weight: &w0}, + }, + } + pool, err := task.synthesizeL4Pool(context.Background(), route, v2.PoolProtocolTCP, v2.HealthCheckProtocolTCP) + assert.NoError(t, err) + assert.Len(t, pool.Members, 1) + assert.Equal(t, "10.0.0.1", pool.Members[0].IP) +} + +func TestSynthesizeL4Pool_IPTargetType(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + mockEP := utils.NewMockEndpointResolver(t) + // targetType=ip -> ResolvePodEndpoints (not NodePort). + mockEP.EXPECT().ResolvePodEndpoints(mock.Anything, mock.Anything, intstr.FromInt(80), mock.Anything). + Return([]utils.EndpointAddress{{IP: "192.168.0.5", Port: 80, Name: "pod-a"}}, nil).Once() + + bp := backendPolicy("prod", "bp", "a") + bp.Spec.TargetType = ptr.To("ip") + task := newTask(t, gw, mockEP, bp) + + p80 := gwv1.PortNumber(80) + route := &l4Route{ + kind: "TCPRoute", namespace: "prod", name: "r", uid: "u", + backendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "a", Port: &p80}}}, + } + pool, err := task.synthesizeL4Pool(context.Background(), route, v2.PoolProtocolTCP, v2.HealthCheckProtocolTCP) + assert.NoError(t, err) + assert.Len(t, pool.Members, 1) + assert.Equal(t, "192.168.0.5", pool.Members[0].IP) + assert.Equal(t, 80, pool.Members[0].Port) +} + +// --- overlays --- + +func TestApplyBackendPolicyToPool(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + bp := backendPolicy("prod", "bp", "a") + bp.Spec.PoolAlgorithm = ptr.To("LEAST_CONNECTIONS") + bp.Spec.Stickiness = ptr.To(true) + task := newTask(t, gw, utils.NewMockEndpointResolver(t), bp) + + pool := &v1alpha1.Pool{} + assert.NoError(t, task.applyBackendPolicyToPool(context.Background(), pool, "prod", "a")) + assert.NotNil(t, pool.Algorithm) + assert.Equal(t, v2.PoolAlgorithm("LEAST_CONNECTIONS"), *pool.Algorithm) + assert.NotNil(t, pool.Stickiness) + assert.True(t, *pool.Stickiness) +} + +func TestApplyBackendPolicyToPool_ProxyProtocolOnTCP(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + bp := backendPolicy("prod", "bp", "a") + bp.Spec.ProxyProtocol = ptr.To(true) + task := newTask(t, gw, utils.NewMockEndpointResolver(t), bp) + + pool := &v1alpha1.Pool{Protocol: v2.PoolProtocolTCP} + assert.NoError(t, task.applyBackendPolicyToPool(context.Background(), pool, "prod", "a")) + assert.Equal(t, v2.PoolProtocolProxy, pool.Protocol, "TCP pool switches to PROXY when proxyProtocol=true") +} + +func TestApplyBackendPolicyToPool_ProxyProtocolIgnoredOnUDP(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + bp := backendPolicy("prod", "bp", "a") + bp.Spec.ProxyProtocol = ptr.To(true) + task := newTask(t, gw, utils.NewMockEndpointResolver(t), bp) + + pool := &v1alpha1.Pool{Protocol: v2.PoolProtocolUDP} + assert.NoError(t, task.applyBackendPolicyToPool(context.Background(), pool, "prod", "a")) + assert.Equal(t, v2.PoolProtocolUDP, pool.Protocol, "UDP pool is left untouched") +} + +func TestApplyBackendPolicyToPool_NoPolicyNoChange(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + pool := &v1alpha1.Pool{} + assert.NoError(t, task.applyBackendPolicyToPool(context.Background(), pool, "prod", "a")) + assert.Nil(t, pool.Algorithm) + assert.Nil(t, pool.Stickiness) +} + +func TestApplyHealthCheckPolicyToPool_PortAndThresholds(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + hp := healthPolicy("prod", "hp", "a") + hp.Spec.Protocol = "TCP" + hp.Spec.Interval = &metav1.Duration{Duration: 15 * time.Second} + hp.Spec.Timeout = &metav1.Duration{Duration: 4 * time.Second} + hp.Spec.HealthyThreshold = ptr.To(int32(2)) + hp.Spec.UnhealthyThreshold = ptr.To(int32(3)) + hp.Spec.Port = ptr.To(int32(8888)) + task := newTask(t, gw, utils.NewMockEndpointResolver(t), hp) + + pool := &v1alpha1.Pool{ + HealthMonitor: v1alpha1.PoolHealthMonitor{Protocol: v2.HealthCheckProtocolTCP}, + Members: []v1alpha1.PoolMember{{IP: "10.0.0.1", Port: 30001, MonitorPort: 30001}}, + } + assert.NoError(t, task.applyHealthCheckPolicyToPool(context.Background(), pool, "prod", "a")) + assert.Equal(t, v2.HealthCheckProtocolTCP, pool.HealthMonitor.Protocol) + assert.Equal(t, 15, *pool.HealthMonitor.Interval) + assert.Equal(t, 4, *pool.HealthMonitor.Timeout) + assert.Equal(t, 2, *pool.HealthMonitor.HealthyThreshold) + assert.Equal(t, 3, *pool.HealthMonitor.UnhealthyThreshold) + assert.Equal(t, 8888, pool.Members[0].MonitorPort, "port override applies to every member") +} + +func TestApplyHealthCheckPolicyToPool_HTTPProbeOverTCPPool(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + hp := healthPolicy("prod", "hp", "a") + hp.Spec.Protocol = "HTTP" + hp.Spec.HTTPHealthCheck = &gwv1alpha1.VKSHTTPHealthCheck{Path: ptr.To("/healthz")} + task := newTask(t, gw, utils.NewMockEndpointResolver(t), hp) + + pool := &v1alpha1.Pool{HealthMonitor: v1alpha1.PoolHealthMonitor{Protocol: v2.HealthCheckProtocolTCP}} + assert.NoError(t, task.applyHealthCheckPolicyToPool(context.Background(), pool, "prod", "a")) + hm := pool.HealthMonitor + assert.Equal(t, v2.HealthCheckProtocolHTTP, hm.Protocol) + assert.NotNil(t, hm.HealthCheckMethod, "HTTP probe requires method") + assert.NotNil(t, hm.HttpVersion, "HTTP probe requires httpVersion") + assert.Equal(t, "/healthz", *hm.HealthCheckPath) + assert.NotNil(t, hm.SuccessCode, "HTTP probe requires successCode") +} + +func TestApplyListenerPolicy(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{ + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + TimeoutClient: &metav1.Duration{Duration: 30 * time.Second}, + TimeoutMember: &metav1.Duration{Duration: 60 * time.Second}, + AllowedCIDRs: []string{"0.0.0.0/0", "10.0.0.0/8"}, + }, + } + l := &v1alpha1.Listener{} + task.applyListenerPolicy(l) + assert.Equal(t, int32(30), *l.TimeoutClient) + assert.Equal(t, int32(60), *l.TimeoutMember) + assert.NotNil(t, l.AllowedCidrs) + assert.Equal(t, "0.0.0.0/0,10.0.0.0/8", *l.AllowedCidrs) +} + +// --- LB-level spec --- + +func TestApplyLoadBalancerSpec(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{ + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{ + Scheme: ptr.To("Internet"), + IsPOC: ptr.To(false), + Tags: map[string]string{"env": "test"}, + }, + }, + } + lbc := &v1alpha1.LoadBalancerConfig{} + task.applyLoadBalancerSpec(lbc) + assert.NotNil(t, lbc.Spec.Scheme) + assert.Equal(t, v2.LoadBalancerScheme("Internet"), *lbc.Spec.Scheme) + assert.NotNil(t, lbc.Spec.IsPoc) + assert.False(t, *lbc.Spec.IsPoc) + assert.Equal(t, "test", lbc.Spec.Tags["env"]) +} + +// InterVPC needs both the private subnet and the client-subnet zone; the +// zone maps to LBC.Spec.PrivateZoneId (a common.Zone). Mirrors the Service +// controller's private-subnet-id + private-zone-id annotations. +func TestApplyLoadBalancerSpec_InterVPC(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{ + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{ + Scheme: ptr.To("InterVPC"), + PrivateSubnetID: ptr.To("subnet-client"), + PrivateZoneID: ptr.To("HCM03-1A"), + }, + }, + } + lbc := &v1alpha1.LoadBalancerConfig{} + task.applyLoadBalancerSpec(lbc) + assert.Equal(t, v2.LoadBalancerScheme("InterVPC"), *lbc.Spec.Scheme) + assert.Equal(t, "subnet-client", *lbc.Spec.PrivateSubnetId) + if assert.NotNil(t, lbc.Spec.PrivateZoneId) { + assert.Equal(t, "HCM03-1A", string(*lbc.Spec.PrivateZoneId)) + } +} + +func TestApplyLoadBalancerSpec_NilPolicyClears(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) // unscopedPolicy nil + lbc := &v1alpha1.LoadBalancerConfig{Spec: v1alpha1.LoadBalancerConfigSpec{Scheme: ptr.To(v2.LoadBalancerScheme("Internet"))}} + task.applyLoadBalancerSpec(lbc) + assert.Nil(t, lbc.Spec.Scheme, "no policy -> create-only fields cleared") +} + +func TestResolveSubnetAndZone_Default(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + sub, net, zone, cidr, err := task.resolveSubnetAndZone(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "subnet-1", sub) + assert.Equal(t, "net-1", net) + assert.Equal(t, "HCM03-1C", zone) + assert.Equal(t, "10.0.0.0/24", cidr) +} + +func TestResolveSubnetAndZone_ExplicitSubnetID(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{ + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{SubnetID: ptr.To("subnet-custom")}, + }, + } + sub, _, _, _, err := task.resolveSubnetAndZone(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "subnet-custom", sub) +} + +func TestResolveSubnetAndZone_PreferZoneEqualsDefault(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{ + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{PreferZoneID: ptr.To("HCM03-1C")}, + }, + } + sub, _, zone, _, err := task.resolveSubnetAndZone(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "subnet-1", sub) + assert.Equal(t, "HCM03-1C", zone) +} + +func TestResolveLoadBalancerName(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + assert.NotEmpty(t, task.resolveLoadBalancerName(), "default generated name") + + task.unscopedPolicy = &gwv1alpha1.VKSGatewayPolicy{ + Spec: gwv1alpha1.VKSGatewayPolicySpec{ + LoadBalancerSpec: &gwv1alpha1.VKSLoadBalancerSpec{LoadBalancerName: ptr.To("my-nlb")}, + }, + } + assert.Equal(t, "my-nlb", task.resolveLoadBalancerName()) +} + +// --- routes & status --- + +func TestListAttachedRoutes_TCPandUDP(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + p := gwv1.PortNumber(80) + tcp := &gwv1a2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "t"}, + Spec: gwv1a2.TCPRouteSpec{CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "gw"}}}, + Rules: []gwv1a2.TCPRouteRule{{BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "a", Port: &p}}}}}}, + } + udp := &gwv1a2.UDPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "u"}, + Spec: gwv1a2.UDPRouteSpec{CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "gw"}}}, + Rules: []gwv1a2.UDPRouteRule{{BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "b", Port: &p}}}}}}, + } + task := newTask(t, gw, utils.NewMockEndpointResolver(t), tcp, udp) + routes, err := task.listAttachedRoutes(context.Background()) + assert.NoError(t, err) + assert.Len(t, routes, 2) + kinds := map[string]bool{} + for _, r := range routes { + kinds[r.kind] = true + } + assert.True(t, kinds["TCPRoute"]) + assert.True(t, kinds["UDPRoute"]) +} + +func TestRouteResolvedRefs_Resolved(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t), svcObj("prod", "a")) + p := gwv1.PortNumber(80) + r := &l4Route{kind: "TCPRoute", namespace: "prod", name: "r", + backendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "a", Port: &p}}}} + status, reason, _ := task.routeResolvedRefs(context.Background(), r) + assert.Equal(t, metav1.ConditionTrue, status) + assert.Equal(t, string(gwv1.RouteReasonResolvedRefs), reason) +} + +func TestRouteResolvedRefs_BackendNotFound(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) // no Service seeded + p := gwv1.PortNumber(80) + r := &l4Route{kind: "TCPRoute", namespace: "prod", name: "r", + backendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "missing", Port: &p}}}} + status, reason, _ := task.routeResolvedRefs(context.Background(), r) + assert.Equal(t, metav1.ConditionFalse, status) + assert.Equal(t, string(gwv1.RouteReasonBackendNotFound), reason) +} + +func TestRouteResolvedRefs_CrossNsRefNotPermitted(t *testing.T) { + gw := &gwv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}} + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) // no ReferenceGrant + p := gwv1.PortNumber(80) + otherNS := gwv1.Namespace("other") + r := &l4Route{kind: "TCPRoute", namespace: "prod", name: "r", + backendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "a", Namespace: &otherNS, Port: &p}}}} + status, reason, _ := task.routeResolvedRefs(context.Background(), r) + assert.Equal(t, metav1.ConditionFalse, status) + assert.Equal(t, string(gwv1.RouteReasonRefNotPermitted), reason) +} + +func TestRouteAttaches_MultiListener(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{Listeners: []gwv1.Listener{ + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, // unsupported -> ignored + tcpListener("tcp", 6379), + }}, + } + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + sec := gwv1.SectionName("tcp") + r := &l4Route{kind: "TCPRoute", namespace: "prod", parentRefs: []gwv1.ParentReference{{Name: "gw", SectionName: &sec}}} + assert.True(t, task.routeAttaches(r)) +} + +func TestListenerStatuses_MixedProtocols(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw"}, + Spec: gwv1.GatewaySpec{Listeners: []gwv1.Listener{ + tcpListener("tcp", 6379), + {Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80}, + }}, + } + task := newTask(t, gw, utils.NewMockEndpointResolver(t)) + ls := task.listenerStatuses() + assert.Len(t, ls, 2) + byName := map[gwv1.SectionName]gwv1.ListenerStatus{} + for _, s := range ls { + byName[s.Name] = s + } + assert.Equal(t, metav1.ConditionTrue, condStatus(byName["tcp"].Conditions, string(gwv1.ListenerConditionAccepted))) + assert.Equal(t, metav1.ConditionFalse, condStatus(byName["http"].Conditions, string(gwv1.ListenerConditionAccepted))) + assert.Equal(t, string(gwv1.ListenerReasonUnsupportedProtocol), condReason(byName["http"].Conditions, string(gwv1.ListenerConditionAccepted))) +} + +func TestBuildListenersAndPools_UDPHappyPath(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw", UID: "gw-uid"}, + Spec: gwv1.GatewaySpec{Listeners: []gwv1.Listener{ + {Name: "udp", Protocol: gwv1.UDPProtocolType, Port: 5353}, + }}, + } + p := gwv1.PortNumber(5353) + route := &gwv1a2.UDPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "dns", UID: "r-uid"}, + Spec: gwv1a2.UDPRouteSpec{CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "gw"}}}, + Rules: []gwv1a2.UDPRouteRule{{BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "dns", Port: &p}}}}}}, + } + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT().ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(5353), mock.Anything). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 32000, Name: "n"}}, nil).Once() + + task := newTask(t, gw, mockEP, route) + pools, listeners, err := task.buildListenersAndPools(context.Background()) + assert.NoError(t, err) + assert.Len(t, listeners, 1) + assert.Equal(t, v2.ListenerProtocolUDP, listeners[0].Protocol) + assert.Len(t, pools, 1) + assert.Equal(t, v2.PoolProtocolUDP, pools[0].Protocol) + assert.Equal(t, v2.HealthCheckProtocolPINGUDP, pools[0].HealthMonitor.Protocol) +} + +// A listener without an attached route is skipped; another with a route is kept. +func TestBuildListenersAndPools_ListenerWithoutRouteSkipped(t *testing.T) { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gw", UID: "gw-uid"}, + Spec: gwv1.GatewaySpec{Listeners: []gwv1.Listener{ + tcpListener("with", 6379), + tcpListener("without", 7000), + }}, + } + p := gwv1.PortNumber(6379) + sec := gwv1.SectionName("with") + route := &gwv1a2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "r", UID: "r-uid"}, + Spec: gwv1a2.TCPRouteSpec{CommonRouteSpec: gwv1.CommonRouteSpec{ParentRefs: []gwv1.ParentReference{{Name: "gw", SectionName: &sec}}}, + Rules: []gwv1a2.TCPRouteRule{{BackendRefs: []gwv1.BackendRef{{BackendObjectReference: gwv1.BackendObjectReference{Name: "a", Port: &p}}}}}}, + } + mockEP := utils.NewMockEndpointResolver(t) + mockEP.EXPECT().ResolveNodePortEndpoints(mock.Anything, mock.Anything, intstr.FromInt(6379), mock.Anything). + Return([]utils.EndpointAddress{{IP: "10.0.0.1", Port: 30001, Name: "n"}}, nil).Once() + + task := newTask(t, gw, mockEP, route) + _, listeners, err := task.buildListenersAndPools(context.Background()) + assert.NoError(t, err) + assert.Len(t, listeners, 1, "only the listener with an attached route produces a cloud listener") + assert.Equal(t, int32(6379), listeners[0].ProtocolPort) +} + +// --- small helpers --- + +func condStatus(conds []metav1.Condition, t string) metav1.ConditionStatus { + for _, c := range conds { + if c.Type == t { + return c.Status + } + } + return metav1.ConditionUnknown +} + +func condReason(conds []metav1.Condition, t string) string { + for _, c := range conds { + if c.Type == t { + return c.Reason + } + } + return "" +} diff --git a/internal/usecase/gateway_uc/nlb_gateway_uc/helpers.go b/internal/usecase/gateway_uc/nlb_gateway_uc/helpers.go new file mode 100644 index 00000000..dcf17209 --- /dev/null +++ b/internal/usecase/gateway_uc/nlb_gateway_uc/helpers.go @@ -0,0 +1,33 @@ +package nlb_gateway_uc + +import ( + "strings" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" +) + +func ptrInt(v int) *int { return &v } +func ptr32(v int32) *int32 { return &v } +func listInNamespace(ns string) client.ListOption { + return client.InNamespace(ns) +} + +// memberName prefixes a member name with the project-wide "vks_" marker and +// truncates to the cloud's 50-char limit. +func memberName(base string) string { + name := domain.VKSResourceNamePrefix + base + if len(name) > 50 { + name = name[:50] + } + return name +} + +func joinExpectedCodes(codes []string) string { + return strings.Join(codes, ",") +} + +func joinCommaStrings(in []string) string { + return strings.Join(in, ",") +} diff --git a/internal/usecase/gateway_uc/nlb_gateway_uc/list_routes.go b/internal/usecase/gateway_uc/nlb_gateway_uc/list_routes.go new file mode 100644 index 00000000..84650fbb --- /dev/null +++ b/internal/usecase/gateway_uc/nlb_gateway_uc/list_routes.go @@ -0,0 +1,149 @@ +package nlb_gateway_uc + +import ( + "context" + "fmt" + "sort" + + "k8s.io/apimachinery/pkg/fields" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" +) + +// l4Route is a protocol-agnostic view of a TCPRoute or UDPRoute so the listener +// attachment and pool-building logic doesn't branch on kind. backendRefs are +// flattened across all of the route's rules (L4 routes usually have one rule). +type l4Route struct { + kind string // "TCPRoute" | "UDPRoute" + namespace string + name string + uid string + creation int64 // creationTimestamp unix seconds, for oldest-wins + parentRefs []gwv1.ParentReference + backendRefs []gwv1.BackendRef +} + +// listAttachedRoutes returns every TCPRoute and UDPRoute whose parentRefs +// reference this Gateway. Per-listener selection happens in oldestRouteForListener. +func (t *nlbBuildTask) listAttachedRoutes(ctx context.Context) ([]*l4Route, error) { + key := t.gw.Namespace + "/" + t.gw.Name + out := make([]*l4Route, 0) + + var tcp gwv1a2.TCPRouteList + if err := t.uc.k8sClient.List(ctx, &tcp, + client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(shared.IndexTCPRouteByParentGateway, key)}, + ); err != nil { + return nil, fmt.Errorf("list TCPRoutes attached to %s: %w", key, err) + } + for i := range tcp.Items { + r := &tcp.Items[i] + out = append(out, &l4Route{ + kind: "TCPRoute", namespace: r.Namespace, name: r.Name, uid: string(r.UID), + creation: r.CreationTimestamp.Unix(), + parentRefs: r.Spec.ParentRefs, + backendRefs: flattenTCPBackendRefs(r), + }) + } + + var udp gwv1a2.UDPRouteList + if err := t.uc.k8sClient.List(ctx, &udp, + client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(shared.IndexUDPRouteByParentGateway, key)}, + ); err != nil { + return nil, fmt.Errorf("list UDPRoutes attached to %s: %w", key, err) + } + for i := range udp.Items { + r := &udp.Items[i] + out = append(out, &l4Route{ + kind: "UDPRoute", namespace: r.Namespace, name: r.Name, uid: string(r.UID), + creation: r.CreationTimestamp.Unix(), + parentRefs: r.Spec.ParentRefs, + backendRefs: flattenUDPBackendRefs(r), + }) + } + return out, nil +} + +func flattenTCPBackendRefs(r *gwv1a2.TCPRoute) []gwv1.BackendRef { + out := make([]gwv1.BackendRef, 0) + for i := range r.Spec.Rules { + out = append(out, r.Spec.Rules[i].BackendRefs...) + } + return out +} + +func flattenUDPBackendRefs(r *gwv1a2.UDPRoute) []gwv1.BackendRef { + out := make([]gwv1.BackendRef, 0) + for i := range r.Spec.Rules { + out = append(out, r.Spec.Rules[i].BackendRefs...) + } + return out +} + +// oldestRouteForListener returns the oldest L4 route (by creationTimestamp, then +// namespace/name) whose kind matches the listener protocol and whose parentRef +// attaches to this listener. An L4 listener has a single default pool, so when +// several routes target one listener the oldest wins (GEP-style determinism). +func oldestRouteForListener(routes []*l4Route, gw *gwv1.Gateway, l *gwv1.Listener) *l4Route { + wantKind := "" + switch l.Protocol { + case gwv1.TCPProtocolType: + wantKind = "TCPRoute" + case gwv1.UDPProtocolType: + wantKind = "UDPRoute" + default: + return nil + } + + matches := make([]*l4Route, 0) + for _, r := range routes { + if r.kind != wantKind { + continue + } + if routeAttachesToListener(r, gw, l) { + matches = append(matches, r) + } + } + if len(matches) == 0 { + return nil + } + sort.SliceStable(matches, func(i, j int) bool { + if matches[i].creation != matches[j].creation { + return matches[i].creation < matches[j].creation + } + if matches[i].namespace != matches[j].namespace { + return matches[i].namespace < matches[j].namespace + } + return matches[i].name < matches[j].name + }) + return matches[0] +} + +// routeAttachesToListener reports whether one of the route's parentRefs targets +// this Gateway listener: same Gateway, and the parentRef's sectionName (if set) +// names this listener, and the parentRef's port (if set) equals this listener's. +func routeAttachesToListener(r *l4Route, gw *gwv1.Gateway, l *gwv1.Listener) bool { + for i := range r.parentRefs { + p := &r.parentRefs[i] + if p.Kind != nil && *p.Kind != "Gateway" { + continue + } + ns := r.namespace + if p.Namespace != nil { + ns = string(*p.Namespace) + } + if ns != gw.Namespace || string(p.Name) != gw.Name { + continue + } + if p.SectionName != nil && string(*p.SectionName) != string(l.Name) { + continue + } + if p.Port != nil && int32(*p.Port) != int32(l.Port) { + continue + } + return true + } + return false +} diff --git a/internal/usecase/gateway_uc/nlb_gateway_uc/status.go b/internal/usecase/gateway_uc/nlb_gateway_uc/status.go new file mode 100644 index 00000000..db9c628b --- /dev/null +++ b/internal/usecase/gateway_uc/nlb_gateway_uc/status.go @@ -0,0 +1,351 @@ +package nlb_gateway_uc + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/controller/gateway/shared" + sharedUC "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase/gateway_uc/shared" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" +) + +// writeGatewayStatus mirrors the ALB path: Accepted always True (we own the +// class), Programmed reflects LBC readiness, addresses come from LBC.Status. +func (t *nlbBuildTask) writeGatewayStatus(ctx context.Context, lbc *v1alpha1.LoadBalancerConfig, translateErr error) error { + cur := t.gw.DeepCopy() + + accepted := metav1.Condition{ + Type: string(gwv1.GatewayConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gwv1.GatewayReasonAccepted), + Message: "Gateway accepted by controller " + consts.GatewayClassControllerNameNLB, + ObservedGeneration: t.gw.Generation, + LastTransitionTime: metav1.Now(), + } + programmed := metav1.Condition{ + Type: string(gwv1.GatewayConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: string(gwv1.GatewayReasonProgrammed), + Message: "LoadBalancerConfig reconciled successfully", + ObservedGeneration: t.gw.Generation, + LastTransitionTime: metav1.Now(), + } + switch { + case translateErr != nil: + programmed.Status = metav1.ConditionFalse + programmed.Reason = string(gwv1.GatewayReasonPending) + programmed.Message = translateErr.Error() + case lbc == nil: + programmed.Status = metav1.ConditionFalse + programmed.Reason = string(gwv1.GatewayReasonPending) + programmed.Message = "LoadBalancerConfig not yet created" + case !lbcReady(lbc): + programmed.Status = metav1.ConditionFalse + programmed.Reason = string(gwv1.GatewayReasonPending) + programmed.Message = lbcStatusMessage(lbc) + } + + shared.SetCondition(&cur.Status.Conditions, accepted) + shared.SetCondition(&cur.Status.Conditions, programmed) + cur.Status.Addresses = gatewayAddressesFromLBC(lbc) + cur.Status.Listeners = t.listenerStatuses() + + if conditionsEqual(cur.Status.Conditions, t.gw.Status.Conditions) && + gatewayAddressesEqual(cur.Status.Addresses, t.gw.Status.Addresses) { + return nil + } + if err := t.uc.k8sClient.Status().Patch(ctx, cur, client.MergeFrom(t.gw)); err != nil { + return fmt.Errorf("patch Gateway %s/%s status: %w", t.gw.Namespace, t.gw.Name, err) + } + return nil +} + +func (t *nlbBuildTask) listenerStatuses() []gwv1.ListenerStatus { + out := make([]gwv1.ListenerStatus, 0, len(t.gw.Spec.Listeners)) + for i := range t.gw.Spec.Listeners { + l := &t.gw.Spec.Listeners[i] + _, _, _, ok := mapL4Protocol(l.Protocol) + ls := gwv1.ListenerStatus{ + Name: l.Name, + SupportedKinds: supportedKindsForL4(l.Protocol), + } + status := metav1.ConditionTrue + reason := string(gwv1.ListenerReasonAccepted) + if !ok { + status = metav1.ConditionFalse + reason = string(gwv1.ListenerReasonUnsupportedProtocol) + } + shared.SetCondition(&ls.Conditions, metav1.Condition{ + Type: string(gwv1.ListenerConditionAccepted), + Status: status, + Reason: reason, + ObservedGeneration: t.gw.Generation, + LastTransitionTime: metav1.Now(), + }) + out = append(out, ls) + } + return out +} + +func supportedKindsForL4(p gwv1.ProtocolType) []gwv1.RouteGroupKind { + group := gwv1.Group(gwv1.GroupName) + switch p { + case gwv1.TCPProtocolType: + return []gwv1.RouteGroupKind{{Group: &group, Kind: "TCPRoute"}} + case gwv1.UDPProtocolType: + return []gwv1.RouteGroupKind{{Group: &group, Kind: "UDPRoute"}} + } + return nil +} + +// writeRouteStatuses writes Accepted/ResolvedRefs on each attached TCP/UDP route +// for our controllerName. Best-effort; idempotent (patches only on change). +func (t *nlbBuildTask) writeRouteStatuses(ctx context.Context) error { + routes, err := t.listAttachedRoutes(ctx) + if err != nil { + return err + } + var firstErr error + for _, r := range routes { + if err := t.writeOneRouteStatus(ctx, r); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (t *nlbBuildTask) writeOneRouteStatus(ctx context.Context, r *l4Route) error { + accepted := t.routeAttaches(r) + resolved, resolvedReason, resolvedMsg := t.routeResolvedRefs(ctx, r) + + acceptedCond := metav1.Condition{ + Type: string(gwv1.RouteConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gwv1.RouteReasonAccepted), + Message: "Route accepted by NLB listener", + ObservedGeneration: 0, + LastTransitionTime: metav1.Now(), + } + if !accepted { + acceptedCond.Status = metav1.ConditionFalse + acceptedCond.Reason = string(gwv1.RouteReasonNotAllowedByListeners) + acceptedCond.Message = "No NLB listener on the parent Gateway accepts this route" + } + resolvedCond := metav1.Condition{ + Type: string(gwv1.RouteConditionResolvedRefs), + Status: resolved, + Reason: resolvedReason, + Message: resolvedMsg, + ObservedGeneration: 0, + LastTransitionTime: metav1.Now(), + } + + switch r.kind { + case "TCPRoute": + obj := &gwv1a2.TCPRoute{} + if err := t.uc.k8sClient.Get(ctx, types.NamespacedName{Namespace: r.namespace, Name: r.name}, obj); err != nil { + return client.IgnoreNotFound(err) + } + cur := obj.DeepCopy() + cur.Status.Parents = t.mergeRouteParents(r, obj.Status.Parents, obj.Generation, acceptedCond, resolvedCond) + if routeParentsEqual(cur.Status.Parents, obj.Status.Parents) { + return nil + } + return t.uc.k8sClient.Status().Patch(ctx, cur, client.MergeFrom(obj)) + case "UDPRoute": + obj := &gwv1a2.UDPRoute{} + if err := t.uc.k8sClient.Get(ctx, types.NamespacedName{Namespace: r.namespace, Name: r.name}, obj); err != nil { + return client.IgnoreNotFound(err) + } + cur := obj.DeepCopy() + cur.Status.Parents = t.mergeRouteParents(r, obj.Status.Parents, obj.Generation, acceptedCond, resolvedCond) + if routeParentsEqual(cur.Status.Parents, obj.Status.Parents) { + return nil + } + return t.uc.k8sClient.Status().Patch(ctx, cur, client.MergeFrom(obj)) + } + return nil +} + +// mergeRouteParents sets our controller's RouteParentStatus for every parentRef +// targeting this Gateway, preserving other controllers' entries (and our own +// entries for other gateways) verbatim. +func (t *nlbBuildTask) mergeRouteParents(r *l4Route, existing []gwv1.RouteParentStatus, gen int64, conds ...metav1.Condition) []gwv1.RouteParentStatus { + for i := range conds { + conds[i].ObservedGeneration = gen + } + out := make([]gwv1.RouteParentStatus, 0, len(existing)+1) + for _, p := range existing { + if string(p.ControllerName) == consts.GatewayClassControllerNameNLB && t.parentRefTargetsThisGW(r, p.ParentRef) { + continue // replace our own stale entry for this Gateway + } + out = append(out, p) + } + for _, pr := range t.parentRefsTargetingGW(r) { + ps := gwv1.RouteParentStatus{ + ParentRef: pr, + ControllerName: gwv1.GatewayController(consts.GatewayClassControllerNameNLB), + } + for _, c := range conds { + shared.SetCondition(&ps.Conditions, c) + } + out = append(out, ps) + } + return out +} + +// parentRefsTargetingGW returns the route's parentRefs that point at this Gateway. +func (t *nlbBuildTask) parentRefsTargetingGW(r *l4Route) []gwv1.ParentReference { + out := make([]gwv1.ParentReference, 0, len(r.parentRefs)) + for i := range r.parentRefs { + if t.parentRefTargetsThisGW(r, r.parentRefs[i]) { + out = append(out, r.parentRefs[i]) + } + } + return out +} + +func (t *nlbBuildTask) parentRefTargetsThisGW(r *l4Route, pr gwv1.ParentReference) bool { + if pr.Kind != nil && *pr.Kind != "Gateway" { + return false + } + ns := r.namespace + if pr.Namespace != nil { + ns = string(*pr.Namespace) + } + return ns == t.gw.Namespace && string(pr.Name) == t.gw.Name +} + +// routeAttaches reports whether the route attaches to at least one supported +// listener on this Gateway. +func (t *nlbBuildTask) routeAttaches(r *l4Route) bool { + for i := range t.gw.Spec.Listeners { + l := &t.gw.Spec.Listeners[i] + if _, _, _, ok := mapL4Protocol(l.Protocol); !ok { + continue + } + if routeAttachesToListener(r, t.gw, l) { + return true + } + } + return false +} + +// routeResolvedRefs validates the route's backendRefs (cross-ns ReferenceGrant +// + Service existence). +func (t *nlbBuildTask) routeResolvedRefs(ctx context.Context, r *l4Route) (metav1.ConditionStatus, string, string) { + for i := range r.backendRefs { + br := &r.backendRefs[i] + ns := r.namespace + if br.Namespace != nil { + ns = string(*br.Namespace) + } + if ns != r.namespace { + allowed, err := sharedUC.RefGrantAllowed(ctx, t.uc.k8sClient, + sharedUC.Ref{Group: "", Kind: "Service", Namespace: ns, Name: string(br.Name)}, + sharedUC.Ref{Group: gwv1.GroupName, Kind: r.kind, Namespace: r.namespace, Name: r.name}) + if err != nil { + return metav1.ConditionFalse, string(gwv1.RouteReasonRefNotPermitted), err.Error() + } + if !allowed { + return metav1.ConditionFalse, string(gwv1.RouteReasonRefNotPermitted), + fmt.Sprintf("cross-namespace backendRef %s/%s not permitted by any ReferenceGrant", ns, br.Name) + } + } + var svc corev1.Service + if err := t.uc.k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: string(br.Name)}, &svc); err != nil { + if apierrors.IsNotFound(err) { + return metav1.ConditionFalse, string(gwv1.RouteReasonBackendNotFound), + fmt.Sprintf("backend Service %s/%s not found", ns, br.Name) + } + return metav1.ConditionFalse, string(gwv1.RouteReasonBackendNotFound), err.Error() + } + } + return metav1.ConditionTrue, string(gwv1.RouteReasonResolvedRefs), "All backend references resolved" +} + +// --- equality helpers (copied shape from the ALB status path) --- + +func lbcReady(lbc *v1alpha1.LoadBalancerConfig) bool { + for _, c := range lbc.Status.Conditions { + if c.Type == "Ready" { + return c.Status == metav1.ConditionTrue + } + } + return false +} + +func lbcStatusMessage(lbc *v1alpha1.LoadBalancerConfig) string { + if lbc.Status.LastReconcileMessage != "" { + return "LBC: " + lbc.Status.LastReconcileMessage + } + return "LoadBalancerConfig not yet ready" +} + +func gatewayAddressesFromLBC(lbc *v1alpha1.LoadBalancerConfig) []gwv1.GatewayStatusAddress { + if lbc == nil || lbc.Status.Address == nil || *lbc.Status.Address == "" { + return nil + } + at := gwv1.IPAddressType + return []gwv1.GatewayStatusAddress{{Type: &at, Value: *lbc.Status.Address}} +} + +func conditionsEqual(a, b []metav1.Condition) bool { + if len(a) != len(b) { + return false + } + idx := make(map[string]metav1.Condition, len(b)) + for i := range b { + idx[b[i].Type] = b[i] + } + for i := range a { + other, ok := idx[a[i].Type] + if !ok { + return false + } + if a[i].Status != other.Status || a[i].Reason != other.Reason || + a[i].Message != other.Message || a[i].ObservedGeneration != other.ObservedGeneration { + return false + } + } + return true +} + +func gatewayAddressesEqual(a, b []gwv1.GatewayStatusAddress) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Value != b[i].Value { + return false + } + } + return true +} + +func routeParentsEqual(a, b []gwv1.RouteParentStatus) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if string(a[i].ControllerName) != string(b[i].ControllerName) { + return false + } + if string(a[i].ParentRef.Name) != string(b[i].ParentRef.Name) { + return false + } + if !conditionsEqual(a[i].Conditions, b[i].Conditions) { + return false + } + } + return true +} diff --git a/internal/usecase/gateway_uc/nlb_gateway_uc/usecase.go b/internal/usecase/gateway_uc/nlb_gateway_uc/usecase.go new file mode 100644 index 00000000..41e621ce --- /dev/null +++ b/internal/usecase/gateway_uc/nlb_gateway_uc/usecase.go @@ -0,0 +1,248 @@ +// Package nlb_gateway_uc translates a Gateway-API Gateway under the +// vngcloud-nlb GatewayClass into a Layer-4 LoadBalancerConfig CR that the +// existing LBC controller reconciles against the cloud NLB. It is the L4 +// (Phase 2) sibling of alb_gateway_uc: TCP/UDP listeners with one default pool +// each, members resolved from attached TCPRoute/UDPRoute backendRefs. There are +// no L7 policies, certificates, or path matching on this path. +// +// Like the ALB path, the Gateway use case is the only writer to the LBC's Spec; +// the LBC controller remains the only writer to the LBC's Status (and to the +// cloud LB itself). +package nlb_gateway_uc + +import ( + "context" + "errors" + "fmt" + + "github.com/anngdinh/operator-helper/contexts" + "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/common" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/domain" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/repository" + "github.com/vngcloud/vngcloud-load-balancer-controller/internal/usecase" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/consts" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/k8s" + "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/utils" +) + +type nlbGatewayUseCase struct { + k8sRepo repository.K8sRepository + vngcloudRepo repository.VngCloudRepository + endpointResolver utils.EndpointResolver + k8sClient client.Client + + // finalizerManager owns the add/remove-finalizer flow (re-Get + optimistic + // Patch + retry-on-conflict), shared with the non-Gateway controllers. + finalizerManager k8s.FinalizerManager + + clusterId string + defaultNetworkId string + defaultSubnetId string + defaultSubnetCIDR string + defaultZone common.Zone +} + +// NewNLBGatewayUseCase wires the dependencies the NLB Gateway translator needs. +func NewNLBGatewayUseCase( + clusterId string, + k8sRepo repository.K8sRepository, + vngcloudRepo repository.VngCloudRepository, + endpointResolver utils.EndpointResolver, + k8sClient client.Client, + finalizerManager k8s.FinalizerManager, +) usecase.NLBGatewayUseCase { + return &nlbGatewayUseCase{ + clusterId: clusterId, + k8sRepo: k8sRepo, + vngcloudRepo: vngcloudRepo, + endpointResolver: endpointResolver, + k8sClient: k8sClient, + finalizerManager: finalizerManager, + } +} + +// InitNLBGatewayUseCase discovers cluster-default network info and clusterID — +// same shape as alb_gateway_uc.InitALBGatewayUseCase. Returns an error until +// the data is fully populated so the controller's retry loop keeps firing. +func (uc *nlbGatewayUseCase) InitNLBGatewayUseCase(ctx context.Context) error { + logger := contexts.NewContext(ctx).Log() + + if uc.defaultNetworkId != "" && uc.defaultSubnetId != "" && uc.defaultSubnetCIDR != "" && uc.defaultZone != "" && uc.clusterId != "" { + return nil + } + + nodes, err := uc.listNodesForNetworkProbe(ctx) + if err != nil { + return fmt.Errorf("NLB Gateway init: list nodes: %w", err) + } + if len(nodes) == 0 { + return errors.New("NLB Gateway init: cluster has no nodes yet") + } + + if uc.defaultNetworkId == "" || uc.defaultSubnetId == "" || uc.defaultSubnetCIDR == "" || uc.defaultZone == "" { + providerID := utils.GetProviderIdFromNode(nodes[0]) + if providerID == "" { + return errors.New("NLB Gateway init: first node has no providerID") + } + zone, networkID, subnetID, cidr, err := uc.vngcloudRepo.GetServerNetworkInfo(ctx, providerID) + if err != nil { + return fmt.Errorf("NLB Gateway init: probe vngcloud network info: %w", err) + } + uc.defaultZone, uc.defaultNetworkId, uc.defaultSubnetId, uc.defaultSubnetCIDR = zone, networkID, subnetID, cidr + logger.Infof("NLB Gateway init: defaults zone=%s network=%s subnet=%s cidr=%s", zone, networkID, subnetID, cidr) + } + + if uc.clusterId == "" { + clusterID := "" + for _, n := range nodes { + if n != nil && n.Labels != nil && n.Labels["vks.vngcloud.vn/cluster-id"] != "" { + clusterID = n.Labels["vks.vngcloud.vn/cluster-id"] + break + } + } + if clusterID == "" { + return errors.New("NLB Gateway init: no clusterID found; specify --cluster-id or label a node with vks.vngcloud.vn/cluster-id") + } + uc.clusterId = clusterID + logger.Infof("NLB Gateway init: clusterID empty in config, picked up from node label: %s", uc.clusterId) + } + return nil +} + +// EnsureNLBGatewayUseCase is the entry called by the NLB Gateway reconciler. +func (uc *nlbGatewayUseCase) EnsureNLBGatewayUseCase(ctx context.Context, req ctrl.Request) error { + logger := contexts.NewContext(ctx).Log() + + gw := &gwv1.Gateway{} + if err := uc.k8sClient.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: req.Name}, gw); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("get Gateway %s/%s: %w", req.Namespace, req.Name, err) + } + + gc, err := uc.fetchGatewayClass(ctx, gw) + if err != nil { + return err + } + if gc == nil || string(gc.Spec.ControllerName) != consts.GatewayClassControllerNameNLB { + // Not ours — the ALB controller (or another) owns a different class. + return nil + } + + if !gw.DeletionTimestamp.IsZero() { + return uc.handleDeletion(ctx, gw) + } + if err := uc.ensureFinalizer(ctx, gw); err != nil { + return err + } + + task := &nlbBuildTask{ + uc: uc, + logger: logger.WithField("gateway", req.Namespace+"/"+req.Name), + gw: gw, + nameHelper: utils.NewNameHelper(uc.clusterId, "gateway", gw.Namespace, gw.Name), + } + return task.run(ctx) +} + +// DeleteNLBGatewayUseCase is the explicit-delete path; the deletion-finalizer +// path inside Ensure also handles cleanup. +func (uc *nlbGatewayUseCase) DeleteNLBGatewayUseCase(ctx context.Context, req ctrl.Request) error { + gw := &gwv1.Gateway{} + if err := uc.k8sClient.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: req.Name}, gw); err != nil { + if apierrors.IsNotFound(err) || meta.IsNoMatchError(err) { + return nil + } + return fmt.Errorf("get Gateway %s/%s: %w", req.Namespace, req.Name, err) + } + return uc.handleDeletion(ctx, gw) +} + +func (uc *nlbGatewayUseCase) fetchGatewayClass(ctx context.Context, gw *gwv1.Gateway) (*gwv1.GatewayClass, error) { + if gw.Spec.GatewayClassName == "" { + return nil, errors.New("gateway has empty gatewayClassName") + } + gc := &gwv1.GatewayClass{} + if err := uc.k8sClient.Get(ctx, types.NamespacedName{Name: string(gw.Spec.GatewayClassName)}, gc); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get GatewayClass %s: %w", gw.Spec.GatewayClassName, err) + } + return gc, nil +} + +func (uc *nlbGatewayUseCase) listNodesForNetworkProbe(ctx context.Context) ([]*corev1.Node, error) { + nodes := &corev1.NodeList{} + if err := uc.k8sRepo.ListNode(ctx, nodes); err != nil { + return nil, err + } + out := make([]*corev1.Node, 0, len(nodes.Items)) + for i := range nodes.Items { + out = append(out, &nodes.Items[i]) + } + return out, nil +} + +func (uc *nlbGatewayUseCase) ensureFinalizer(ctx context.Context, gw *gwv1.Gateway) error { + if err := uc.finalizerManager.AddFinalizers(ctx, gw, domain.GatewayFinalizer); err != nil { + return fmt.Errorf("add finalizer on Gateway %s/%s: %w", gw.Namespace, gw.Name, err) + } + return nil +} + +// handleDeletion removes the LBC owned by this Gateway and then drops the +// finalizer. The LBC controller's own finalizer guarantees the cloud LB is +// gone before the LBC object is. +// +// RemoveFinalizers re-Gets the Gateway before patching and treats an +// already-deleted object as success, so a racing delete (the stale-UID +// StorageError we used to log) no longer surfaces as a reconcile error. +func (uc *nlbGatewayUseCase) handleDeletion(ctx context.Context, gw *gwv1.Gateway) error { + if err := uc.deleteOwnedLBC(ctx, gw); err != nil { + return err + } + if err := uc.finalizerManager.RemoveFinalizers(ctx, gw, domain.GatewayFinalizer); err != nil { + return fmt.Errorf("remove finalizer on Gateway %s/%s: %w", gw.Namespace, gw.Name, err) + } + return nil +} + +// listOwnedLBCs returns the LBCs owned by this Gateway (by the gateway-uid label). +func (uc *nlbGatewayUseCase) listOwnedLBCs(ctx context.Context, gw *gwv1.Gateway) ([]*v1alpha1.LoadBalancerConfig, error) { + var list v1alpha1.LoadBalancerConfigList + if err := uc.k8sRepo.ListLoadBalancerConfig(ctx, &list, + client.InNamespace(gw.Namespace), + client.MatchingLabels{domain.OwnerLabelGatewayUID: string(gw.UID)}, + ); err != nil { + return nil, fmt.Errorf("list LBCs owned by Gateway %s/%s: %w", gw.Namespace, gw.Name, err) + } + out := make([]*v1alpha1.LoadBalancerConfig, 0, len(list.Items)) + for i := range list.Items { + out = append(out, &list.Items[i]) + } + return out, nil +} + +func (uc *nlbGatewayUseCase) deleteOwnedLBC(ctx context.Context, gw *gwv1.Gateway) error { + lbcs, err := uc.listOwnedLBCs(ctx, gw) + if err != nil { + return err + } + for i := range lbcs { + if err := uc.k8sRepo.DeleteLoadBalancerConfig(ctx, lbcs[i]); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("delete LBC %s/%s: %w", lbcs[i].Namespace, lbcs[i].Name, err) + } + } + return nil +} diff --git a/internal/usecase/gateway_uc/shared/policy_resolver.go b/internal/usecase/gateway_uc/shared/policy_resolver.go new file mode 100644 index 00000000..e45849bc --- /dev/null +++ b/internal/usecase/gateway_uc/shared/policy_resolver.go @@ -0,0 +1,43 @@ +package shared + +import ( + "sort" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +// PolicyObj is the minimal interface a Direct policy must satisfy to participate in resolution. +type PolicyObj interface { + GetName() string + GetNamespace() string + GetCreationTimestamp() metav1.Time + Matches(t pkggw.PolicyTarget) bool +} + +// ResolveDirectPolicy filters policies, sorts by (creationTimestamp, namespace/name) ascending, +// and returns the winner (oldest) plus the losers (Conflicted). +func ResolveDirectPolicy[P PolicyObj](in []P, target pkggw.PolicyTarget) (winner P, losers []P) { + var matches []P + for _, p := range in { + if p.Matches(target) { + matches = append(matches, p) + } + } + if len(matches) == 0 { + var zero P + return zero, nil + } + sort.SliceStable(matches, func(i, j int) bool { + ti := matches[i].GetCreationTimestamp() + tj := matches[j].GetCreationTimestamp() + if !ti.Equal(&tj) { + return ti.Before(&tj) + } + ki := matches[i].GetNamespace() + "/" + matches[i].GetName() + kj := matches[j].GetNamespace() + "/" + matches[j].GetName() + return ki < kj + }) + return matches[0], matches[1:] +} diff --git a/internal/usecase/gateway_uc/shared/policy_resolver_test.go b/internal/usecase/gateway_uc/shared/policy_resolver_test.go new file mode 100644 index 00000000..bd4aec1e --- /dev/null +++ b/internal/usecase/gateway_uc/shared/policy_resolver_test.go @@ -0,0 +1,62 @@ +package shared + +import ( + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkggw "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/gateway" +) + +type fakePolicy struct { + name string + namespace string + section string + targetKey string + created time.Time +} + +func (p *fakePolicy) GetName() string { return p.name } +func (p *fakePolicy) GetNamespace() string { return p.namespace } +func (p *fakePolicy) GetCreationTimestamp() metav1.Time { return metav1.NewTime(p.created) } +func (p *fakePolicy) Matches(target pkggw.PolicyTarget) bool { + return p.namespace == target.Namespace && + p.targetKey == target.Name && + p.section == target.SectionName +} + +func TestResolveDirectPolicy_OldestWins(t *testing.T) { + older := &fakePolicy{name: "a", namespace: "ns", targetKey: "gw1", created: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)} + newer := &fakePolicy{name: "b", namespace: "ns", targetKey: "gw1", created: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)} + in := []*fakePolicy{newer, older} + target := pkggw.PolicyTarget{Namespace: "ns", Name: "gw1"} + win, lose := ResolveDirectPolicy(in, target) + if win == nil || win.GetName() != "a" { + t.Fatalf("want winner=a; got %#v", win) + } + if len(lose) != 1 || lose[0].GetName() != "b" { + t.Fatalf("want losers=[b]; got %#v", lose) + } +} + +func TestResolveDirectPolicy_NoMatch(t *testing.T) { + p := &fakePolicy{name: "a", namespace: "ns", targetKey: "other", created: time.Now()} + win, lose := ResolveDirectPolicy([]*fakePolicy{p}, pkggw.PolicyTarget{Namespace: "ns", Name: "gw1"}) + if win != nil { + t.Fatal("expected no winner") + } + if len(lose) != 0 { + t.Fatal("expected no losers") + } +} + +func TestResolveDirectPolicy_NameTiebreak(t *testing.T) { + same := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + a := &fakePolicy{name: "alpha", namespace: "ns", targetKey: "gw1", created: same} + b := &fakePolicy{name: "beta", namespace: "ns", targetKey: "gw1", created: same} + win, _ := ResolveDirectPolicy([]*fakePolicy{b, a}, pkggw.PolicyTarget{Namespace: "ns", Name: "gw1"}) + if win.GetName() != "alpha" { + t.Fatalf("want alpha; got %s", win.GetName()) + } +} diff --git a/internal/usecase/gateway_uc/shared/refgrant.go b/internal/usecase/gateway_uc/shared/refgrant.go new file mode 100644 index 00000000..295e9de9 --- /dev/null +++ b/internal/usecase/gateway_uc/shared/refgrant.go @@ -0,0 +1,55 @@ +package shared + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" +) + +// Ref identifies one side of a cross-namespace reference. +type Ref struct { + Group, Kind, Namespace, Name string +} + +// RefGrantAllowed reports whether `from` is permitted to reference `to`. +func RefGrantAllowed(ctx context.Context, c client.Client, to, from Ref) (bool, error) { + if to.Namespace == from.Namespace { + return true, nil + } + var grants gwv1beta1.ReferenceGrantList + if err := c.List(ctx, &grants, client.InNamespace(to.Namespace)); err != nil { + return false, err + } + for _, g := range grants.Items { + if !grantFromMatches(g.Spec.From, from) { + continue + } + if !grantToMatches(g.Spec.To, to) { + continue + } + return true, nil + } + return false, nil +} + +func grantFromMatches(froms []gwv1beta1.ReferenceGrantFrom, f Ref) bool { + for _, x := range froms { + if string(x.Group) == f.Group && string(x.Kind) == f.Kind && string(x.Namespace) == f.Namespace { + return true + } + } + return false +} + +func grantToMatches(tos []gwv1beta1.ReferenceGrantTo, t Ref) bool { + for _, x := range tos { + if string(x.Group) != t.Group || string(x.Kind) != t.Kind { + continue + } + if x.Name == nil || string(*x.Name) == t.Name { + return true + } + } + return false +} diff --git a/internal/usecase/gateway_uc/shared/refgrant_test.go b/internal/usecase/gateway_uc/shared/refgrant_test.go new file mode 100644 index 00000000..452c89ee --- /dev/null +++ b/internal/usecase/gateway_uc/shared/refgrant_test.go @@ -0,0 +1,62 @@ +package shared + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" +) + +func testScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = gwv1beta1.Install(s) + return s +} + +func TestRefGrantAllowed_SameNamespace(t *testing.T) { + c := fake.NewClientBuilder().Build() + ok, err := RefGrantAllowed(context.Background(), c, + Ref{Group: "", Kind: "Service", Namespace: "ns", Name: "svc"}, + Ref{Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Namespace: "ns", Name: "r"}, + ) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("same-namespace must always be allowed") + } +} + +func TestRefGrantAllowed_GrantPresent(t *testing.T) { + grant := &gwv1beta1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{Name: "g", Namespace: "backend-ns"}, + Spec: gwv1beta1.ReferenceGrantSpec{ + From: []gwv1beta1.ReferenceGrantFrom{{ + Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Namespace: "route-ns", + }}, + To: []gwv1beta1.ReferenceGrantTo{{Group: "", Kind: "Service"}}, + }, + } + c := fake.NewClientBuilder().WithScheme(testScheme()).WithObjects(grant).Build() + ok, _ := RefGrantAllowed(context.Background(), c, + Ref{Group: "", Kind: "Service", Namespace: "backend-ns", Name: "svc"}, + Ref{Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Namespace: "route-ns", Name: "r"}, + ) + if !ok { + t.Fatal("grant should allow") + } +} + +func TestRefGrantAllowed_NoGrant(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(testScheme()).Build() + ok, _ := RefGrantAllowed(context.Background(), c, + Ref{Group: "", Kind: "Service", Namespace: "backend-ns", Name: "svc"}, + Ref{Group: "gateway.networking.k8s.io", Kind: "HTTPRoute", Namespace: "route-ns", Name: "r"}, + ) + if ok { + t.Fatal("no grant: should deny") + } +} diff --git a/internal/usecase/lbc_uc/delete_pool.go b/internal/usecase/lbc_uc/delete_pool.go index 5c85bb50..3e9f9060 100644 --- a/internal/usecase/lbc_uc/delete_pool.go +++ b/internal/usecase/lbc_uc/delete_pool.go @@ -136,7 +136,7 @@ func (t *defaultModelDeployTask) canDeleteWholePool(ctx context.Context, lbId, p if !t.comparePoolMembers(ctx, updateMembers, convertMemberList(currentListMembers)) { convertMembers := make([]loadbalancerv2.IMemberRequest, 0) for _, member := range updateMembers { - convertMembers = append(convertMembers, loadbalancerv2.NewMember(member.Name, member.IP, member.Port, member.MonitorPort)) + convertMembers = append(convertMembers, buildMemberRequest(member)) } updateMemberOptions := loadbalancerv2.NewUpdatePoolMembersRequest(lbId, poolId).WithMembers(convertMembers...) return false, updateMemberOptions diff --git a/internal/usecase/lbc_uc/deploy_listener.go b/internal/usecase/lbc_uc/deploy_listener.go index 64abe612..48a377e9 100644 --- a/internal/usecase/lbc_uc/deploy_listener.go +++ b/internal/usecase/lbc_uc/deploy_listener.go @@ -354,12 +354,13 @@ func (t *defaultModelDeployTask) buildListenerUpdateRequest(ctx context.Context, // compare listener headers, if they are the same then return true func (t *defaultModelDeployTask) compareHeader(current []entityv2.ListenerInsertHeader, newHeaders []v1alpha1.InsertHeader) bool { - if current == nil && newHeaders == nil { + // Treat nil and empty as equivalent — vngcloud always returns [] + // for "no headers" while a freshly-built spec carries nil. Without + // this collapse, every controller restart produced a phantom + // "headers ([] -> [])" diff and a needless listener update. + if len(current) == 0 && len(newHeaders) == 0 { return true } - if current == nil || newHeaders == nil { - return false - } if len(current) != len(newHeaders) { return false } diff --git a/internal/usecase/lbc_uc/deploy_policy.go b/internal/usecase/lbc_uc/deploy_policy.go index ee2f29cf..364245bf 100644 --- a/internal/usecase/lbc_uc/deploy_policy.go +++ b/internal/usecase/lbc_uc/deploy_policy.go @@ -19,6 +19,24 @@ func (t *defaultModelDeployTask) deployPolicies(ctx context.Context, lbId, liste return nil, err } + // Drop orphans (cloud policies whose name isn't in the spec) before + // creating new ones. Without this we hit POLICY_PER_LB quota on any + // reconcile where a policy was renamed in the spec — the new name + // doesn't match a cloud entry, so deployPolicy issues CreatePolicy, + // the LB rejects it, the deploy errors out, and the existing + // status-driven garbage collector in delete_policy.go never gets a + // chance to run. Free quota first, then add. + if err := t.deleteOrphanPoliciesByName(ctx, lbId, listenerId, policiesSpec, currentPolicies); err != nil { + return nil, err + } + // Re-list so subsequent name lookups see the deletions; otherwise an + // orphan we just deleted could still be matched by a fresh deployPolicy + // call as if it existed. + currentPolicies, err = t.vngcloudRepo.ListPolicyOfListener(ctx, lbId, listenerId) + if err != nil { + return nil, err + } + createdPolicies := make([]v1alpha1.CreatedPolicy, 0, len(policiesSpec)) for _, policySpec := range policiesSpec { createdPolicy, err := t.deployPolicy(ctx, lbId, listenerId, listenerPort, policySpec, newCreatedPools, currentPolicies) @@ -30,6 +48,45 @@ func (t *defaultModelDeployTask) deployPolicies(ctx context.Context, lbId, liste return createdPolicies, nil } +// deleteOrphanPoliciesByName removes any cloud policy whose name isn't +// present in policiesSpec. We are the sole owner of every policy on this +// listener (the LBC is fully driven by Spec), so anything not in spec is +// either an old name for a policy that's been renamed or a leftover from +// a previous reconcile — both safe to delete. +// +// This complements (does NOT replace) deployDeleteRedundantPolicies in +// delete_policy.go. The status-driven GC there still cleans up policy +// IDs we recorded but no longer want; this name-driven sweep additionally +// covers cloud entries we lost track of (e.g. status not yet written +// before a previous failure, or a rename across controller versions). +func (t *defaultModelDeployTask) deleteOrphanPoliciesByName(ctx context.Context, lbId, listenerId string, policiesSpec []v1alpha1.Policy, currentPolicies *entityv2.ListPolicies) error { + if t.lbConfig.Spec.Type == loadbalancerv2.LoadBalancerTypeLayer4 { + return nil + } + if currentPolicies == nil || len(currentPolicies.Items) == 0 { + return nil + } + wanted := make(map[string]struct{}, len(policiesSpec)) + for _, p := range policiesSpec { + wanted[p.Name] = struct{}{} + } + for _, p := range currentPolicies.Items { + if _, keep := wanted[p.Name]; keep { + continue + } + t.logger.Infof("Deleting orphan policy %s (id=%s) on listener %s — not in spec", + p.Name, p.UUID, listenerId) + if err := t.vngcloudRepo.DeletePolicy(ctx, lbId, listenerId, p.UUID); err != nil { + return err + } + if _, err := t.vngcloudRepo.WaitForLBActive(ctx, lbId); err != nil { + t.logger.Error("Failed to wait for loadbalancer active: ", err) + return err + } + } + return nil +} + func (t *defaultModelDeployTask) deployPolicy(ctx context.Context, lbId, listenerId string, listenerPort int, policySpec v1alpha1.Policy, newCreatedPools []v1alpha1.CreatedPool, currentPolicies *entityv2.ListPolicies) (*v1alpha1.CreatedPolicy, error) { searchPolicyByName := func(name string) *entityv2.Policy { for _, p := range currentPolicies.Items { diff --git a/internal/usecase/lbc_uc/deploy_pool.go b/internal/usecase/lbc_uc/deploy_pool.go index c67a5a78..529b4885 100644 --- a/internal/usecase/lbc_uc/deploy_pool.go +++ b/internal/usecase/lbc_uc/deploy_pool.go @@ -120,7 +120,7 @@ func (t *defaultModelDeployTask) deployPool(ctx context.Context, lbId string, po if !t.comparePoolMembers(ctx, updateMembers, convertMemberList(currentPoolMembers)) { convertMembers := make([]loadbalancerv2.IMemberRequest, 0) for _, member := range updateMembers { - convertMembers = append(convertMembers, loadbalancerv2.NewMember(member.Name, member.IP, member.Port, member.MonitorPort)) + convertMembers = append(convertMembers, buildMemberRequest(member)) } updateMemberOptions := loadbalancerv2.NewUpdatePoolMembersRequest(lbId, currentPool.UUID).WithMembers(convertMembers...) @@ -148,13 +148,7 @@ func (t *defaultModelDeployTask) deployPool(ctx context.Context, lbId string, po func (t *defaultModelDeployTask) buildCreatePoolRequest(_ context.Context, lbId string, pool *v1alpha1.Pool) loadbalancerv2.ICreatePoolRequest { convertMembers := make([]loadbalancerv2.IMemberRequest, 0) for _, member := range pool.Members { - convertMembers = append(convertMembers, - loadbalancerv2.NewMember( - member.Name, - member.IP, - member.Port, - member.MonitorPort, - )) + convertMembers = append(convertMembers, buildMemberRequest(member)) } healthMonitor := loadbalancerv2.HealthMonitor{ HealthCheckProtocol: pool.HealthMonitor.Protocol, @@ -359,20 +353,22 @@ func (t *defaultModelDeployTask) buildPoolUpdateRequest(_ context.Context, lbID } // MergePoolMembers merges the pool members -// - keep current member if it is in spec or not created by us +// - keep current member only if it is not in spec and not created by us +// - prefer the spec member (with its desired weight) when its identity matches a current member func (t *defaultModelDeployTask) mergePoolMembers(_ context.Context, createdMembers, currentMembers, poolMemberSpec []v1alpha1.PoolMember) []v1alpha1.PoolMember { mergedPoolMembers := make([]v1alpha1.PoolMember, 0) - // keep current member if it is in spec or not created by us + // keep current member only if it is not managed by us (not in spec and not created by us). + // members that are in spec are added below from poolMemberSpec so the desired weight wins. for _, member := range currentMembers { - if t.checkIfPoolMemberExist(poolMemberSpec, &member) || !t.checkIfPoolMemberExist(createdMembers, &member) { + if !t.checkIfPoolMemberExistByAddress(poolMemberSpec, &member) && !t.checkIfPoolMemberExistByAddress(createdMembers, &member) { mergedPoolMembers = append(mergedPoolMembers, member) } } - // add new members from spec + // add members from spec (carrying their desired weight) for _, member := range poolMemberSpec { - if !t.checkIfPoolMemberExist(mergedPoolMembers, &member) { + if !t.checkIfPoolMemberExistByAddress(mergedPoolMembers, &member) { mergedPoolMembers = append(mergedPoolMembers, member) } } @@ -394,14 +390,13 @@ func (t *defaultModelDeployTask) comparePoolMembers(_ context.Context, poolMembe return true } -// checkIfPoolMemberExist checks if the pool member exists in the pool members. -func (t *defaultModelDeployTask) checkIfPoolMemberExist(list []v1alpha1.PoolMember, member *v1alpha1.PoolMember) bool { +// checkIfPoolMemberExistByAddress checks if a member with the same identity +// (IP+Port+MonitorPort) exists in the list, ignoring weight. Used for +// de-duplication in mergePoolMembers and for "can we cover this member" checks. +func (t *defaultModelDeployTask) checkIfPoolMemberExistByAddress(list []v1alpha1.PoolMember, member *v1alpha1.PoolMember) bool { for _, r := range list { if r.IP == member.IP && r.Port == member.Port && - // r.Backup == member.Backup && - // r.Name == member.Name && - // r.Weight == member.Weight && r.MonitorPort == member.MonitorPort { return true } @@ -409,6 +404,57 @@ func (t *defaultModelDeployTask) checkIfPoolMemberExist(list []v1alpha1.PoolMemb return false } +// checkIfPoolMemberExist checks if the pool member exists in the pool members +// with the same identity AND the same effective weight. Weight must be part of +// equality so that a weight change (e.g. 80 -> 20) is detected and pushed to the +// load balancer. An unset (nil) weight is treated as the default weight, matching +// what buildMemberRequest sends to the API; this keeps the comparison stable and +// prevents a reconcile loop for members whose weight was never specified. +func (t *defaultModelDeployTask) checkIfPoolMemberExist(list []v1alpha1.PoolMember, member *v1alpha1.PoolMember) bool { + for _, r := range list { + if r.IP == member.IP && + r.Port == member.Port && + r.MonitorPort == member.MonitorPort && + effectiveWeight(r.Weight) == effectiveWeight(member.Weight) { + return true + } + } + return false +} + +// defaultMemberWeight is the weight applied when a member does not specify one. +// It mirrors the SDK default used by buildMemberRequest. +const defaultMemberWeight = 1 + +// effectiveWeight returns the weight a member will be deployed with: its explicit +// value, or the default when unset. A non-positive weight is treated as unset, +// since valid load balancer weights are >= 1. +func effectiveWeight(w *int) int { + if w == nil || *w <= 0 { + return defaultMemberWeight + } + return *w +} + +// buildMemberRequest builds an SDK member request carrying the member's weight. +// The SDK's NewMember constructor hardcodes weight to 1 and exposes no setter, so +// the request struct is built directly. Weight defaults to 1 when unset. +func buildMemberRequest(member v1alpha1.PoolMember) loadbalancerv2.IMemberRequest { + weight := effectiveWeight(member.Weight) + backup := false + if member.Backup != nil { + backup = *member.Backup + } + return &loadbalancerv2.Member{ + Name: member.Name, + IpAddress: member.IP, + Port: member.Port, + MonitorPort: member.MonitorPort, + Weight: weight, + Backup: backup, + } +} + func convertMemberList(members *entityv2.ListMembers) []v1alpha1.PoolMember { result := make([]v1alpha1.PoolMember, 0) if members == nil || len(members.Items) == 0 { diff --git a/internal/usecase/lbc_uc/deploy_pool_weight_test.go b/internal/usecase/lbc_uc/deploy_pool_weight_test.go new file mode 100644 index 00000000..b47f3731 --- /dev/null +++ b/internal/usecase/lbc_uc/deploy_pool_weight_test.go @@ -0,0 +1,153 @@ +package lbc_uc + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +// checkIfPoolMemberExist must treat weight as part of member equality, so that +// a weight change (e.g. 80 -> 20) is detected and an update is triggered. +func TestCheckIfPoolMemberExist_WeightAware(t *testing.T) { + task := &defaultModelDeployTask{} + + tests := []struct { + name string + list []v1alpha1.PoolMember + member v1alpha1.PoolMember + want bool + }{ + { + name: "same identity but different weight is not equal", + list: []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80)}, + }, + member: v1alpha1.PoolMember{IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(20)}, + want: false, + }, + { + name: "same identity and same weight is equal", + list: []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80)}, + }, + member: v1alpha1.PoolMember{IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80)}, + want: true, + }, + { + name: "nil weight equals default weight (1), avoiding a reconcile loop", + list: []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(1)}, + }, + member: v1alpha1.PoolMember{IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: nil}, + want: true, + }, + { + name: "nil weight does not match a non-default weight", + list: []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80)}, + }, + member: v1alpha1.PoolMember{IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: nil}, + want: false, + }, + { + name: "both nil weight is equal", + list: []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: nil}, + }, + member: v1alpha1.PoolMember{IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: nil}, + want: true, + }, + { + name: "different identity is not equal", + list: []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80)}, + }, + member: v1alpha1.PoolMember{IP: "10.0.0.2", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80)}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := task.checkIfPoolMemberExist(tt.list, &tt.member) + assert.Equal(t, tt.want, got) + }) + } +} + +// checkIfPoolMemberExistByAddress matches purely on identity (IP+Port+MonitorPort), +// ignoring weight. It is used for de-duplication in mergePoolMembers and for the +// "can we cover this member" check during LB/pool deletion. +func TestCheckIfPoolMemberExistByAddress_IgnoresWeight(t *testing.T) { + task := &defaultModelDeployTask{} + + list := []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80)}, + } + + // Same identity, different weight: still considered "exists by address". + got := task.checkIfPoolMemberExistByAddress(list, &v1alpha1.PoolMember{ + IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(20), + }) + assert.True(t, got, "weight must be ignored for identity matching") + + // Different identity: not found. + got = task.checkIfPoolMemberExistByAddress(list, &v1alpha1.PoolMember{ + IP: "10.0.0.9", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80), + }) + assert.False(t, got) +} + +// comparePoolMembers must report a difference when only the weight changes, +// so the controller pushes the new weights to vLB. +func TestComparePoolMembers_DetectsWeightChange(t *testing.T) { + task := &defaultModelDeployTask{} + + current := []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(50)}, + {IP: "10.0.0.2", Port: 8080, MonitorPort: 8080, Weight: ptr.To(50)}, + } + desired := []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80)}, + {IP: "10.0.0.2", Port: 8080, MonitorPort: 8080, Weight: ptr.To(20)}, + } + + // equal=false means "needs update" + assert.False(t, task.comparePoolMembers(context.Background(), desired, current), + "weight change must be detected as a difference") +} + +// mergePoolMembers must carry the desired (spec) weight, not the current +// cloud weight, when a member's identity matches an existing member. +func TestMergePoolMembers_PrefersSpecWeight(t *testing.T) { + task := &defaultModelDeployTask{} + + created := []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(50)}, + {IP: "10.0.0.2", Port: 8080, MonitorPort: 8080, Weight: ptr.To(50)}, + } + current := []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(50)}, + {IP: "10.0.0.2", Port: 8080, MonitorPort: 8080, Weight: ptr.To(50)}, + } + spec := []v1alpha1.PoolMember{ + {IP: "10.0.0.1", Port: 8080, MonitorPort: 8080, Weight: ptr.To(80)}, + {IP: "10.0.0.2", Port: 8080, MonitorPort: 8080, Weight: ptr.To(20)}, + } + + merged := task.mergePoolMembers(context.Background(), created, current, spec) + + assert.Len(t, merged, 2) + weights := map[string]int{} + for _, m := range merged { + if m.Weight != nil { + weights[m.IP] = *m.Weight + } + } + assert.Equal(t, 80, weights["10.0.0.1"], "merged member must use spec weight") + assert.Equal(t, 20, weights["10.0.0.2"], "merged member must use spec weight") +} diff --git a/mkdocs.yml b/mkdocs.yml index 1e443c90..4b0b3995 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,9 @@ nav: - Upgrade: deploy/upgrade.md - Guide: - Ingress: guide/ingress.md + - Gateway API (ALB): guide/gateway-api.md + - Gateway API (NLB / L4): guide/gateway-nlb.md + - Real client IP (NLB + HAProxy): guide/gateway-nlb-haproxy-realip.md - Service (L4): guide/service.md - LoadBalancerConfig CRD: guide/load-balancer-config.md - NodeSecurityGroup CRD: guide/node-security-group.md diff --git a/pkg/consts/consts.go b/pkg/consts/consts.go index 0db5c8ea..ee14e623 100644 --- a/pkg/consts/consts.go +++ b/pkg/consts/consts.go @@ -44,3 +44,10 @@ const ( ConfigClusterIdAnnotation = "fleet.vngcloud.vn/config-cluster-id" ) + +const ( + GatewayClassControllerNameALB = "gateway.vks.vngcloud.vn/alb" + GatewayClassControllerNameNLB = "gateway.vks.vngcloud.vn/nlb" + GatewayClassNameALB = "vngcloud-alb" + GatewayClassNameNLB = "vngcloud-nlb" +) diff --git a/pkg/gateway/gatewayapi_utils.go b/pkg/gateway/gatewayapi_utils.go new file mode 100644 index 00000000..45706f57 --- /dev/null +++ b/pkg/gateway/gatewayapi_utils.go @@ -0,0 +1,31 @@ +package gateway + +import ( + "regexp" + "strings" +) + +// HostnameToRegex converts a Gateway-API hostname (literal or `*.suffix`) to an +// anchored regex string. Returns isLiteral=true when no wildcard is present. +// Empty input ("") is treated as literal empty (matches anything at the listener layer). +func HostnameToRegex(h string) (string, bool) { + if h == "" { + return "", true + } + if strings.HasPrefix(h, "*.") { + suffix := regexp.QuoteMeta(h[2:]) + return "^[^.]+\\." + suffix + "$", false + } + return "^" + regexp.QuoteMeta(h) + "$", true +} + +// HostnameMatches returns true if a route hostname matches a listener hostname per +// Gateway-API rules. Empty listener hostname matches anything. +func HostnameMatches(listener, route string) bool { + if listener == "" { + return true + } + re, _ := HostnameToRegex(listener) + matched, _ := regexp.MatchString(re, route) + return matched +} diff --git a/pkg/gateway/gatewayapi_utils_test.go b/pkg/gateway/gatewayapi_utils_test.go new file mode 100644 index 00000000..68a72134 --- /dev/null +++ b/pkg/gateway/gatewayapi_utils_test.go @@ -0,0 +1,42 @@ +package gateway + +import "testing" + +func TestHostnameToRegex(t *testing.T) { + cases := []struct { + in string + wantRegex string + wantLiteral bool + }{ + {"foo.example.com", "^foo\\.example\\.com$", true}, + {"*.example.com", "^[^.]+\\.example\\.com$", false}, + {"", "", true}, + } + for _, tc := range cases { + gotRegex, isLiteral := HostnameToRegex(tc.in) + if gotRegex != tc.wantRegex || isLiteral != tc.wantLiteral { + t.Errorf("HostnameToRegex(%q) = (%q, %v); want (%q, %v)", + tc.in, gotRegex, isLiteral, tc.wantRegex, tc.wantLiteral) + } + } +} + +func TestHostnameMatches(t *testing.T) { + cases := []struct { + listener, route string + want bool + }{ + {"", "foo.example.com", true}, + {"foo.example.com", "foo.example.com", true}, + {"foo.example.com", "bar.example.com", false}, + {"*.example.com", "foo.example.com", true}, + {"*.example.com", "foo.bar.example.com", false}, + {"*.example.com", "example.com", false}, + } + for _, tc := range cases { + if got := HostnameMatches(tc.listener, tc.route); got != tc.want { + t.Errorf("HostnameMatches(%q,%q) = %v; want %v", + tc.listener, tc.route, got, tc.want) + } + } +} diff --git a/pkg/gateway/policy_target.go b/pkg/gateway/policy_target.go new file mode 100644 index 00000000..f33cf583 --- /dev/null +++ b/pkg/gateway/policy_target.go @@ -0,0 +1,47 @@ +package gateway + +import ( + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +// PolicyTarget identifies a candidate object a Direct policy may attach to. +type PolicyTarget struct { + Group string + Kind string + Namespace string + Name string + SectionName string +} + +// TargetRefMatches reports whether a LocalPolicyTargetReference matches the given target. +// The policy and the target must be in the same namespace (Direct attachment). +func TargetRefMatches(ref gwv1alpha2.LocalPolicyTargetReference, policyNamespace string, t PolicyTarget) bool { + if string(ref.Group) != t.Group { + return false + } + if string(ref.Kind) != t.Kind { + return false + } + if string(ref.Name) != t.Name { + return false + } + return policyNamespace == t.Namespace +} + +// TargetRefMatchesWithSection compares a LocalPolicyTargetReferenceWithSectionName against a target. +// SectionName matches when both are empty, both equal, or the target's section is empty. +func TargetRefMatchesWithSection( + ref gwv1alpha2.LocalPolicyTargetReferenceWithSectionName, + policyNamespace string, + t PolicyTarget, +) bool { + base := gwv1alpha2.LocalPolicyTargetReference{Group: ref.Group, Kind: ref.Kind, Name: ref.Name} + if !TargetRefMatches(base, policyNamespace, t) { + return false + } + refSection := "" + if ref.SectionName != nil { + refSection = string(*ref.SectionName) + } + return refSection == t.SectionName +} diff --git a/pkg/gateway/policy_target_test.go b/pkg/gateway/policy_target_test.go new file mode 100644 index 00000000..3daeb60d --- /dev/null +++ b/pkg/gateway/policy_target_test.go @@ -0,0 +1,130 @@ +package gateway + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +func TestTargetRefMatches(t *testing.T) { + grp := gwv1alpha2.Group("gateway.networking.k8s.io") + tref := gwv1alpha2.LocalPolicyTargetReference{ + Group: grp, + Kind: gwv1alpha2.Kind("Gateway"), + Name: gwv1alpha2.ObjectName("prod-gw"), + } + ok := TargetRefMatches(tref, "ns", PolicyTarget{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "prod-gw", + Namespace: "ns", + }) + if !ok { + t.Fatal("expected match") + } +} + +func TestTargetRefMatches_DifferentName(t *testing.T) { + tref := gwv1alpha2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "prod-gw", + } + if TargetRefMatches(tref, "ns", PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", + Name: "staging-gw", Namespace: "ns", + }) { + t.Fatal("unexpected match") + } + _ = metav1.Now() +} + +func sectionPtr(s string) *gwv1alpha2.SectionName { + v := gwv1alpha2.SectionName(s) + return &v +} + +func TestTargetRefMatchesWithSection(t *testing.T) { + cases := []struct { + name string + refSection *gwv1alpha2.SectionName + targetSection string + wantMatch bool + }{ + { + name: "both empty sections match", + refSection: nil, + targetSection: "", + wantMatch: true, + }, + { + name: "ref section matches target section", + refSection: sectionPtr("listener-a"), + targetSection: "listener-a", + wantMatch: true, + }, + { + name: "ref section does not match different target section", + refSection: sectionPtr("listener-a"), + targetSection: "listener-b", + wantMatch: false, + }, + { + name: "ref section set but target section empty — no match", + refSection: sectionPtr("listener-a"), + targetSection: "", + wantMatch: false, + }, + { + name: "ref section nil but target section set — no match", + refSection: nil, + targetSection: "listener-a", + wantMatch: false, + }, + } + + base := gwv1alpha2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwv1alpha2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "prod-gw", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ref := base + ref.SectionName = tc.refSection + got := TargetRefMatchesWithSection(ref, "ns", PolicyTarget{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "prod-gw", + Namespace: "ns", + SectionName: tc.targetSection, + }) + if got != tc.wantMatch { + t.Errorf("TargetRefMatchesWithSection = %v; want %v", got, tc.wantMatch) + } + }) + } +} + +func TestTargetRefMatchesWithSection_BaseMismatch(t *testing.T) { + // When the base (group/kind/name/namespace) doesn't match, section is irrelevant. + ref := gwv1alpha2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwv1alpha2.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "other-gw", + }, + SectionName: sectionPtr("listener-a"), + } + got := TargetRefMatchesWithSection(ref, "ns", PolicyTarget{ + Group: "gateway.networking.k8s.io", Kind: "Gateway", + Name: "prod-gw", Namespace: "ns", SectionName: "listener-a", + }) + if got { + t.Fatal("base mismatch should not match") + } +} diff --git a/pkg/gateway/synth_pool.go b/pkg/gateway/synth_pool.go new file mode 100644 index 00000000..413bd013 --- /dev/null +++ b/pkg/gateway/synth_pool.go @@ -0,0 +1,97 @@ +package gateway + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "sort" +) + +// BackendKey is the canonical identity of a backend used in pool naming. +type BackendKey struct { + Namespace string + Name string + Port int32 + Weight int32 +} + +// BackendWeight is a (weight, ready-endpoints) pair fed to ScaleWeights. +type BackendWeight struct { + Weight int32 + Ready int32 +} + +// SynthPoolName returns a deterministic pool name <= 50 chars derived from the +// route UID, rule index, and backend set. Backend ordering is normalized. +// The name starts with the project-wide "vks_" prefix so every cloud-side +// resource the controller creates is greppable; mirrors domain.VKSResourceNamePrefix +// without depending on the internal domain package from this pure helper pkg. +func SynthPoolName(routeUID string, ruleIdx int, backends []BackendKey) string { + sorted := append([]BackendKey(nil), backends...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Namespace != sorted[j].Namespace { + return sorted[i].Namespace < sorted[j].Namespace + } + if sorted[i].Name != sorted[j].Name { + return sorted[i].Name < sorted[j].Name + } + if sorted[i].Port != sorted[j].Port { + return sorted[i].Port < sorted[j].Port + } + return sorted[i].Weight < sorted[j].Weight + }) + h := sha256.New() + for _, b := range sorted { + _, _ = fmt.Fprintf(h, "%s/%s:%d=%d\n", b.Namespace, b.Name, b.Port, b.Weight) + } + digest := hex.EncodeToString(h.Sum(nil)) + prefix := routeUID + if len(prefix) > 8 { + prefix = prefix[:8] + } + name := fmt.Sprintf("vks_gw_%s_%d_%s", prefix, ruleIdx, digest[:5]) + if len(name) > 50 { + name = name[:50] + } + return name +} + +// ScaleWeights computes vngcloud member weights given (declared weight, ready endpoints) +// per backend. Member weights are floored at 1 and the largest member weight is capped at 100. +func ScaleWeights(in []BackendWeight) []int32 { + if len(in) == 0 { + return nil + } + out := make([]int32, len(in)) + var maxW big.Int + for i, b := range in { + n := b.Ready + if n <= 0 { + n = 1 + } + mw := new(big.Int).SetInt64(int64(b.Weight)) + mw.Mul(mw, big.NewInt(100)) + mw.Quo(mw, big.NewInt(int64(n))) + if mw.Sign() <= 0 { + mw.SetInt64(1) + } + if mw.Cmp(&maxW) > 0 { + maxW.Set(mw) + } + out[i] = int32(mw.Int64()) + } + if maxW.Cmp(big.NewInt(100)) > 0 { + scale := big.NewInt(100) + for i := range out { + v := new(big.Int).SetInt64(int64(out[i])) + v.Mul(v, scale) + v.Quo(v, &maxW) + if v.Sign() <= 0 { + v.SetInt64(1) + } + out[i] = int32(v.Int64()) + } + } + return out +} diff --git a/pkg/gateway/synth_pool_test.go b/pkg/gateway/synth_pool_test.go new file mode 100644 index 00000000..c3fd5de3 --- /dev/null +++ b/pkg/gateway/synth_pool_test.go @@ -0,0 +1,51 @@ +package gateway + +import "testing" + +func TestSynthPoolName_Deterministic(t *testing.T) { + a := SynthPoolName("12345678abcdef", 0, []BackendKey{{Namespace: "ns", Name: "a", Port: 80, Weight: 1}}) + b := SynthPoolName("12345678abcdef", 0, []BackendKey{{Namespace: "ns", Name: "a", Port: 80, Weight: 1}}) + if a != b { + t.Fatalf("not deterministic: %s vs %s", a, b) + } + if len(a) > 50 { + t.Errorf("name too long: %d", len(a)) + } +} + +func TestSynthPoolName_OrderInsensitive(t *testing.T) { + backendsA := []BackendKey{ + {Namespace: "ns", Name: "a", Port: 80, Weight: 1}, + {Namespace: "ns", Name: "b", Port: 80, Weight: 1}, + } + backendsB := []BackendKey{ + {Namespace: "ns", Name: "b", Port: 80, Weight: 1}, + {Namespace: "ns", Name: "a", Port: 80, Weight: 1}, + } + a := SynthPoolName("u", 0, backendsA) + b := SynthPoolName("u", 0, backendsB) + if a != b { + t.Fatalf("expected order-insensitive: %s vs %s", a, b) + } +} + +func TestScaleWeights_Cap(t *testing.T) { + out := ScaleWeights([]BackendWeight{{Weight: 1, Ready: 3}, {Weight: 99, Ready: 1}}) + if len(out) != 2 { + t.Fatalf("want 2 entries, got %d", len(out)) + } + for _, w := range out { + if w < 1 || w > 100 { + t.Errorf("weight out of range: %d", w) + } + } +} + +func TestScaleWeights_FloorOne(t *testing.T) { + out := ScaleWeights([]BackendWeight{{Weight: 1, Ready: 1000}, {Weight: 1000, Ready: 1}}) + for _, w := range out { + if w < 1 { + t.Errorf("weight floored below 1: %d", w) + } + } +} diff --git a/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vksbackendpolicies.yaml b/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vksbackendpolicies.yaml new file mode 100644 index 00000000..dcddf1b7 --- /dev/null +++ b/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vksbackendpolicies.yaml @@ -0,0 +1,466 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + labels: + gateway.networking.k8s.io/policy: direct + name: vksbackendpolicies.gateway.vks.vngcloud.vn +spec: + group: gateway.vks.vngcloud.vn + names: + categories: + - gateway-api + kind: VKSBackendPolicy + listKind: VKSBackendPolicyList + plural: vksbackendpolicies + shortNames: + - vksbpolicy + singular: vksbackendpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRefs[*].name + name: Targets + type: string + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + enableTLSEncryption: + type: boolean + manageDFPMembers: + type: boolean + poolAlgorithm: + enum: + - ROUND_ROBIN + - LEAST_CONNECTIONS + - SOURCE_IP + type: string + proxyProtocol: + description: |- + ProxyProtocol makes the cloud LB pool speak HAProxy PROXY protocol to its + members, so an L4 (NLB) backend such as an HAProxy/nginx ingress + controller can recover the real client IP behind the load balancer. + Honored only on the NLB (L4) path and only for TCP pools (UDP is + ignored). Mirrors the Service controller's + `vks.vngcloud.vn/enable-proxy-protocol` annotation. + type: boolean + stickiness: + description: |- + Stickiness enables sticky sessions on the pool. The vngcloud LB API + exposes only an on/off flag; cookie name and TTL are not configurable. + type: boolean + targetNodeLabels: + additionalProperties: + type: string + type: object + targetRefs: + items: + description: |- + LocalPolicyTargetReference identifies an API object to apply a direct or + inherited policy to. This should be used as part of Policy resources + that can target Gateway API resources. For more information on how this + policy attachment model works, and a sample Policy resource, refer to + the policy attachment documentation for Gateway API. + properties: + group: + description: Group is the group of the target resource. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is kind of the target resource. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the target resource. + maxLength: 253 + minLength: 1 + type: string + required: + - group + - kind + - name + type: object + maxItems: 16 + minItems: 1 + type: array + targetType: + description: |- + TargetType selects which addresses the controller resolves into pool + members. "ip" puts pod IPs in the pool (works on flat networks / + Cilium native routing). "instance" puts node IPs + nodePort in the + pool (works on overlay networks where pod IPs aren't routable from + the cloud LB). Default is auto-detected from the cluster CNI — same + rule the Ingress controller uses today (Cilium overlay → instance, + native routing → ip). + + This is a controller-side translation toggle, not a vngcloud LB API + field; the resulting member IPs are what gets stored on the cloud + pool. See design spec §1.6. + enum: + - instance + - ip + type: string + required: + - targetRefs + type: object + status: + properties: + ancestors: + description: Ancestors records reconcile status per controller acting + on this policy. + items: + description: |- + PolicyAncestorStatus describes the status of a route with respect to an + associated Ancestor. + + Ancestors refer to objects that are either the Target of a policy or above it + in terms of object hierarchy. For example, if a policy targets a Service, the + Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and + the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most + useful object to place Policy status on, so we recommend that implementations + SHOULD use Gateway as the PolicyAncestorStatus object unless the designers + have a _very_ good reason otherwise. + + In the context of policy attachment, the Ancestor is used to distinguish which + resource results in a distinct application of this policy. For example, if a policy + targets a Service, it may have a distinct result per attached Gateway. + + Policies targeting the same resource may have different effects depending on the + ancestors of those resources. For example, different Gateways targeting the same + Service may have different capabilities, especially if they have different underlying + implementations. + + For example, in BackendTLSPolicy, the Policy attaches to a Service that is + used as a backend in a HTTPRoute that is itself attached to a Gateway. + In this case, the relevant object for status is the Gateway, and that is the + ancestor object referred to in this status. + + Note that a parent is also an ancestor, so for objects where the parent is the + relevant object for status, this struct SHOULD still be used. + + This struct is intended to be used in a slice that's effectively a map, + with a composite key made up of the AncestorRef and the ControllerName. + properties: + ancestorRef: + description: |- + AncestorRef corresponds with a ParentRef in the spec that this + PolicyAncestorStatus struct describes the status of. + properties: + group: + default: gateway.networking.k8s.io + description: |- + Group is the group of the referent. + When unspecified, "gateway.networking.k8s.io" is inferred. + To set the core API group (such as for a "Service" kind referent), + Group must be explicitly set to "" (empty string). + + Support: Core + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Gateway + description: |- + Kind is kind of the referent. + + There are two kinds of parent resources with "Core" support: + + * Gateway (Gateway conformance profile) + * Service (Mesh conformance profile, ClusterIP Services only) + + Support for other resources is Implementation-Specific. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name is the name of the referent. + + Support: Core + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referent. When unspecified, this refers + to the local namespace of the Route. + + Note that there are specific rules for ParentRefs which cross namespace + boundaries. Cross-namespace references are only valid if they are explicitly + allowed by something in the namespace they are referring to. For example: + Gateway has the AllowedRoutes field, and ReferenceGrant provides a + generic way to enable any other kind of cross-namespace reference. + + + ParentRefs from a Route to a Service in the same namespace are "producer" + routes, which apply default routing rules to inbound connections from + any namespace to the Service. + + ParentRefs from a Route to a Service in a different namespace are + "consumer" routes, and these routing rules are only applied to outbound + connections originating from the same namespace as the Route, for which + the intended destination of the connections are a Service targeted as a + ParentRef of the Route. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port is the network port this Route targets. It can be interpreted + differently based on the type of parent resource. + + When the parent resource is a Gateway, this targets all listeners + listening on the specified port that also support this kind of Route(and + select this Route). It's not recommended to set `Port` unless the + networking behaviors specified in a Route must apply to a specific port + as opposed to a listener(s) whose port(s) may be changed. When both Port + and SectionName are specified, the name and port of the selected listener + must match both specified values. + + + When the parent resource is a Service, this targets a specific port in the + Service spec. When both Port (experimental) and SectionName are specified, + the name and port of the selected port must match both specified values. + + + Implementations MAY choose to support other parent resources. + Implementations supporting other types of parent resources MUST clearly + document how/if Port is interpreted. + + For the purpose of status, an attachment is considered successful as + long as the parent resource accepts it partially. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment + from the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, + the Route MUST be considered detached from the Gateway. + + Support: Extended + format: int32 + maximum: 65535 + minimum: 1 + type: integer + sectionName: + description: |- + SectionName is the name of a section within the target resource. In the + following resources, SectionName is interpreted as the following: + + * Gateway: Listener name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + * Service: Port name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + + Implementations MAY choose to support attaching Routes to other resources. + If that is the case, they MUST clearly document how SectionName is + interpreted. + + When unspecified (empty string), this will reference the entire resource. + For the purpose of status, an attachment is considered successful if at + least one section in the parent resource accepts it. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from + the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, the + Route MUST be considered detached from the Gateway. + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + conditions: + description: Conditions describes the status of the Policy with + respect to the given Ancestor. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + controllerName: + description: |- + ControllerName is a domain/path string that indicates the name of the + controller that wrote this status. This corresponds with the + controllerName field on GatewayClass. + + Example: "example.net/gateway-controller". + + The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are + valid Kubernetes names + (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). + + Controllers MUST populate this field when writing status. Controllers should ensure that + entries to status populated with their ControllerName are cleaned up when they are no + longer necessary. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ + type: string + required: + - ancestorRef + - controllerName + type: object + maxItems: 16 + type: array + x-kubernetes-list-type: atomic + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vksgatewaypolicies.yaml b/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vksgatewaypolicies.yaml new file mode 100644 index 00000000..8a0310a4 --- /dev/null +++ b/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vksgatewaypolicies.yaml @@ -0,0 +1,520 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + labels: + gateway.networking.k8s.io/policy: direct + name: vksgatewaypolicies.gateway.vks.vngcloud.vn +spec: + group: gateway.vks.vngcloud.vn + names: + categories: + - gateway-api + kind: VKSGatewayPolicy + listKind: VKSGatewayPolicyList + plural: vksgatewaypolicies + shortNames: + - vksgwpolicy + singular: vksgatewaypolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRefs[*].name + name: Targets + type: string + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + allowedCidrs: + items: + type: string + type: array + certificateIds: + items: + type: string + type: array + clientCertificateId: + type: string + insertHeaders: + additionalProperties: + type: string + type: object + loadBalancerSpec: + properties: + enableAutoscale: + description: |- + EnableAutoscale toggles cloud-side autoscaling on the LB. Maps to + LBC.Spec.EnableAutoscale — same as the Ingress "enable-autoscale" + annotation. Unset = cloud default (off). + type: boolean + isPOC: + description: |- + IsPOC marks the LB as a POC tier. Maps to LBC.Spec.IsPoc — same as + the Ingress "is-poc" annotation. Unset = production tier. + type: boolean + loadBalancerId: + type: string + loadBalancerName: + description: |- + LoadBalancerName sets a custom name for the cloud LB at creation time. + Maps to LBC.Spec.LoadBalancerName — same as the Ingress + "load-balancer-name" annotation. Written only when the LBC is first + created (the cloud name is then mirrored back into Status); unset = a + controller-generated default name. + type: string + packageId: + type: string + preferZoneId: + description: |- + PreferZoneID pins the LB to a specific availability zone. The controller + resolves a subnet in that zone from a cluster node. Maps to the same + zone/subnet selection the Ingress "prefer-zone-id" annotation drives. + Lower precedence than LoadBalancerID (adoption) and SubnetID. + type: string + privateSubnetId: + description: |- + PrivateSubnetID overrides the LB's private subnet (used by the + InterVPC scheme). Maps to LBC.Spec.PrivateSubnetId — same field the + Ingress controller's "private-subnet-id" annotation writes. + type: string + privateZoneId: + description: |- + PrivateZoneID is the zone of the client subnet, required by the + InterVPC scheme since the client subnet lives in a different VPC. + Maps to LBC.Spec.PrivateZoneId — same as the Service controller's + "private-zone-id" annotation. + type: string + scheme: + enum: + - Internet + - Internal + - InterVPC + type: string + subnetId: + type: string + tags: + additionalProperties: + type: string + type: object + type: object + targetRefs: + items: + description: |- + LocalPolicyTargetReferenceWithSectionName identifies an API object to apply a + direct policy to. This should be used as part of Policy resources that can + target single resources. For more information on how this policy attachment + mode works, and a sample Policy resource, refer to the policy attachment + documentation for Gateway API. + + Note: This should only be used for direct policy attachment when references + to SectionName are actually needed. In all other cases, + LocalPolicyTargetReference should be used. + properties: + group: + description: Group is the group of the target resource. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is kind of the target resource. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the target resource. + maxLength: 253 + minLength: 1 + type: string + sectionName: + description: |- + SectionName is the name of a section within the target resource. When + unspecified, this targetRef targets the entire resource. In the following + resources, SectionName is interpreted as the following: + + * Gateway: Listener name + * HTTPRoute: HTTPRouteRule name + * Service: Port name + + If a SectionName is specified, but does not exist on the targeted object, + the Policy must fail to attach, and the policy implementation should record + a `ResolvedRefs` or similar Condition in the Policy's status. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - group + - kind + - name + type: object + maxItems: 16 + minItems: 1 + type: array + timeoutClient: + type: string + timeoutConnection: + type: string + timeoutMember: + type: string + required: + - targetRefs + type: object + status: + properties: + ancestors: + description: Ancestors records reconcile status per controller acting + on this policy. + items: + description: |- + PolicyAncestorStatus describes the status of a route with respect to an + associated Ancestor. + + Ancestors refer to objects that are either the Target of a policy or above it + in terms of object hierarchy. For example, if a policy targets a Service, the + Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and + the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most + useful object to place Policy status on, so we recommend that implementations + SHOULD use Gateway as the PolicyAncestorStatus object unless the designers + have a _very_ good reason otherwise. + + In the context of policy attachment, the Ancestor is used to distinguish which + resource results in a distinct application of this policy. For example, if a policy + targets a Service, it may have a distinct result per attached Gateway. + + Policies targeting the same resource may have different effects depending on the + ancestors of those resources. For example, different Gateways targeting the same + Service may have different capabilities, especially if they have different underlying + implementations. + + For example, in BackendTLSPolicy, the Policy attaches to a Service that is + used as a backend in a HTTPRoute that is itself attached to a Gateway. + In this case, the relevant object for status is the Gateway, and that is the + ancestor object referred to in this status. + + Note that a parent is also an ancestor, so for objects where the parent is the + relevant object for status, this struct SHOULD still be used. + + This struct is intended to be used in a slice that's effectively a map, + with a composite key made up of the AncestorRef and the ControllerName. + properties: + ancestorRef: + description: |- + AncestorRef corresponds with a ParentRef in the spec that this + PolicyAncestorStatus struct describes the status of. + properties: + group: + default: gateway.networking.k8s.io + description: |- + Group is the group of the referent. + When unspecified, "gateway.networking.k8s.io" is inferred. + To set the core API group (such as for a "Service" kind referent), + Group must be explicitly set to "" (empty string). + + Support: Core + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Gateway + description: |- + Kind is kind of the referent. + + There are two kinds of parent resources with "Core" support: + + * Gateway (Gateway conformance profile) + * Service (Mesh conformance profile, ClusterIP Services only) + + Support for other resources is Implementation-Specific. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name is the name of the referent. + + Support: Core + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referent. When unspecified, this refers + to the local namespace of the Route. + + Note that there are specific rules for ParentRefs which cross namespace + boundaries. Cross-namespace references are only valid if they are explicitly + allowed by something in the namespace they are referring to. For example: + Gateway has the AllowedRoutes field, and ReferenceGrant provides a + generic way to enable any other kind of cross-namespace reference. + + + ParentRefs from a Route to a Service in the same namespace are "producer" + routes, which apply default routing rules to inbound connections from + any namespace to the Service. + + ParentRefs from a Route to a Service in a different namespace are + "consumer" routes, and these routing rules are only applied to outbound + connections originating from the same namespace as the Route, for which + the intended destination of the connections are a Service targeted as a + ParentRef of the Route. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port is the network port this Route targets. It can be interpreted + differently based on the type of parent resource. + + When the parent resource is a Gateway, this targets all listeners + listening on the specified port that also support this kind of Route(and + select this Route). It's not recommended to set `Port` unless the + networking behaviors specified in a Route must apply to a specific port + as opposed to a listener(s) whose port(s) may be changed. When both Port + and SectionName are specified, the name and port of the selected listener + must match both specified values. + + + When the parent resource is a Service, this targets a specific port in the + Service spec. When both Port (experimental) and SectionName are specified, + the name and port of the selected port must match both specified values. + + + Implementations MAY choose to support other parent resources. + Implementations supporting other types of parent resources MUST clearly + document how/if Port is interpreted. + + For the purpose of status, an attachment is considered successful as + long as the parent resource accepts it partially. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment + from the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, + the Route MUST be considered detached from the Gateway. + + Support: Extended + format: int32 + maximum: 65535 + minimum: 1 + type: integer + sectionName: + description: |- + SectionName is the name of a section within the target resource. In the + following resources, SectionName is interpreted as the following: + + * Gateway: Listener name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + * Service: Port name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + + Implementations MAY choose to support attaching Routes to other resources. + If that is the case, they MUST clearly document how SectionName is + interpreted. + + When unspecified (empty string), this will reference the entire resource. + For the purpose of status, an attachment is considered successful if at + least one section in the parent resource accepts it. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from + the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, the + Route MUST be considered detached from the Gateway. + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + conditions: + description: Conditions describes the status of the Policy with + respect to the given Ancestor. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + controllerName: + description: |- + ControllerName is a domain/path string that indicates the name of the + controller that wrote this status. This corresponds with the + controllerName field on GatewayClass. + + Example: "example.net/gateway-controller". + + The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are + valid Kubernetes names + (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). + + Controllers MUST populate this field when writing status. Controllers should ensure that + entries to status populated with their ControllerName are cleaned up when they are no + longer necessary. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ + type: string + required: + - ancestorRef + - controllerName + type: object + maxItems: 16 + type: array + x-kubernetes-list-type: atomic + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vkshealthcheckpolicies.yaml b/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vkshealthcheckpolicies.yaml new file mode 100644 index 00000000..02ca769a --- /dev/null +++ b/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vkshealthcheckpolicies.yaml @@ -0,0 +1,481 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + labels: + gateway.networking.k8s.io/policy: direct + name: vkshealthcheckpolicies.gateway.vks.vngcloud.vn +spec: + group: gateway.vks.vngcloud.vn + names: + categories: + - gateway-api + kind: VKSHealthCheckPolicy + listKind: VKSHealthCheckPolicyList + plural: vkshealthcheckpolicies + shortNames: + - vkshcpolicy + singular: vkshealthcheckpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.protocol + name: Protocol + type: string + - jsonPath: .spec.targetRefs[*].name + name: Targets + type: string + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + healthyThreshold: + format: int32 + type: integer + httpHealthCheck: + properties: + expectedCodes: + items: + type: string + type: array + host: + type: string + httpVersion: + description: |- + HTTPVersion is the HTTP version used for the probe. HTTP/HTTPS protocol + only; ignored for TCP. Defaults to "1.1". Mirrors the Ingress + `vks.vngcloud.vn/healthcheck-http-version` annotation. + enum: + - "1.0" + - "1.1" + type: string + method: + description: |- + Method is the HTTP method used for the probe. HTTP/HTTPS protocol only; + ignored for TCP. Defaults to GET. Mirrors the Ingress + `vks.vngcloud.vn/healthcheck-http-method` annotation. + enum: + - GET + - PUT + - POST + type: string + path: + type: string + type: object + interval: + type: string + port: + description: |- + Port overrides the health-check (monitor) port on every pool member fed + by the targeted Service. When unset, each member is probed on its own + traffic port. Mirrors the Ingress `vks.vngcloud.vn/healthcheck-port` + annotation; applies to all protocols. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + protocol: + enum: + - HTTP + - HTTPS + - TCP + type: string + targetRefs: + items: + description: |- + LocalPolicyTargetReference identifies an API object to apply a direct or + inherited policy to. This should be used as part of Policy resources + that can target Gateway API resources. For more information on how this + policy attachment model works, and a sample Policy resource, refer to + the policy attachment documentation for Gateway API. + properties: + group: + description: Group is the group of the target resource. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is kind of the target resource. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the target resource. + maxLength: 253 + minLength: 1 + type: string + required: + - group + - kind + - name + type: object + maxItems: 16 + minItems: 1 + type: array + timeout: + type: string + unhealthyThreshold: + format: int32 + type: integer + required: + - protocol + - targetRefs + type: object + status: + properties: + ancestors: + description: Ancestors records reconcile status per controller acting + on this policy. + items: + description: |- + PolicyAncestorStatus describes the status of a route with respect to an + associated Ancestor. + + Ancestors refer to objects that are either the Target of a policy or above it + in terms of object hierarchy. For example, if a policy targets a Service, the + Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and + the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most + useful object to place Policy status on, so we recommend that implementations + SHOULD use Gateway as the PolicyAncestorStatus object unless the designers + have a _very_ good reason otherwise. + + In the context of policy attachment, the Ancestor is used to distinguish which + resource results in a distinct application of this policy. For example, if a policy + targets a Service, it may have a distinct result per attached Gateway. + + Policies targeting the same resource may have different effects depending on the + ancestors of those resources. For example, different Gateways targeting the same + Service may have different capabilities, especially if they have different underlying + implementations. + + For example, in BackendTLSPolicy, the Policy attaches to a Service that is + used as a backend in a HTTPRoute that is itself attached to a Gateway. + In this case, the relevant object for status is the Gateway, and that is the + ancestor object referred to in this status. + + Note that a parent is also an ancestor, so for objects where the parent is the + relevant object for status, this struct SHOULD still be used. + + This struct is intended to be used in a slice that's effectively a map, + with a composite key made up of the AncestorRef and the ControllerName. + properties: + ancestorRef: + description: |- + AncestorRef corresponds with a ParentRef in the spec that this + PolicyAncestorStatus struct describes the status of. + properties: + group: + default: gateway.networking.k8s.io + description: |- + Group is the group of the referent. + When unspecified, "gateway.networking.k8s.io" is inferred. + To set the core API group (such as for a "Service" kind referent), + Group must be explicitly set to "" (empty string). + + Support: Core + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Gateway + description: |- + Kind is kind of the referent. + + There are two kinds of parent resources with "Core" support: + + * Gateway (Gateway conformance profile) + * Service (Mesh conformance profile, ClusterIP Services only) + + Support for other resources is Implementation-Specific. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name is the name of the referent. + + Support: Core + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referent. When unspecified, this refers + to the local namespace of the Route. + + Note that there are specific rules for ParentRefs which cross namespace + boundaries. Cross-namespace references are only valid if they are explicitly + allowed by something in the namespace they are referring to. For example: + Gateway has the AllowedRoutes field, and ReferenceGrant provides a + generic way to enable any other kind of cross-namespace reference. + + + ParentRefs from a Route to a Service in the same namespace are "producer" + routes, which apply default routing rules to inbound connections from + any namespace to the Service. + + ParentRefs from a Route to a Service in a different namespace are + "consumer" routes, and these routing rules are only applied to outbound + connections originating from the same namespace as the Route, for which + the intended destination of the connections are a Service targeted as a + ParentRef of the Route. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port is the network port this Route targets. It can be interpreted + differently based on the type of parent resource. + + When the parent resource is a Gateway, this targets all listeners + listening on the specified port that also support this kind of Route(and + select this Route). It's not recommended to set `Port` unless the + networking behaviors specified in a Route must apply to a specific port + as opposed to a listener(s) whose port(s) may be changed. When both Port + and SectionName are specified, the name and port of the selected listener + must match both specified values. + + + When the parent resource is a Service, this targets a specific port in the + Service spec. When both Port (experimental) and SectionName are specified, + the name and port of the selected port must match both specified values. + + + Implementations MAY choose to support other parent resources. + Implementations supporting other types of parent resources MUST clearly + document how/if Port is interpreted. + + For the purpose of status, an attachment is considered successful as + long as the parent resource accepts it partially. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment + from the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, + the Route MUST be considered detached from the Gateway. + + Support: Extended + format: int32 + maximum: 65535 + minimum: 1 + type: integer + sectionName: + description: |- + SectionName is the name of a section within the target resource. In the + following resources, SectionName is interpreted as the following: + + * Gateway: Listener name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + * Service: Port name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + + Implementations MAY choose to support attaching Routes to other resources. + If that is the case, they MUST clearly document how SectionName is + interpreted. + + When unspecified (empty string), this will reference the entire resource. + For the purpose of status, an attachment is considered successful if at + least one section in the parent resource accepts it. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from + the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, the + Route MUST be considered detached from the Gateway. + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + conditions: + description: Conditions describes the status of the Policy with + respect to the given Ancestor. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + controllerName: + description: |- + ControllerName is a domain/path string that indicates the name of the + controller that wrote this status. This corresponds with the + controllerName field on GatewayClass. + + Example: "example.net/gateway-controller". + + The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are + valid Kubernetes names + (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). + + Controllers MUST populate this field when writing status. Controllers should ensure that + entries to status populated with their ControllerName are cleaned up when they are no + longer necessary. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ + type: string + required: + - ancestorRef + - controllerName + type: object + maxItems: 16 + type: array + x-kubernetes-list-type: atomic + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vksroutepolicies.yaml b/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vksroutepolicies.yaml new file mode 100644 index 00000000..b16e6971 --- /dev/null +++ b/pkg/k8s/apis/vks.vngcloud.vn/crds/gateway.vks.vngcloud.vn_vksroutepolicies.yaml @@ -0,0 +1,469 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + labels: + gateway.networking.k8s.io/policy: direct + name: vksroutepolicies.gateway.vks.vngcloud.vn +spec: + group: gateway.vks.vngcloud.vn + names: + categories: + - gateway-api + kind: VKSRoutePolicy + listKind: VKSRoutePolicyList + plural: vksroutepolicies + shortNames: + - vksroutepolicy + singular: vksroutepolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.targetRefs[*].name + name: Targets + type: string + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + actions: + items: + properties: + redirect: + properties: + httpCode: + format: int32 + type: integer + keepQueryString: + type: boolean + url: + type: string + required: + - url + type: object + type: + enum: + - Reject + - Redirect + type: string + required: + - type + type: object + type: array + position: + format: int32 + type: integer + targetRefs: + items: + description: |- + LocalPolicyTargetReferenceWithSectionName identifies an API object to apply a + direct policy to. This should be used as part of Policy resources that can + target single resources. For more information on how this policy attachment + mode works, and a sample Policy resource, refer to the policy attachment + documentation for Gateway API. + + Note: This should only be used for direct policy attachment when references + to SectionName are actually needed. In all other cases, + LocalPolicyTargetReference should be used. + properties: + group: + description: Group is the group of the target resource. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + description: Kind is kind of the target resource. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the target resource. + maxLength: 253 + minLength: 1 + type: string + sectionName: + description: |- + SectionName is the name of a section within the target resource. When + unspecified, this targetRef targets the entire resource. In the following + resources, SectionName is interpreted as the following: + + * Gateway: Listener name + * HTTPRoute: HTTPRouteRule name + * Service: Port name + + If a SectionName is specified, but does not exist on the targeted object, + the Policy must fail to attach, and the policy implementation should record + a `ResolvedRefs` or similar Condition in the Policy's status. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - group + - kind + - name + type: object + maxItems: 16 + minItems: 1 + type: array + required: + - targetRefs + type: object + status: + properties: + ancestors: + description: Ancestors records reconcile status per controller acting + on this policy. + items: + description: |- + PolicyAncestorStatus describes the status of a route with respect to an + associated Ancestor. + + Ancestors refer to objects that are either the Target of a policy or above it + in terms of object hierarchy. For example, if a policy targets a Service, the + Policy's Ancestors are, in order, the Service, the HTTPRoute, the Gateway, and + the GatewayClass. Almost always, in this hierarchy, the Gateway will be the most + useful object to place Policy status on, so we recommend that implementations + SHOULD use Gateway as the PolicyAncestorStatus object unless the designers + have a _very_ good reason otherwise. + + In the context of policy attachment, the Ancestor is used to distinguish which + resource results in a distinct application of this policy. For example, if a policy + targets a Service, it may have a distinct result per attached Gateway. + + Policies targeting the same resource may have different effects depending on the + ancestors of those resources. For example, different Gateways targeting the same + Service may have different capabilities, especially if they have different underlying + implementations. + + For example, in BackendTLSPolicy, the Policy attaches to a Service that is + used as a backend in a HTTPRoute that is itself attached to a Gateway. + In this case, the relevant object for status is the Gateway, and that is the + ancestor object referred to in this status. + + Note that a parent is also an ancestor, so for objects where the parent is the + relevant object for status, this struct SHOULD still be used. + + This struct is intended to be used in a slice that's effectively a map, + with a composite key made up of the AncestorRef and the ControllerName. + properties: + ancestorRef: + description: |- + AncestorRef corresponds with a ParentRef in the spec that this + PolicyAncestorStatus struct describes the status of. + properties: + group: + default: gateway.networking.k8s.io + description: |- + Group is the group of the referent. + When unspecified, "gateway.networking.k8s.io" is inferred. + To set the core API group (such as for a "Service" kind referent), + Group must be explicitly set to "" (empty string). + + Support: Core + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Gateway + description: |- + Kind is kind of the referent. + + There are two kinds of parent resources with "Core" support: + + * Gateway (Gateway conformance profile) + * Service (Mesh conformance profile, ClusterIP Services only) + + Support for other resources is Implementation-Specific. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name is the name of the referent. + + Support: Core + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referent. When unspecified, this refers + to the local namespace of the Route. + + Note that there are specific rules for ParentRefs which cross namespace + boundaries. Cross-namespace references are only valid if they are explicitly + allowed by something in the namespace they are referring to. For example: + Gateway has the AllowedRoutes field, and ReferenceGrant provides a + generic way to enable any other kind of cross-namespace reference. + + + ParentRefs from a Route to a Service in the same namespace are "producer" + routes, which apply default routing rules to inbound connections from + any namespace to the Service. + + ParentRefs from a Route to a Service in a different namespace are + "consumer" routes, and these routing rules are only applied to outbound + connections originating from the same namespace as the Route, for which + the intended destination of the connections are a Service targeted as a + ParentRef of the Route. + + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + port: + description: |- + Port is the network port this Route targets. It can be interpreted + differently based on the type of parent resource. + + When the parent resource is a Gateway, this targets all listeners + listening on the specified port that also support this kind of Route(and + select this Route). It's not recommended to set `Port` unless the + networking behaviors specified in a Route must apply to a specific port + as opposed to a listener(s) whose port(s) may be changed. When both Port + and SectionName are specified, the name and port of the selected listener + must match both specified values. + + + When the parent resource is a Service, this targets a specific port in the + Service spec. When both Port (experimental) and SectionName are specified, + the name and port of the selected port must match both specified values. + + + Implementations MAY choose to support other parent resources. + Implementations supporting other types of parent resources MUST clearly + document how/if Port is interpreted. + + For the purpose of status, an attachment is considered successful as + long as the parent resource accepts it partially. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment + from the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, + the Route MUST be considered detached from the Gateway. + + Support: Extended + format: int32 + maximum: 65535 + minimum: 1 + type: integer + sectionName: + description: |- + SectionName is the name of a section within the target resource. In the + following resources, SectionName is interpreted as the following: + + * Gateway: Listener name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + * Service: Port name. When both Port (experimental) and SectionName + are specified, the name and port of the selected listener must match + both specified values. + + Implementations MAY choose to support attaching Routes to other resources. + If that is the case, they MUST clearly document how SectionName is + interpreted. + + When unspecified (empty string), this will reference the entire resource. + For the purpose of status, an attachment is considered successful if at + least one section in the parent resource accepts it. For example, Gateway + listeners can restrict which Routes can attach to them by Route kind, + namespace, or hostname. If 1 of 2 Gateway listeners accept attachment from + the referencing Route, the Route MUST be considered successfully + attached. If no Gateway listeners accept attachment from this Route, the + Route MUST be considered detached from the Gateway. + + Support: Core + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + conditions: + description: Conditions describes the status of the Policy with + respect to the given Ancestor. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + controllerName: + description: |- + ControllerName is a domain/path string that indicates the name of the + controller that wrote this status. This corresponds with the + controllerName field on GatewayClass. + + Example: "example.net/gateway-controller". + + The format of this field is DOMAIN "/" PATH, where DOMAIN and PATH are + valid Kubernetes names + (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). + + Controllers MUST populate this field when writing status. Controllers should ensure that + entries to status populated with their ControllerName are cleaned up when they are no + longer necessary. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/[A-Za-z0-9\/\-._~%!$&'()*+,;=:]+$ + type: string + required: + - ancestorRef + - controllerName + type: object + maxItems: 16 + type: array + x-kubernetes-list-type: atomic + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/k8s/apis/vks.vngcloud.vn/register.go b/pkg/k8s/apis/vks.vngcloud.vn/register.go index 8cebd1e5..2bc03c76 100644 --- a/pkg/k8s/apis/vks.vngcloud.vn/register.go +++ b/pkg/k8s/apis/vks.vngcloud.vn/register.go @@ -32,6 +32,18 @@ var ( //go:embed crds/vks.vngcloud.vn_globalloadbalancerconfigs.yaml globalloadbalancerconfigsCRDBytes []byte + + //go:embed crds/gateway.vks.vngcloud.vn_vksgatewaypolicies.yaml + vksgatewaypoliciesCRDBytes []byte + + //go:embed crds/gateway.vks.vngcloud.vn_vksbackendpolicies.yaml + vksbackendpoliciesCRDBytes []byte + + //go:embed crds/gateway.vks.vngcloud.vn_vkshealthcheckpolicies.yaml + vkshealthcheckpoliciesCRDBytes []byte + + //go:embed crds/gateway.vks.vngcloud.vn_vksroutepolicies.yaml + vksroutepoliciesCRDBytes []byte ) // CRDDefinition holds the name and embedded bytes of a CRD @@ -47,6 +59,10 @@ func GetAllCRDs() []CRDDefinition { {Name: "nodesecuritygroups.vks.vngcloud.vn", Bytes: nodesecuritygroupsCRDBytes}, {Name: "vngcloudgloballoadbalancers.vks.vngcloud.vn", Bytes: vngcloudgloballoadbalancersCRDBytes}, {Name: "globalloadbalancerconfigs.vks.vngcloud.vn", Bytes: globalloadbalancerconfigsCRDBytes}, + {Name: "vksgatewaypolicies.gateway.vks.vngcloud.vn", Bytes: vksgatewaypoliciesCRDBytes}, + {Name: "vksbackendpolicies.gateway.vks.vngcloud.vn", Bytes: vksbackendpoliciesCRDBytes}, + {Name: "vkshealthcheckpolicies.gateway.vks.vngcloud.vn", Bytes: vkshealthcheckpoliciesCRDBytes}, + {Name: "vksroutepolicies.gateway.vks.vngcloud.vn", Bytes: vksroutepoliciesCRDBytes}, } } diff --git a/pkg/k8s/finalizer.go b/pkg/k8s/finalizer.go index b1da9cd7..6f3093f2 100644 --- a/pkg/k8s/finalizer.go +++ b/pkg/k8s/finalizer.go @@ -54,7 +54,8 @@ func (m *defaultFinalizerManager) AddFinalizers(ctx context.Context, obj client. func (m *defaultFinalizerManager) RemoveFinalizers(ctx context.Context, obj client.Object, finalizers ...string) error { return retry.RetryOnConflict(retry.DefaultBackoff, func() error { if err := m.k8sClient.Get(ctx, NamespacedName(obj), obj); err != nil { - return err + // Object already gone — finalizer removal is moot, goal is met. + return client.IgnoreNotFound(err) } oldObj := obj.DeepCopyObject().(client.Object) @@ -70,7 +71,9 @@ func (m *defaultFinalizerManager) RemoveFinalizers(ctx context.Context, obj clie } err := m.k8sClient.Patch(ctx, obj, client.MergeFromWithOptions(oldObj, client.MergeFromWithOptimisticLock{})) if err != nil { - return err + // Object deleted between Get and Patch — goal is already met. + // Conflicts are retried by RetryOnConflict (re-Gets a fresh copy). + return client.IgnoreNotFound(err) } // return nil diff --git a/pkg/k8s/finalizer_test.go b/pkg/k8s/finalizer_test.go new file mode 100644 index 00000000..aa9c51df --- /dev/null +++ b/pkg/k8s/finalizer_test.go @@ -0,0 +1,27 @@ +package k8s + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// RemoveFinalizers must treat an already-deleted object as success: the +// finalizer it would strip is gone with the object, so there is nothing left +// to do and the caller should not see an error (which would re-queue). +func TestRemoveFinalizers_ObjectAlreadyGone(t *testing.T) { + // Build a client with NO objects → Get returns NotFound. + c := fake.NewClientBuilder().Build() + m := NewDefaultFinalizerManager(c, logr.Discard()) + + obj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "gone"}, + } + err := m.RemoveFinalizers(context.Background(), obj, "test/fin") + assert.NoError(t, err) +} diff --git a/pkg/utils/endpoint_resolver_test.go b/pkg/utils/endpoint_resolver_test.go index 87c8517e..2f716f3f 100644 --- a/pkg/utils/endpoint_resolver_test.go +++ b/pkg/utils/endpoint_resolver_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/golang/mock/gomock" - . "github.com/onsi/gomega" "github.com/vngcloud/vngcloud-load-balancer-controller/pkg/k8s" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -402,7 +401,7 @@ func Test_defaultEndpointResolver_ResolvePodEndpoints(t *testing.T) { k8sSchema := runtime.NewScheme() err := clientgoscheme.AddToScheme(k8sSchema) - Expect(err).ToNot(HaveOccurred()) + assert.NoError(t, err) k8sClient := testclient.NewClientBuilder().WithScheme(k8sSchema).Build() ctx := context.Background() diff --git a/test/e2e/gateway/01-kitchen-sink.yaml b/test/e2e/gateway/01-kitchen-sink.yaml new file mode 100644 index 00000000..754eccd2 --- /dev/null +++ b/test/e2e/gateway/01-kitchen-sink.yaml @@ -0,0 +1,175 @@ +# ===== Compact kitchen-sink (≤10 policies — vngcloud's POLICY_PER_LB quota) ===== +# 1 listener × 1 hostname × 4 rules × 1 match each = 4 policies. +# Exercises every customizable VKSPolicy field but stays under the cloud limit. +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: kitchen-mini + namespace: gw-demo +spec: + gatewayClassName: vngcloud-alb + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Same +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: kitchen-mini + namespace: gw-demo +spec: + parentRefs: + - name: kitchen-mini + hostnames: + - kitchen.example.com + rules: + - name: exact-rule + matches: + - path: + type: Exact + value: /api/v1 + backendRefs: + - name: echo + port: 80 + weight: 90 + - name: echo-v2 + port: 80 + weight: 10 + - name: prefix-rule + matches: + - path: + type: PathPrefix + value: /assets + backendRefs: + - name: echo + port: 80 + - name: regex-rule + matches: + - path: + type: RegularExpression + value: ^/users/[0-9]+$ + backendRefs: + - name: echo + port: 80 + - name: filter-redirect + matches: + - path: + type: PathPrefix + value: /old + filters: + - type: RequestRedirect + requestRedirect: + scheme: https + hostname: new.kitchen.example.com + statusCode: 301 +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: + name: kitchen-mini-defaults + namespace: gw-demo +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: kitchen-mini + loadBalancerSpec: + scheme: Internet + tags: + env: smoke-test + allowedCidrs: + - 0.0.0.0/0 + - 10.0.0.0/8 + insertHeaders: + X-Forwarded-For: "true" + X-Forwarded-Proto: "true" + timeoutClient: 30s + timeoutMember: 60s + timeoutConnection: 5s +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: + name: echo-policy + namespace: gw-demo +spec: + targetRefs: + - group: "" + kind: Service + name: echo + targetType: instance + poolAlgorithm: LEAST_CONNECTIONS + stickiness: true +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: + name: echo-v2-policy + namespace: gw-demo +spec: + targetRefs: + - group: "" + kind: Service + name: echo-v2 + targetType: instance + poolAlgorithm: ROUND_ROBIN +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSHealthCheckPolicy +metadata: + name: echo-hc + namespace: gw-demo +spec: + targetRefs: + - group: "" + kind: Service + name: echo + protocol: HTTP + interval: 15s + timeout: 4s + healthyThreshold: 2 + unhealthyThreshold: 3 + httpHealthCheck: + path: / + host: kitchen.example.com + expectedCodes: + - "200" + - "201" +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSRoutePolicy +metadata: + name: prefix-redirect + namespace: gw-demo +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: kitchen-mini + sectionName: prefix-rule + position: 5 + actions: + - type: Redirect + redirect: + url: https://prefix-only.example.com + httpCode: 302 + keepQueryString: true +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSRoutePolicy +metadata: + name: regex-reject + namespace: gw-demo +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: kitchen-mini + sectionName: regex-rule + actions: + - type: Reject diff --git a/test/e2e/gateway/advanced_test.go b/test/e2e/gateway/advanced_test.go new file mode 100644 index 00000000..6cdc69f2 --- /dev/null +++ b/test/e2e/gateway/advanced_test.go @@ -0,0 +1,322 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gateway + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +// HTTPS/TLS termination via certificateRefs (Secret import) + per-listener +// VKSGatewayPolicy scoping by sectionName. One ALB. +var _ = Describe("ALB Gateway HTTPS + per-listener policy scoping", func() { + It("imports a TLS Secret on the HTTPS listener and scopes a per-listener policy by sectionName", func() { + kubectlApply(selfSignedTLSSecretYAML(testNamespace, "tls-secret", "app.example.com")) + kubectlApply(tlsPoliciesYAML) // unscoped (timeoutClient 20s) + sectionName=https (99s) + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "tls-gw", "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(tlsGatewayYAML) // http :80 + https :443 (certificateRefs: tls-secret) + + var lbc *v1alpha1.LoadBalancerConfig + Eventually(func(g Gomega) { + lbc = getOwnedLBC(testNamespace, "tls-gw") + g.Expect(lbc).NotTo(BeNil()) + g.Expect(listenerByPort(lbc, 80)).NotTo(BeNil()) + g.Expect(listenerByPort(lbc, 443)).NotTo(BeNil()) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + + https := listenerByPort(lbc, 443) + http := listenerByPort(lbc, 80) + + By("the Secret is imported as the HTTPS listener's default certificate") + Expect(https.CertificateDefault).NotTo(BeNil()) + Expect(https.CertificateDefault.SecretName).NotTo(BeNil()) + Expect(*https.CertificateDefault.SecretName).To(Equal("tls-secret")) + Expect(lbc.Spec.CreateCertificates).To(ContainElement(HaveField("SecretName", "tls-secret"))) + + By("per-listener policy (sectionName=https) wins over the unscoped default") + Expect(https.TimeoutClient).NotTo(BeNil()) + Expect(*https.TimeoutClient).To(Equal(int32(99))) + Expect(http.TimeoutClient).NotTo(BeNil()) + Expect(*http.TimeoutClient).To(Equal(int32(20))) + }) +}) + +// targetType=ip (pod IPs as members) + VKSHealthCheckPolicy conflict +// (oldest-wins). One ALB. +var _ = Describe("ALB Gateway backend targetType=ip and health-check conflict", func() { + It("resolves pod IPs and applies the oldest health-check policy on conflict", func() { + kubectlApply(echo3BackendYAML) + kubectl("-n", testNamespace, "rollout", "status", "deploy/echo3", "--timeout=120s") + + // Two health-check policies on the same Service conflict; the + // oldest-created (also lexicographically first) wins the LBC, and the + // policy-validator controller reports the loser Conflicted. + kubectlApply(hcConflictYAML("hc-a-older", 2)) // applied first -> older -> wins + kubectlApply(hcConflictYAML("hc-b-newer", 9)) + kubectlApply(advBackendPolicyYAML) // targetType: ip on echo3 + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "adv-gw", "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(advGatewayRouteYAML) + + var lbc *v1alpha1.LoadBalancerConfig + Eventually(func(g Gomega) { + lbc = getOwnedLBC(testNamespace, "adv-gw") + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Pools).NotTo(BeEmpty()) + g.Expect(lbc.Spec.Pools[0].Members).NotTo(BeEmpty()) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + + pool := lbc.Spec.Pools[0] + By("targetType ip -> members use the pod port (80), not a nodePort") + for _, m := range pool.Members { + Expect(m.Port).To(Equal(80)) + } + By("conflict -> oldest health-check policy (healthyThreshold 2) wins") + Expect(pool.HealthMonitor.HealthyThreshold).NotTo(BeNil()) + Expect(*pool.HealthMonitor.HealthyThreshold).To(Equal(2)) + + By("the policy validator reports the older policy Accepted and the newer Conflicted") + Eventually(func(g Gomega) { + g.Expect(jsonpath(testNamespace, "vkshealthcheckpolicy", "hc-a-older", + `{.status.conditions[?(@.type=="Accepted")].status}`)).To(Equal("True")) + g.Expect(jsonpath(testNamespace, "vkshealthcheckpolicy", "hc-b-newer", + `{.status.conditions[?(@.type=="Accepted")].reason}`)).To(Equal("Conflicted")) + }, time.Minute, 5*time.Second).Should(Succeed()) + }) +}) + +// Gateway / HTTPRoute status reporting, on a SIMPLE cloud-valid gateway (no +// health-check override, so the default TCP probe on the member port passes and +// the LB reaches Programmed). Kept separate from the kitchen suite, which +// deliberately breaks the health check (port 8080, nothing listening) to test +// the monitorPort override — so it must not be coupled to provisioning success. +var _ = Describe("ALB Gateway status reporting", Ordered, func() { + BeforeAll(func() { + kubectlApply(statusBackendYAML) + kubectl("-n", testNamespace, "rollout", "status", "deploy/echo-status", "--timeout=120s") + kubectlApply(statusPolicyYAML) + kubectlApply(statusGatewayRouteYAML) + }) + AfterAll(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "status-gw", + "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + + It("reports Gateway Programmed + an address once the LB provisions", func() { + Eventually(func(g Gomega) { + g.Expect(jsonpath(testNamespace, "gateway", "status-gw", + `{.status.conditions[?(@.type=="Programmed")].status}`)).To(Equal("True")) + g.Expect(jsonpath(testNamespace, "gateway", "status-gw", + `{.status.addresses[0].value}`)).NotTo(BeEmpty()) + }, 5*time.Minute, 5*time.Second).Should(Succeed()) + }) + + // Requires the controller to be running the route-status writer + // (writeRouteStatuses). Verified in-process by the alb envtest suite; on a + // live cluster the running controller must include this build. + It("reports HTTPRoute Accepted + ResolvedRefs on its parent", func() { + Eventually(func(g Gomega) { + g.Expect(jsonpath(testNamespace, "httproute", "status-route", + `{.status.parents[0].conditions[?(@.type=="Accepted")].status}`)).To(Equal("True")) + g.Expect(jsonpath(testNamespace, "httproute", "status-route", + `{.status.parents[0].conditions[?(@.type=="ResolvedRefs")].status}`)).To(Equal("True")) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + }) +}) + +var statusBackendYAML = fmt.Sprintf(` +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo-status, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo-status}} + template: + metadata: {labels: {app: echo-status}} + spec: + containers: [{name: nginx, image: nginx:alpine, ports: [{containerPort: 80}]}] +--- +apiVersion: v1 +kind: Service +metadata: {name: echo-status, namespace: %[1]s} +spec: {type: NodePort, selector: {app: echo-status}, ports: [{port: 80, targetPort: 80}]} +`, testNamespace) + +var statusPolicyYAML = fmt.Sprintf(` +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: status-pol, namespace: %[1]s} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: Gateway, name: status-gw}] + loadBalancerSpec: {scheme: Internet} +`, testNamespace) + +var statusGatewayRouteYAML = fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: status-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: [{name: http, protocol: HTTP, port: 80, allowedRoutes: {namespaces: {from: Same}}}] +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: {name: status-route, namespace: %[1]s} +spec: + parentRefs: [{name: status-gw}] + rules: + - matches: [{path: {type: PathPrefix, value: /}}] + backendRefs: [{name: echo-status, port: 80}] +`, testNamespace, gatewayClassName) + +// --- fixtures --- + +// selfSignedTLSSecretYAML returns a kubernetes.io/tls Secret manifest carrying a +// freshly generated self-signed cert/key for cn. +func selfSignedTLSSecretYAML(ns, name, cn string) string { + priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{cn}, + BasicConstraintsValid: true, + } + der, _ := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, _ := x509.MarshalECPrivateKey(priv) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return fmt.Sprintf(` +apiVersion: v1 +kind: Secret +metadata: {name: %[1]s, namespace: %[2]s} +type: kubernetes.io/tls +data: + tls.crt: %[3]s + tls.key: %[4]s +`, name, ns, base64.StdEncoding.EncodeToString(certPEM), base64.StdEncoding.EncodeToString(keyPEM)) +} + +var tlsPoliciesYAML = fmt.Sprintf(` +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: tls-unscoped, namespace: %[1]s} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: Gateway, name: tls-gw}] + loadBalancerSpec: {scheme: Internet} + timeoutClient: 20s +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: tls-https, namespace: %[1]s} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: Gateway, name: tls-gw, sectionName: https}] + timeoutClient: 99s +`, testNamespace) + +var tlsGatewayYAML = fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: tls-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: + - {name: http, protocol: HTTP, port: 80, allowedRoutes: {namespaces: {from: Same}}} + - name: https + protocol: HTTPS + port: 443 + tls: + mode: Terminate + certificateRefs: [{name: tls-secret}] + allowedRoutes: {namespaces: {from: Same}} +`, testNamespace, gatewayClassName) + +var echo3BackendYAML = fmt.Sprintf(` +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo3, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo3}} + template: + metadata: {labels: {app: echo3}} + spec: + containers: [{name: nginx, image: nginx:alpine, ports: [{containerPort: 80}]}] +--- +apiVersion: v1 +kind: Service +metadata: {name: echo3, namespace: %[1]s} +spec: {selector: {app: echo3}, ports: [{port: 80, targetPort: 80}]} +`, testNamespace) + +func hcConflictYAML(name string, healthyThreshold int) string { + return fmt.Sprintf(` +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSHealthCheckPolicy +metadata: {name: %[2]s, namespace: %[1]s} +spec: + targetRefs: [{group: "", kind: Service, name: echo3}] + protocol: TCP + healthyThreshold: %[3]d +`, testNamespace, name, healthyThreshold) +} + +var advBackendPolicyYAML = fmt.Sprintf(` +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: {name: echo3-bp, namespace: %[1]s} +spec: + targetRefs: [{group: "", kind: Service, name: echo3}] + targetType: ip +`, testNamespace) + +var advGatewayRouteYAML = fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: adv-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: [{name: http, protocol: HTTP, port: 80, allowedRoutes: {namespaces: {from: Same}}}] +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: {name: adv-route, namespace: %[1]s} +spec: + parentRefs: [{name: adv-gw}] + rules: + - matches: [{path: {type: PathPrefix, value: /}}] + backendRefs: [{name: echo3, port: 80}] +`, testNamespace, gatewayClassName) diff --git a/test/e2e/gateway/lb_config_test.go b/test/e2e/gateway/lb_config_test.go new file mode 100644 index 00000000..f3ffebe9 --- /dev/null +++ b/test/e2e/gateway/lb_config_test.go @@ -0,0 +1,433 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gateway + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +// The comprehensive suite provisions ONE ALB from a kitchen-sink of Gateway-API +// resources + all four VKS policies, then asserts the whole resulting +// LoadBalancerConfig — the same surface the Ingress annotations cover. Specs +// share the single LBC (Ordered) so we don't provision an ALB per assertion. +var _ = Describe("ALB Gateway -> LoadBalancerConfig (comprehensive)", Ordered, func() { + var lbc *v1alpha1.LoadBalancerConfig + var listener *v1alpha1.Listener + + BeforeAll(func() { + By("creating two weighted backends") + kubectlApply(echoBackendsYAML) + kubectl("-n", testNamespace, "rollout", "status", "deploy/echo", "--timeout=120s") + kubectl("-n", testNamespace, "rollout", "status", "deploy/echo-v2", "--timeout=120s") + + By("applying all policies BEFORE the Gateway (loadBalancerName is create-only)") + kubectlApply(kitchenPoliciesYAML) + + By("applying the Gateway + HTTPRoute (4 supported rules + 1 unsupported header rule)") + kubectlApply(kitchenGatewayRouteYAML) + + By("waiting until the LBC is fully translated") + Eventually(func(g Gomega) { + lbc = getOwnedLBC(testNamespace, "kitchen-gw") + g.Expect(lbc).NotTo(BeNil()) + listener = listenerByPort(lbc, 80) + g.Expect(listener).NotTo(BeNil()) + // 4 supported rules -> 4 policies; the header-match rule is dropped. + g.Expect(listener.Policies).To(HaveLen(4)) + // the weighted rule aggregated 2 services into one pool with members. + wp := weightedPool(lbc) + g.Expect(wp).NotTo(BeNil()) + g.Expect(wp.Members).NotTo(BeEmpty()) + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + }) + + AfterAll(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "kitchen-gw", + "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + + It("maps LB-level fields from VKSGatewayPolicy.loadBalancerSpec", func() { + Expect(lbc.Spec.Scheme).NotTo(BeNil()) + Expect(string(*lbc.Spec.Scheme)).To(Equal("Internet")) + Expect(lbc.Spec.Tags).To(HaveKeyWithValue("env", "e2e")) + Expect(lbc.Spec.EnableAutoscale).NotTo(BeNil()) + Expect(*lbc.Spec.EnableAutoscale).To(BeFalse()) + Expect(lbc.Spec.IsPoc).NotTo(BeNil()) + Expect(*lbc.Spec.IsPoc).To(BeFalse()) + // policy-applied-before-Gateway, so the create-only name takes effect. + Expect(lbc.Spec.LoadBalancerName).To(Equal("e2e-kitchen")) + }) + + It("maps listener fields (timeouts, allowedCidrs, insertHeaders)", func() { + Expect(*listener.TimeoutClient).To(Equal(int32(30))) + Expect(*listener.TimeoutMember).To(Equal(int32(60))) + Expect(*listener.TimeoutConnection).To(Equal(int32(5))) + Expect(listener.AllowedCidrs).NotTo(BeNil()) + Expect(*listener.AllowedCidrs).To(Equal("0.0.0.0/0,10.0.0.0/8")) + var headerNames []string + for _, h := range listener.InsertHeaders { + headerNames = append(headerNames, h.HeaderName) + } + Expect(headerNames).To(ContainElement("X-Custom")) + }) + + It("maps pool tuning + full health monitor", func() { + wp := weightedPool(lbc) + Expect(wp.Algorithm).NotTo(BeNil()) + Expect(string(*wp.Algorithm)).To(Equal("LEAST_CONNECTIONS")) + Expect(*wp.Stickiness).To(BeTrue()) + Expect(*wp.TLSEncryption).To(BeTrue()) + + hm := wp.HealthMonitor + Expect(string(hm.Protocol)).To(Equal("HTTP")) + Expect(*hm.Interval).To(Equal(15)) + Expect(*hm.Timeout).To(Equal(4)) + Expect(*hm.HealthyThreshold).To(Equal(2)) + Expect(*hm.UnhealthyThreshold).To(Equal(3)) + Expect(string(*hm.HealthCheckMethod)).To(Equal("POST")) + Expect(string(*hm.HttpVersion)).To(Equal("1.0")) + Expect(*hm.HealthCheckPath).To(Equal("/healthz")) + Expect(*hm.DomainName).To(Equal("app.example.com")) + Expect(*hm.SuccessCode).To(Equal("200,201")) + for _, m := range wp.Members { + Expect(m.MonitorPort).To(Equal(8080)) + } + }) + + It("aggregates weighted backendRefs into one pool with scaled member weights", func() { + wp := weightedPool(lbc) + ports := map[int]bool{} + weights := map[int]bool{} + for _, m := range wp.Members { + ports[m.Port] = true + if m.Weight != nil { + weights[*m.Weight] = true + } + } + Expect(len(ports)).To(BeNumerically(">=", 2), "members from 2 backend services") + Expect(len(weights)).To(BeNumerically(">=", 2), "90:10 split -> distinct scaled weights") + }) + + It("maps HTTPRoute path match types to L7 compareType + hostname rule", func() { + exact := policyByPathValue(listener, "/api") + Expect(exact).NotTo(BeNil()) + Expect(string(ruleByType(exact, "PATH").CompareType)).To(Equal("EQUAL_TO")) + + host := ruleByType(exact, "HOST_NAME") + Expect(host).NotTo(BeNil()) + Expect(string(host.CompareType)).To(Equal("EQUAL_TO")) + Expect(host.RuleValue).To(Equal("app.example.com")) + + prefix := policyByPathValue(listener, "/assets") + Expect(prefix).NotTo(BeNil()) + Expect(string(ruleByType(prefix, "PATH").CompareType)).To(Equal("STARTS_WITH")) + + var regexSeen bool + for i := range listener.Policies { + if r := ruleByType(&listener.Policies[i], "PATH"); r != nil && string(r.CompareType) == "REGEX" { + regexSeen = true + } + } + Expect(regexSeen).To(BeTrue(), "RegularExpression match -> REGEX compareType") + }) + + It("defaults to REDIRECT_TO_POOL for a routed rule", func() { + p := policyByPathValue(listener, "/api") + Expect(string(p.Action)).To(Equal("REDIRECT_TO_POOL")) + Expect(p.RedirectPoolName).NotTo(BeNil()) + }) + + It("honors VKSRoutePolicy Redirect action + explicit position", func() { + p := policyByPathValue(listener, "/assets") + Expect(string(p.Action)).To(Equal("REDIRECT_TO_URL")) + Expect(*p.RedirectUrl).To(Equal("https://moved.example.com")) + Expect(*p.RedirectHttpCode).To(Equal(int32(302))) + Expect(*p.KeepQueryString).To(BeTrue()) + Expect(p.Position).NotTo(BeNil()) + Expect(*p.Position).To(Equal(int32(10))) + }) + + It("honors VKSRoutePolicy Reject action", func() { + var rejected bool + for i := range listener.Policies { + if string(listener.Policies[i].Action) == "REJECT" { + rejected = true + } + } + Expect(rejected).To(BeTrue()) + }) + + It("maps an HTTPRoute RequestRedirect filter to REDIRECT_TO_URL", func() { + p := policyByPathValue(listener, "/old") + Expect(p).NotTo(BeNil()) + Expect(string(p.Action)).To(Equal("REDIRECT_TO_URL")) + Expect(*p.RedirectUrl).To(ContainSubstring("new.example.com")) + }) + + It("drops a rule whose match uses an unsupported dimension (header)", func() { + Expect(policyByPathValue(listener, "/h")).To(BeNil(), "header-match rule must not produce a policy") + Expect(listener.Policies).To(HaveLen(4)) + }) + + It("reports PartiallyInvalid on the route for the dropped header rule", func() { + Eventually(func() string { + return routeParentStatus(testNamespace, "kitchen-route", "PartiallyInvalid") + }, time.Minute, 3*time.Second).Should(Equal("True")) + Expect(routeParentReason(testNamespace, "kitchen-route", "PartiallyInvalid")).To(Equal("UnsupportedValue")) + }) + + // The remaining specs mutate/delete shared fixtures, so any spec that + // asserts the pre-update state must precede them (Ordered container). + It("reconciles policy updates (every-reconcile field changes; create-only name stays)", func() { + kubectl("-n", testNamespace, "patch", "vksgatewaypolicy", "kitchen-lb", + "--type=merge", "-p", `{"spec":{"timeoutClient":"45s"}}`) + Eventually(func(g Gomega) { + cur := getOwnedLBC(testNamespace, "kitchen-gw") + g.Expect(cur).NotTo(BeNil()) + l := listenerByPort(cur, 80) + g.Expect(l).NotTo(BeNil()) + g.Expect(l.TimeoutClient).NotTo(BeNil()) + g.Expect(*l.TimeoutClient).To(Equal(int32(45)), "every-reconcile field must update") + g.Expect(cur.Spec.LoadBalancerName).To(Equal("e2e-kitchen"), "create-only name must NOT change") + }, time.Minute, 3*time.Second).Should(Succeed()) + }) + + It("reverts the rule action when its VKSRoutePolicy is deleted", func() { + kubectl("-n", testNamespace, "delete", "vksroutepolicy", "regex-reject") + Eventually(func(g Gomega) { + cur := getOwnedLBC(testNamespace, "kitchen-gw") + g.Expect(cur).NotTo(BeNil()) + l := listenerByPort(cur, 80) + g.Expect(l).NotTo(BeNil()) + p := policyByPathValue(l, "^/u/[0-9]+$") + g.Expect(p).NotTo(BeNil()) + g.Expect(string(p.Action)).To(Equal("REDIRECT_TO_POOL"), "REJECT overlay must revert to default") + }, time.Minute, 3*time.Second).Should(Succeed()) + }) + + It("drops all listener policies when the HTTPRoute is deleted", func() { + kubectl("-n", testNamespace, "delete", "httproute", "kitchen-route") + Eventually(func(g Gomega) { + cur := getOwnedLBC(testNamespace, "kitchen-gw") + g.Expect(cur).NotTo(BeNil()) + l := listenerByPort(cur, 80) + g.Expect(l).NotTo(BeNil()) + g.Expect(l.Policies).To(BeEmpty()) + }, time.Minute, 3*time.Second).Should(Succeed()) + }) + + // preferZoneId positive twin of the fail-closed negative below: a second + // tiny gateway pinned to the zone the kitchen LB landed in must produce an + // LBC in that zone. (Single-zone cluster: this exercises the valid-zone + // path; the multi-zone node-scan path is unit-tested.) + It("creates the LBC in the preferred zone for a valid preferZoneId", func() { + zone := string(lbc.Spec.ZoneId) + Expect(zone).NotTo(BeEmpty()) + kubectlApply(gwPolicyYAML("zonepos-gw", fmt.Sprintf("preferZoneId: %q", zone))) + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "zonepos-gw", + "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(plainGatewayYAML("zonepos-gw")) + Eventually(func(g Gomega) { + cur := getOwnedLBC(testNamespace, "zonepos-gw") + g.Expect(cur).NotTo(BeNil()) + g.Expect(string(cur.Spec.ZoneId)).To(Equal(zone)) + }, time.Minute, 5*time.Second).Should(Succeed()) + }) +}) + +// Negative / fail-closed inputs. These error during subnet/zone resolution, +// before the LBC is created, so they provision no cloud LB. +var _ = Describe("ALB Gateway fail-closed inputs", func() { + It("creates no LBC for a non-existent loadBalancerId (adoption lookup fails)", func() { + kubectlApply(gwPolicyYAML("adopt-gw", "loadBalancerId: \"lb-does-not-exist-0000\"")) + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "adopt-gw", + "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(plainGatewayYAML("adopt-gw")) + Consistently(func() string { return ownedLBCName(testNamespace, "adopt-gw") }, 30*time.Second, 5*time.Second). + Should(BeEmpty(), "bogus loadBalancerId must fail closed") + }) + + It("creates no LBC for a preferZoneId with no matching node", func() { + kubectlApply(gwPolicyYAML("zone-gw", `preferZoneId: "ZONE-DOES-NOT-EXIST"`)) + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "zone-gw", + "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(plainGatewayYAML("zone-gw")) + Consistently(func() string { return ownedLBCName(testNamespace, "zone-gw") }, 30*time.Second, 5*time.Second). + Should(BeEmpty(), "unresolvable preferZoneId must fail closed") + }) +}) + +// --- fixtures --- + +var echoBackendsYAML = fmt.Sprintf(` +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo}} + template: + metadata: {labels: {app: echo}} + spec: + containers: [{name: nginx, image: nginx:alpine, ports: [{containerPort: 80}]}] +--- +apiVersion: v1 +kind: Service +metadata: {name: echo, namespace: %[1]s} +spec: {type: NodePort, selector: {app: echo}, ports: [{port: 80, targetPort: 80}]} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo-v2, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo-v2}} + template: + metadata: {labels: {app: echo-v2}} + spec: + containers: [{name: nginx, image: nginx:alpine, ports: [{containerPort: 80}]}] +--- +apiVersion: v1 +kind: Service +metadata: {name: echo-v2, namespace: %[1]s} +spec: {type: NodePort, selector: {app: echo-v2}, ports: [{port: 80, targetPort: 80}]} +`, testNamespace) + +var kitchenPoliciesYAML = fmt.Sprintf(` +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: kitchen-lb, namespace: %[1]s} +spec: + targetRefs: + [{group: gateway.networking.k8s.io, kind: Gateway, name: kitchen-gw}] + loadBalancerSpec: + {scheme: Internet, loadBalancerName: e2e-kitchen, isPOC: false, + enableAutoscale: false, tags: {env: e2e}} + timeoutClient: 30s + timeoutMember: 60s + timeoutConnection: 5s + allowedCidrs: ["0.0.0.0/0", "10.0.0.0/8"] + insertHeaders: {X-Custom: "true"} +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: {name: echo-bp, namespace: %[1]s} +spec: + # both weighted backends share one policy — divergent per-backend policies + # would fail the rule closed (BackendConfigMismatch) + targetRefs: [{group: "", kind: Service, name: echo}, {group: "", kind: Service, name: echo-v2}] + targetType: instance + poolAlgorithm: LEAST_CONNECTIONS + stickiness: true + enableTLSEncryption: true +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSHealthCheckPolicy +metadata: {name: echo-hc, namespace: %[1]s} +spec: + targetRefs: [{group: "", kind: Service, name: echo}, {group: "", kind: Service, name: echo-v2}] + protocol: HTTP + interval: 15s + timeout: 4s + healthyThreshold: 2 + unhealthyThreshold: 3 + port: 8080 + httpHealthCheck: + {path: /healthz, host: app.example.com, method: POST, + httpVersion: "1.0", expectedCodes: ["200", "201"]} +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSRoutePolicy +metadata: {name: prefix-redirect, namespace: %[1]s} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: HTTPRoute, name: kitchen-route, sectionName: prefix}] + position: 10 + actions: [{type: Redirect, redirect: {url: "https://moved.example.com", httpCode: 302, keepQueryString: true}}] +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSRoutePolicy +metadata: {name: regex-reject, namespace: %[1]s} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: HTTPRoute, name: kitchen-route, sectionName: regex}] + actions: [{type: Reject}] +`, testNamespace) + +var kitchenGatewayRouteYAML = fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: kitchen-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: [{name: http, protocol: HTTP, port: 80, allowedRoutes: {namespaces: {from: Same}}}] +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: {name: kitchen-route, namespace: %[1]s} +spec: + parentRefs: [{name: kitchen-gw}] + hostnames: [app.example.com] + rules: + - name: exact + matches: [{path: {type: Exact, value: /api}}] + backendRefs: [{name: echo, port: 80, weight: 90}, {name: echo-v2, port: 80, weight: 10}] + - name: prefix + matches: [{path: {type: PathPrefix, value: /assets}}] + backendRefs: [{name: echo, port: 80}] + - name: regex + matches: [{path: {type: RegularExpression, value: "^/u/[0-9]+$"}}] + backendRefs: [{name: echo, port: 80}] + - name: filter + matches: [{path: {type: PathPrefix, value: /old}}] + filters: [{type: RequestRedirect, requestRedirect: {scheme: https, hostname: new.example.com, statusCode: 301}}] + - name: header + matches: [{path: {type: PathPrefix, value: /h}, headers: [{name: X-Env, value: canary}]}] + backendRefs: [{name: echo, port: 80}] +`, testNamespace, gatewayClassName) + +func gwPolicyYAML(gw, lbSpecLine string) string { + return fmt.Sprintf(` +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: %[2]s-pol, namespace: %[1]s} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: Gateway, name: %[2]s}] + loadBalancerSpec: {%[3]s} +`, testNamespace, gw, lbSpecLine) +} + +func plainGatewayYAML(gw string) string { + return fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: %[2]s, namespace: %[1]s} +spec: + gatewayClassName: %[3]s + listeners: [{name: http, protocol: HTTP, port: 80, allowedRoutes: {namespaces: {from: Same}}}] +`, testNamespace, gw, gatewayClassName) +} diff --git a/test/e2e/gateway/new_behaviors_test.go b/test/e2e/gateway/new_behaviors_test.go new file mode 100644 index 00000000..eb3b54b4 --- /dev/null +++ b/test/e2e/gateway/new_behaviors_test.go @@ -0,0 +1,284 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gateway + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func routeParentReason(ns, route, condType string) string { + return jsonpath(ns, "httproute", route, + fmt.Sprintf(`{.status.parents[0].conditions[?(@.type==%q)].reason}`, condType)) +} +func routeParentStatus(ns, route, condType string) string { + return jsonpath(ns, "httproute", route, + fmt.Sprintf(`{.status.parents[0].conditions[?(@.type==%q)].status}`, condType)) +} + +// Cross-namespace backendRef is honored only when a ReferenceGrant permits it. +var _ = Describe("ALB Gateway cross-namespace backend via ReferenceGrant", func() { + const backendNS = "gw-e2e-xns-backend" + It("includes the cross-ns backend only after a ReferenceGrant exists", func() { + kubectlApply(fmt.Sprintf("apiVersion: v1\nkind: Namespace\nmetadata: {name: %s}\n", backendNS)) + DeferCleanup(func() { kubectlQuiet("delete", "namespace", backendNS, "--ignore-not-found", "--wait=false") }) + kubectlApply(xnsBackendYAML(backendNS)) + kubectl("-n", backendNS, "rollout", "status", "deploy/echo", "--timeout=120s") + + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "xns-gw", "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(xnsGatewayRouteYAML(backendNS)) + + By("without a ReferenceGrant the route reports ResolvedRefs=RefNotPermitted") + Eventually(func() string { + return routeParentReason(testNamespace, "xns-route", "ResolvedRefs") + }, 90*time.Second, 5*time.Second).Should(Equal("RefNotPermitted")) + + By("after creating a ReferenceGrant the route resolves") + kubectlApply(xnsReferenceGrantYAML(backendNS, testNamespace)) + Eventually(func() string { + return routeParentStatus(testNamespace, "xns-route", "ResolvedRefs") + }, 2*time.Minute, 5*time.Second).Should(Equal("True")) + + By("deleting the ReferenceGrant revokes the permission again") + kubectl("-n", backendNS, "delete", "referencegrant", "allow-routes") + Eventually(func() string { + return routeParentReason(testNamespace, "xns-route", "ResolvedRefs") + }, 2*time.Minute, 5*time.Second).Should(Equal("RefNotPermitted")) + }) +}) + +// The policy validator reports TargetNotFound for a policy whose target is +// missing; no Gateway exists, so no LBC (and no cloud LB) is created. +var _ = Describe("ALB Gateway policy targeting a missing object", func() { + It("reports Accepted=False with reason TargetNotFound", func() { + kubectlApply(ghostPolicyYAML) + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "vksgatewaypolicy", "ghost-pol", "--ignore-not-found") + }) + Eventually(func() string { + return jsonpath(testNamespace, "vksgatewaypolicy", "ghost-pol", + `{.status.conditions[?(@.type=="Accepted")].reason}`) + }, time.Minute, 5*time.Second).Should(Equal("TargetNotFound")) + }) +}) + +// allowedRoutes NamespacesFromSelector matches the route namespace's labels. +var _ = Describe("ALB Gateway allowedRoutes NamespacesFromSelector", func() { + const routeNS = "gw-e2e-sel" + It("attaches a route from a namespace whose labels match the listener selector", func() { + kubectlApply(fmt.Sprintf("apiVersion: v1\nkind: Namespace\nmetadata: {name: %s, labels: {team: blue}}\n", routeNS)) + DeferCleanup(func() { kubectlQuiet("delete", "namespace", routeNS, "--ignore-not-found", "--wait=false") }) + + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "sel-gw", "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(selGatewayYAML) + kubectlApply(selBackendRouteYAML(routeNS)) + kubectl("-n", routeNS, "rollout", "status", "deploy/echo", "--timeout=120s") + + By("the route in the team=blue namespace is Accepted by the selector listener") + Eventually(func() string { + return routeParentStatus(routeNS, "sel-route", "Accepted") + }, 90*time.Second, 5*time.Second).Should(Equal("True")) + }) +}) + +// A rule whose backends carry divergent policies fails closed. +var _ = Describe("ALB Gateway backend-config mismatch", func() { + It("reports BackendConfigMismatch when a rule's backends carry divergent policies", func() { + kubectlApply(mismatchBackendsYAML) + kubectl("-n", testNamespace, "rollout", "status", "deploy/echo-a", "--timeout=120s") + kubectl("-n", testNamespace, "rollout", "status", "deploy/echo-b", "--timeout=120s") + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "mm-gw", "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(mismatchGatewayRouteYAML) + + Eventually(func() string { + return routeParentReason(testNamespace, "mm-route", "ResolvedRefs") + }, 90*time.Second, 5*time.Second).Should(Equal("BackendConfigMismatch")) + }) +}) + +// --- fixtures --- + +var ghostPolicyYAML = fmt.Sprintf(` +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSGatewayPolicy +metadata: {name: ghost-pol, namespace: %[1]s} +spec: + targetRefs: [{group: gateway.networking.k8s.io, kind: Gateway, name: no-such-gw}] +`, testNamespace) + +func xnsBackendYAML(ns string) string { + return fmt.Sprintf(` +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo}} + template: + metadata: {labels: {app: echo}} + spec: + containers: [{name: nginx, image: nginx:alpine, ports: [{containerPort: 80}]}] +--- +apiVersion: v1 +kind: Service +metadata: {name: echo, namespace: %[1]s} +spec: {type: NodePort, selector: {app: echo}, ports: [{port: 80, targetPort: 80}]} +`, ns) +} + +func xnsGatewayRouteYAML(backendNS string) string { + return fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: xns-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: [{name: http, protocol: HTTP, port: 80, allowedRoutes: {namespaces: {from: Same}}}] +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: {name: xns-route, namespace: %[1]s} +spec: + parentRefs: [{name: xns-gw}] + rules: + - matches: [{path: {type: PathPrefix, value: /}}] + backendRefs: [{name: echo, namespace: %[3]s, port: 80}] +`, testNamespace, gatewayClassName, backendNS) +} + +func xnsReferenceGrantYAML(backendNS, fromNS string) string { + return fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: {name: allow-routes, namespace: %[1]s} +spec: + from: [{group: gateway.networking.k8s.io, kind: HTTPRoute, namespace: %[2]s}] + to: [{group: "", kind: Service}] +`, backendNS, fromNS) +} + +var selGatewayYAML = fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: sel-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Selector + selector: {matchLabels: {team: blue}} +`, testNamespace, gatewayClassName) + +func selBackendRouteYAML(routeNS string) string { + return fmt.Sprintf(` +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo}} + template: + metadata: {labels: {app: echo}} + spec: + containers: [{name: nginx, image: nginx:alpine, ports: [{containerPort: 80}]}] +--- +apiVersion: v1 +kind: Service +metadata: {name: echo, namespace: %[1]s} +spec: {type: NodePort, selector: {app: echo}, ports: [{port: 80, targetPort: 80}]} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: {name: sel-route, namespace: %[1]s} +spec: + parentRefs: [{name: sel-gw, namespace: %[2]s}] + rules: + - matches: [{path: {type: PathPrefix, value: /}}] + backendRefs: [{name: echo, port: 80}] +`, routeNS, testNamespace) +} + +var mismatchBackendsYAML = fmt.Sprintf(` +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo-a, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo-a}} + template: + metadata: {labels: {app: echo-a}} + spec: {containers: [{name: nginx, image: nginx:alpine, ports: [{containerPort: 80}]}]} +--- +apiVersion: v1 +kind: Service +metadata: {name: echo-a, namespace: %[1]s} +spec: {type: NodePort, selector: {app: echo-a}, ports: [{port: 80, targetPort: 80}]} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo-b, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo-b}} + template: + metadata: {labels: {app: echo-b}} + spec: {containers: [{name: nginx, image: nginx:alpine, ports: [{containerPort: 80}]}]}} +--- +apiVersion: v1 +kind: Service +metadata: {name: echo-b, namespace: %[1]s} +spec: {type: NodePort, selector: {app: echo-b}, ports: [{port: 80, targetPort: 80}]} +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSHealthCheckPolicy +metadata: {name: echo-a-hc, namespace: %[1]s} +spec: + targetRefs: [{group: "", kind: Service, name: echo-a}] + protocol: HTTP + httpHealthCheck: {path: /healthz} +`, testNamespace) + +var mismatchGatewayRouteYAML = fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: mm-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: [{name: http, protocol: HTTP, port: 80, allowedRoutes: {namespaces: {from: Same}}}] +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: {name: mm-route, namespace: %[1]s} +spec: + parentRefs: [{name: mm-gw}] + rules: + - matches: [{path: {type: PathPrefix, value: /}}] + backendRefs: [{name: echo-a, port: 80}, {name: echo-b, port: 80}] +`, testNamespace, gatewayClassName) diff --git a/test/e2e/gateway/nlb_test.go b/test/e2e/gateway/nlb_test.go new file mode 100644 index 00000000..277d4dac --- /dev/null +++ b/test/e2e/gateway/nlb_test.go @@ -0,0 +1,280 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gateway + +import ( + "fmt" + "os" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v2 "github.com/vngcloud/vngcloud-go-sdk/v2/vngcloud/services/loadbalancer/v2" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +// The NLB (L4) specs need TWO extra preconditions beyond RUN_GATEWAY_E2E that +// the ALB specs don't: the Gateway-API *experimental* CRDs (TCPRoute/UDPRoute) +// installed, and the controller running with --disable-nlb-gateway-controller=false. +// They are therefore gated behind a second opt-in flag and skip cleanly when the +// vngcloud-nlb GatewayClass can't be made Accepted. +const nlbGatewayClassName = "vngcloud-nlb" + +func nlbEnabled() bool { return os.Getenv("RUN_GATEWAY_NLB_E2E") == "true" } + +var _ = Describe("NLB Gateway -> Layer-4 LoadBalancerConfig", Ordered, func() { + BeforeAll(func() { + if !nlbEnabled() { + Skip("set RUN_GATEWAY_NLB_E2E=true (needs experimental TCP/UDP CRDs + NLB controller enabled)") + } + ensureNLBGatewayClass() + }) + + It("provisions a Layer-4 LBC from a TCP listener + TCPRoute", func() { + kubectlApply(nlbTCPBackendYAML) + kubectl("-n", testNamespace, "rollout", "status", "deploy/echo-tcp", "--timeout=120s") + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "nlb-tcp-gw", + "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(nlbTCPGatewayRouteYAML) + + var lbc *v1alpha1.LoadBalancerConfig + Eventually(func(g Gomega) { + lbc = getOwnedLBC(testNamespace, "nlb-tcp-gw") + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Type).To(Equal(v2.LoadBalancerTypeLayer4)) + l := listenerByPort(lbc, 6379) + g.Expect(l).NotTo(BeNil()) + g.Expect(l.Protocol).To(Equal(v2.ListenerProtocolTCP)) + g.Expect(l.DefaultPoolName).NotTo(BeNil()) + g.Expect(lbc.Spec.Pools).NotTo(BeEmpty()) + g.Expect(lbc.Spec.Pools[0].Protocol).To(Equal(v2.PoolProtocolTCP)) + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + + By("the Gateway reports Programmed + an address once the NLB provisions") + Eventually(func(g Gomega) { + g.Expect(jsonpath(testNamespace, "gateway", "nlb-tcp-gw", + `{.status.conditions[?(@.type=="Programmed")].status}`)).To(Equal("True")) + g.Expect(jsonpath(testNamespace, "gateway", "nlb-tcp-gw", + `{.status.addresses[0].value}`)).NotTo(BeEmpty()) + }, 5*time.Minute, 5*time.Second).Should(Succeed()) + + By("the TCPRoute reports Accepted on its parent") + Eventually(func() string { + return routeKindParentStatus("tcproute", testNamespace, "tcp-route", "Accepted") + }, 2*time.Minute, 5*time.Second).Should(Equal("True")) + }) + + It("provisions a UDP listener + pool from a UDPRoute", func() { + kubectlApply(nlbUDPBackendYAML) + kubectl("-n", testNamespace, "rollout", "status", "deploy/echo-udp", "--timeout=120s") + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "nlb-udp-gw", + "--ignore-not-found", "--wait=true", "--timeout=5m") + }) + kubectlApply(nlbUDPGatewayRouteYAML) + + Eventually(func(g Gomega) { + lbc := getOwnedLBC(testNamespace, "nlb-udp-gw") + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Type).To(Equal(v2.LoadBalancerTypeLayer4)) + l := listenerByPort(lbc, 5353) + g.Expect(l).NotTo(BeNil()) + g.Expect(l.Protocol).To(Equal(v2.ListenerProtocolUDP)) + g.Expect(lbc.Spec.Pools).NotTo(BeEmpty()) + g.Expect(lbc.Spec.Pools[0].Protocol).To(Equal(v2.PoolProtocolUDP)) + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + }) + + // VKSBackendPolicy.targetNodeLabels restricts which nodes become LB members + // (instance mode). Selecting a single node by its hostname must yield exactly + // one pool member — proving the label is wired into endpoint resolution. + It("filters LB pool members by VKSBackendPolicy.targetNodeLabels", func() { + names := nodeNames() + if len(names) < 2 { + Skip(fmt.Sprintf("needs >= 2 Ready nodes to prove node-label filtering (have %d)", len(names))) + } + target := names[0] + targetIP := nodeInternalIP(target) + Expect(targetIP).NotTo(BeEmpty(), "could not read InternalIP of node %s", target) + + kubectlApply(nlbTCPBackendYAML) // reuse the echo-tcp NodePort backend + kubectl("-n", testNamespace, "rollout", "status", "deploy/echo-tcp", "--timeout=120s") + DeferCleanup(func() { + kubectlQuiet("-n", testNamespace, "delete", "gateway", "nlb-nodelabel-gw", + "--ignore-not-found", "--wait=true", "--timeout=5m") + kubectlQuiet("-n", testNamespace, "delete", "tcproute", "nodelabel-route", + "--ignore-not-found") + kubectlQuiet("-n", testNamespace, "delete", "vksbackendpolicy", "echo-tcp-nodes", + "--ignore-not-found") + }) + kubectlApply(nlbNodeLabelYAML(target)) + + By("the LBC pool carries exactly the one selected node as a member") + Eventually(func(g Gomega) { + lbc := getOwnedLBC(testNamespace, "nlb-nodelabel-gw") + g.Expect(lbc).NotTo(BeNil()) + g.Expect(lbc.Spec.Pools).NotTo(BeEmpty()) + g.Expect(lbc.Spec.Pools[0].Members).To(HaveLen(1), + "targetNodeLabels selecting one node must yield one member (cluster has %d nodes)", len(names)) + g.Expect(lbc.Spec.Pools[0].Members[0].IP).To(Equal(targetIP)) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + }) +}) + +// nodeNames returns the names of all nodes in the cluster. +func nodeNames() []string { + out := kubectl("get", "nodes", "-o", "jsonpath={.items[*].metadata.name}") + return strings.Fields(out) +} + +// nodeInternalIP returns the InternalIP of the named node, or "". +func nodeInternalIP(name string) string { + return jsonpath("", "node", name, `{.status.addresses[?(@.type=="InternalIP")].address}`) +} + +// routeKindParentStatus reads .status.parents[0].conditions[type==cond].status +// for an arbitrary route kind (tcproute/udproute). +func routeKindParentStatus(kind, ns, name, cond string) string { + return jsonpath(ns, kind, name, + fmt.Sprintf(`{.status.parents[0].conditions[?(@.type==%q)].status}`, cond)) +} + +func ensureNLBGatewayClass() { + kubectlApply(fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: %s +spec: + controllerName: gateway.vks.vngcloud.vn/nlb +`, nlbGatewayClassName)) + + Eventually(func() string { + return jsonpath("", "gatewayclass", nlbGatewayClassName, + `{.status.conditions[?(@.type=="Accepted")].status}`) + }, time.Minute, 3*time.Second).Should(Equal("True"), + "vngcloud-nlb GatewayClass not Accepted — "+ + "is the controller running with --disable-nlb-gateway-controller=false "+ + "and the experimental CRDs installed?") +} + +// --- fixtures --- + +var nlbTCPBackendYAML = fmt.Sprintf(` +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo-tcp, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo-tcp}} + template: + metadata: {labels: {app: echo-tcp}} + spec: + containers: [{name: redis, image: redis:7-alpine, ports: [{containerPort: 6379}]}] +--- +apiVersion: v1 +kind: Service +metadata: {name: echo-tcp, namespace: %[1]s} +spec: {type: NodePort, selector: {app: echo-tcp}, ports: [{port: 6379, targetPort: 6379, protocol: TCP}]} +`, testNamespace) + +var nlbTCPGatewayRouteYAML = fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: nlb-tcp-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: [{name: tcp, protocol: TCP, port: 6379, allowedRoutes: {namespaces: {from: Same}}}] +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TCPRoute +metadata: {name: tcp-route, namespace: %[1]s} +spec: + parentRefs: [{name: nlb-tcp-gw, sectionName: tcp}] + rules: + - backendRefs: [{name: echo-tcp, port: 6379}] +`, testNamespace, nlbGatewayClassName) + +var nlbUDPBackendYAML = fmt.Sprintf(` +apiVersion: apps/v1 +kind: Deployment +metadata: {name: echo-udp, namespace: %[1]s} +spec: + replicas: 1 + selector: {matchLabels: {app: echo-udp}} + template: + metadata: {labels: {app: echo-udp}} + spec: + # Any Ready pod works: the spec asserts the LBC's UDP listener/pool shape, + # not UDP traffic. nginx becomes Ready without extra config (coredns + # crash-loops without a Corefile), so endpoints resolve into the pool. + containers: [{name: echo, image: nginx:alpine, ports: [{containerPort: 5353, protocol: UDP}]}] +--- +apiVersion: v1 +kind: Service +metadata: {name: echo-udp, namespace: %[1]s} +spec: {type: NodePort, selector: {app: echo-udp}, ports: [{port: 5353, targetPort: 5353, protocol: UDP}]} +`, testNamespace) + +var nlbUDPGatewayRouteYAML = fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: nlb-udp-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: [{name: udp, protocol: UDP, port: 5353, allowedRoutes: {namespaces: {from: Same}}}] +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: UDPRoute +metadata: {name: udp-route, namespace: %[1]s} +spec: + parentRefs: [{name: nlb-udp-gw, sectionName: udp}] + rules: + - backendRefs: [{name: echo-udp, port: 5353}] +`, testNamespace, nlbGatewayClassName) + +// nlbNodeLabelYAML fronts the echo-tcp backend with a Gateway whose +// VKSBackendPolicy pins the LB members to a single node via hostname. +func nlbNodeLabelYAML(nodeName string) string { + return fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: {name: nlb-nodelabel-gw, namespace: %[1]s} +spec: + gatewayClassName: %[2]s + listeners: [{name: tcp, protocol: TCP, port: 6379, allowedRoutes: {namespaces: {from: Same}}}] +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TCPRoute +metadata: {name: nodelabel-route, namespace: %[1]s} +spec: + parentRefs: [{name: nlb-nodelabel-gw, sectionName: tcp}] + rules: + - backendRefs: [{name: echo-tcp, port: 6379}] +--- +apiVersion: gateway.vks.vngcloud.vn/v1alpha1 +kind: VKSBackendPolicy +metadata: {name: echo-tcp-nodes, namespace: %[1]s} +spec: + targetRefs: [{group: "", kind: Service, name: echo-tcp}] + targetNodeLabels: {kubernetes.io/hostname: %[3]s} +`, testNamespace, nlbGatewayClassName, nodeName) +} diff --git a/test/e2e/gateway/suite_test.go b/test/e2e/gateway/suite_test.go new file mode 100644 index 00000000..827cc30e --- /dev/null +++ b/test/e2e/gateway/suite_test.go @@ -0,0 +1,210 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package gateway holds the Gateway-API (GEP-713) end-to-end suite. +// +// Unlike the Kind-based default e2e in test/e2e, these specs provision REAL +// vngcloud ALBs, so they run only against a real cluster that already has the +// controller deployed with the ALB gateway controller enabled (the default; +// see --disable-alb-gateway-controller). The suite is opt-in: +// +// RUN_GATEWAY_E2E=true KUBECONFIG=/path/to/cluster.yaml go test ./test/e2e/gateway/ -v +// +// Without RUN_GATEWAY_E2E the whole suite is skipped, so `make test-e2e` +// (Kind, no vngcloud API) is unaffected. The controller is assumed to be +// already running against the target cluster — the suite does not build or +// deploy it; it only applies Gateway-API resources and asserts on the +// LoadBalancerConfig the controller generates (both the Ingress and Gateway +// paths emit the same LBC, so the LBC is the verification ground). +package gateway + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/vngcloud/vngcloud-load-balancer-controller/api/v1alpha1" +) + +const ( + gatewayClassName = "vngcloud-alb" + controllerName = "gateway.vks.vngcloud.vn/alb" + testNamespace = "gw-e2e" +) + +func TestGatewayE2E(t *testing.T) { + if os.Getenv("RUN_GATEWAY_E2E") != "true" { + t.Skip("set RUN_GATEWAY_E2E=true (and KUBECONFIG) to run gateway-api e2e against a real vngcloud cluster") + } + RegisterFailHandler(Fail) + RunSpecs(t, "gateway-api e2e suite") +} + +var _ = BeforeSuite(func() { + ensureGatewayClass() + // Recreate the namespace clean so a previous aborted run can't leak state. + kubectlQuiet("delete", "namespace", testNamespace, "--ignore-not-found", "--wait=true", "--timeout=5m") + kubectl("create", "namespace", testNamespace) +}) + +var _ = AfterSuite(func() { + // Deleting the namespace removes the Gateways; their finalizers tear down + // the cloud LBs. Best-effort — leaked LBs are surfaced by the warning. + kubectlQuiet("delete", "namespace", testNamespace, "--wait=true", "--timeout=5m") +}) + +// ensureGatewayClass creates the ALB GatewayClass and proves the controller is +// live by waiting for it to be Accepted — a fast precondition that fails the +// whole suite early if the ALB gateway controller isn't running. +func ensureGatewayClass() { + kubectlApply(fmt.Sprintf(` +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: %s +spec: + controllerName: %s +`, gatewayClassName, controllerName)) + + Eventually(func() string { + return jsonpath("", "gatewayclass", gatewayClassName, + `{.status.conditions[?(@.type=="Accepted")].status}`) + }, time.Minute, 3*time.Second).Should(Equal("True"), + "GatewayClass not Accepted — is the controller running with --disable-alb-gateway-controller=false?") +} + +// --- kubectl helpers (mirrors test/e2e's shell-out style; no client-go) --- + +// kubectl runs `kubectl ` and fails the spec on error. +func kubectl(args ...string) string { + out, err := exec.Command("kubectl", args...).CombinedOutput() + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "kubectl %s\n%s", strings.Join(args, " "), out) + return string(out) +} + +// kubectlQuiet runs kubectl best-effort (for cleanup); errors are warnings only. +func kubectlQuiet(args ...string) { + if out, err := exec.Command("kubectl", args...).CombinedOutput(); err != nil { + _, _ = fmt.Fprintf(GinkgoWriter, "warning: kubectl %s: %v\n%s\n", strings.Join(args, " "), err, out) + } +} + +// kubectlApply pipes a manifest to `kubectl apply -f -`. +func kubectlApply(manifest string) { + cmd := exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = strings.NewReader(manifest) + out, err := cmd.CombinedOutput() + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "kubectl apply:\n%s\n--- manifest ---\n%s", out, manifest) +} + +// jsonpath returns the value at expr, or "" on error. Pass ns="" for +// cluster-scoped resources. +func jsonpath(ns, resource, name, expr string) string { + args := []string{} + if ns != "" { + args = append(args, "-n", ns) + } + args = append(args, "get", resource, name, "-o", "jsonpath="+expr) + out, err := exec.Command("kubectl", args...).CombinedOutput() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// ownedLBCName returns the name of the LoadBalancerConfig owned by gwName, or "". +func ownedLBCName(ns, gwName string) string { + out, err := exec.Command("kubectl", "-n", ns, "get", "loadbalancerconfig", + "-l", "vks.vngcloud.vn/owner-resource-name="+gwName, + "-o", "jsonpath={.items[0].metadata.name}").CombinedOutput() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// getOwnedLBC fetches the LBC owned by gwName and unmarshals it into the typed +// struct, so specs can assert on nested fields directly. Returns nil if not +// found yet (callers wrap in Eventually). +func getOwnedLBC(ns, gwName string) *v1alpha1.LoadBalancerConfig { + name := ownedLBCName(ns, gwName) + if name == "" { + return nil + } + out, err := exec.Command("kubectl", "-n", ns, "get", "loadbalancerconfig", name, "-o", "json").CombinedOutput() + if err != nil { + return nil + } + var lbc v1alpha1.LoadBalancerConfig + if err := json.Unmarshal(out, &lbc); err != nil { + return nil + } + return &lbc +} + +// listenerByPort returns the LBC listener bound to the given port, or nil. +func listenerByPort(lbc *v1alpha1.LoadBalancerConfig, port int32) *v1alpha1.Listener { + for i := range lbc.Spec.Listeners { + if lbc.Spec.Listeners[i].ProtocolPort == port { + return &lbc.Spec.Listeners[i] + } + } + return nil +} + +// policyByPathValue returns the listener policy whose PATH rule equals pathVal. +func policyByPathValue(l *v1alpha1.Listener, pathVal string) *v1alpha1.Policy { + for i := range l.Policies { + for _, r := range l.Policies[i].L7Rules { + if string(r.RuleType) == "PATH" && r.RuleValue == pathVal { + return &l.Policies[i] + } + } + } + return nil +} + +// ruleByType returns the policy's L7 rule of the given ruleType (HOST_NAME/PATH), or nil. +func ruleByType(p *v1alpha1.Policy, ruleType string) *v1alpha1.L7Rule { + for i := range p.L7Rules { + if string(p.L7Rules[i].RuleType) == ruleType { + return &p.L7Rules[i] + } + } + return nil +} + +// weightedPool returns the pool whose members span 2+ distinct ports, i.e. the +// synthetic pool that aggregated multiple weighted backendRefs. +func weightedPool(lbc *v1alpha1.LoadBalancerConfig) *v1alpha1.Pool { + for i := range lbc.Spec.Pools { + ports := map[int]struct{}{} + for _, m := range lbc.Spec.Pools[i].Members { + ports[m.Port] = struct{}{} + } + if len(ports) >= 2 { + return &lbc.Spec.Pools[i] + } + } + return nil +} diff --git a/test/utils/utils.go b/test/utils/utils.go index 3b7d787a..58a6fcdf 100644 --- a/test/utils/utils.go +++ b/test/utils/utils.go @@ -22,7 +22,7 @@ import ( "os/exec" "strings" - . "github.com/onsi/ginkgo/v2" //nolint:golint,revive,staticcheck + . "github.com/onsi/ginkgo/v2" //nolint:revive,staticcheck ) const (