Copilot commented on code in PR #2890:
URL: 
https://github.com/apache/apisix-ingress-controller/pull/2890#discussion_r4068429082


##########
internal/adc/cache/store.go:
##########
@@ -86,6 +100,52 @@ func gatewayProxyOf(name string) (types.NamespacedNameKind, 
bool) {
        return gatewayProxy, true
 }
 
+func (s *Store) recordChange(name string, owner types.NamespacedNameKind) {
+       s.revision++
+       if s.lastChange[name] == nil {
+               s.lastChange[name] = make(map[types.NamespacedNameKind]uint64)
+       }
+       s.lastChange[name][owner] = s.revision

Review Comment:
   These owner entries are never removed when an individual resource is 
deleted; only `DeleteAll` drops the entire per-config map. A long-lived 
GatewayProxy with resource-name churn therefore retains one key (including its 
strings) for every historical owner, causing unbounded controller memory 
growth. Please prune tombstones once no in-flight sync can refer to their 
revisions, or otherwise bound this history.



##########
internal/provider/apisix/status.go:
##########
@@ -137,13 +174,39 @@ func (d *apisixProvider) classifySyncResult(
        return gatewayProxyMsgs, failedEndpoints
 }
 
+// dropUnit maps a rejected resource to what has to be dropped for the rest to 
apply. A
+// service is dropped by itself. A route, stream route or named upstream is 
dropped along
+// with the service it lives in: the service is what one Kubernetes rule 
translates to,
+// and a named upstream is referenced by the service's traffic-split by id, so 
dropping
+// it alone would leave that reference dangling. Nested resources are found 
through their
+// parent, which also tells them apart when several services embed upstreams 
of the same
+// id.
+func (d *apisixProvider) dropUnit(configName string, ev adctypes.StatusEvent) 
(wireKey, exclusion, bool) {
+       serviceID := ev.ResourceID
+       switch ev.ResourceType {
+       case adctypes.TypeService:
+       case adctypes.TypeRoute, adctypes.TypeStreamRoute, 
adctypes.TypeUpstream:
+               serviceID = ev.ParentID
+       default:
+               return wireKey{}, exclusion{}, false
+       }
+       service, ok := d.store.Lookup(configName, adctypes.TypeService, 
serviceID)
+       if !ok {
+               return wireKey{}, exclusion{}, false
+       }
+       return wireKey{adctypes.TypeService, service.ID}, exclusion{owner: 
service.Owner, name: service.Name}, true

Review Comment:
   For a rejected route, this attributes the dropped service to the service 
owner rather than to the rejected route's owner. The cache explicitly supports 
routes whose own labels differ from their containing service 
(`internal/adc/cache/store.go:160-176`), so in that case the wrong resource 
gets `SyncFailed`, and editing the actual bad route will not clear the 
exclusion. Keep dropping the parent service, but derive the exclusion owner 
from the route event when available.



##########
internal/provider/apisix/status.go:
##########
@@ -58,23 +61,29 @@ const (
 // get a True the first time, and what keeps a restart from leaving a stale 
False stuck
 // forever: the write only ever depends on this round's actual outcome.
 //
-// Resource status can't afford the same full recompute: a config's resource 
set can be
-// large, and rewriting every one of them every round even when nothing 
changed would be
-// wasteful. So resources keep a small persisted delta in d.resourceFailures 
instead:
-// newly (or still) failing resources are written SyncFailed, and any resource 
that was
-// failing last round but isn't failing this one gets its error explicitly 
cleared with
-// an Accepted write.
-func (d *apisixProvider) updateStatusFromSyncResults(ctx context.Context, 
results map[string]types.ADCExecutionErrors) {
+// Resource status follows the whole skip table instead, since an excluded 
resource stays
+// dropped until its owner is written again. A config's resource set can be 
large, so
+// resources keep a small persisted delta in d.resourceFailures rather than 
being rewritten
+// every round: resources that are dropped or failing are written SyncFailed, 
and any
+// resource that was last round but isn't now gets its error explicitly 
cleared with an
+// Accepted write.
+//
+// It reports whether anything was newly excluded, which is what makes the 
next push
+// different from the one that just failed: see sync().
+func (d *apisixProvider) updateStatusFromSyncResults(ctx context.Context, 
results map[string]types.ADCExecutionErrors, revisions map[string]uint64) bool {
        resourceFailures := map[types.NamespacedNameKind][]string{}
+       newlyExcluded := 0
 
        for configName, execErrs := range results {
+               dropped := map[wireKey]exclusion{}
+               gatewayProxyMsgs, failedEndpoints := 
d.classifySyncResult(configName, execErrs, revisions[configName], dropped, 
resourceFailures)
+               newlyExcluded += d.skipped.MarkFailing(configName, dropped)

Review Comment:
   `ChangedSince` and `MarkFailing` are separate critical sections. If a 
reconcile writes fixed content after the stale check but before this insertion, 
`applyResourceState` clears the old exclusions first and this code then re-adds 
the stale exclusion; with no later content change, the fixed resource remains 
excluded indefinitely. Serialize classification plus insertion with the same 
provider lock used by `applyResourceState`/`removeResourceState`, or make the 
revision check and insertion atomic.



##########
test/e2e/ingress/ingress.go:
##########
@@ -1600,4 +1600,115 @@ spec:
                })
 
        })
+
+       Context("Bad resource isolation", func() {
+               var gatewayProxy = `
+apiVersion: apisix.apache.org/v1alpha1
+kind: GatewayProxy
+metadata:
+  name: apisix-proxy-config
+  namespace: %s
+spec:
+  provider:
+    type: ControlPlane
+    controlPlane:
+      endpoints:
+      - %s
+      auth:
+        type: AdminKey
+        adminKey:
+          value: "%s"
+`
+               var ingressClass = `
+apiVersion: networking.k8s.io/v1
+kind: IngressClass
+metadata:
+  name: %s
+spec:
+  controller: "%s"
+  parameters:
+    apiGroup: "apisix.apache.org"
+    kind: "GatewayProxy"
+    name: "apisix-proxy-config"
+    namespace: "%s"
+    scope: "Namespace"
+`
+               // The Ingress picks up its plugins from an ApisixPluginConfig 
named in an
+               // annotation; limit-count refuses a count that is not greater 
than 0.
+               var rejectedPluginConfig = `
+apiVersion: apisix.apache.org/v2
+kind: ApisixPluginConfig
+metadata:
+  name: rejected-plugins
+spec:
+  ingressClassName: %s
+  plugins:
+  - name: limit-count
+    enable: true
+    config:
+      count: 0
+      time_window: 60
+      rejected_code: 503
+      key: remote_addr
+`
+               var ingressTemplate = `
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: rejected
+%s
+spec:
+  ingressClassName: %s
+  rules:
+  - host: rejected-ingress.example.com
+    http:
+      paths:
+      - path: /
+        pathType: Prefix
+        backend:
+          service:
+            name: httpbin-service-e2e-test
+            port:
+              number: 80
+`
+               const withRejectedPlugins = `  annotations:
+    k8s.apisix.apache.org/plugin-config-name: rejected-plugins`
+
+               It("a rejected Ingress is reported as an event", func() {
+                       By("create GatewayProxy")
+                       err := 
s.CreateResourceFromStringWithNamespace(fmt.Sprintf(gatewayProxy, 
s.Namespace(), s.Deployer.GetAdminEndpoint(), s.AdminKey()), s.Namespace())
+                       Expect(err).NotTo(HaveOccurred(), "creating 
GatewayProxy")
+
+                       By("create IngressClass")
+                       err = 
s.CreateResourceFromStringWithNamespace(fmt.Sprintf(ingressClass, 
s.Namespace(), s.GetControllerName(), s.Namespace()), "")
+                       Expect(err).NotTo(HaveOccurred(), "creating 
IngressClass")
+
+                       By("create an Ingress whose plugins the data plane 
rejects")
+                       err = 
s.CreateResourceFromString(fmt.Sprintf(rejectedPluginConfig, s.Namespace()))
+                       Expect(err).NotTo(HaveOccurred(), "creating 
ApisixPluginConfig")
+                       err = 
s.CreateResourceFromString(fmt.Sprintf(ingressTemplate, withRejectedPlugins, 
s.Namespace()))
+                       Expect(err).NotTo(HaveOccurred(), "creating Ingress")

Review Comment:
   This case creates only the rejected Ingress. It will still pass if that 
rejection blocks the entire GatewayProxy, because it checks the event and only 
sends traffic after fixing the Ingress. Add a valid sibling Ingress in the same 
initial push and assert it is served before the fix to cover Ingress isolation 
rather than only reporting and recovery.



##########
test/e2e/crds/v2/isolation.go:
##########
@@ -0,0 +1,255 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package v2
+
+import (
+       "fmt"
+       "net/http"
+
+       . "github.com/onsi/ginkgo/v2"
+       . "github.com/onsi/gomega"
+       gomegatypes "github.com/onsi/gomega/types"
+
+       "github.com/apache/apisix-ingress-controller/test/e2e/scaffold"
+)
+
+// Every case applies a valid and a rejected resource together, so the first 
sync carries
+// both: the sync converges when the rejected one is excluded and the valid 
one is served.
+// Fixing the rejected one then has to bring it in too.
+//
+// What makes a resource rejected is always something the data plane checks 
for every
+// backend: a value outside the schema's range, or a known plugin configured 
in a way its
+// own check_schema refuses. An unknown plugin name is not used, since 
apisix-standalone
+// accepts those.
+var _ = Describe("Test bad resource isolation", Label("apisix.apache.org", 
"v2", "isolation"), func() {
+       s := scaffold.NewDefaultScaffold()
+
+       // rejectedPlugin is a plugin every backend loads, configured with a 
count the plugin
+       // itself refuses (it has to be greater than 0).
+       const rejectedPlugin = `
+    plugins:
+    - name: limit-count
+      enable: true
+      config:
+        count: 0
+        time_window: 60
+        rejected_code: 503
+        key: remote_addr
+`
+       // A consistent-hash load balancer needs a key to hash on. The data 
plane checks that
+       // in code rather than in its schema, so the configuration gets past 
ADC's schema
+       // check and is rejected only by the data plane itself.
+       const rejectedHashKey = ""
+       const acceptedHashKey = "    key: remote_addr"
+       const acceptedPlugin = `
+    plugins:
+    - name: limit-count
+      enable: true
+      config:
+        count: 100
+        time_window: 60
+        rejected_code: 503
+        key: remote_addr
+`
+       // routes is one ApisixRoute serving valid.example.com and one serving
+       // rejected.example.com, whose plugin configuration is filled in per 
case.
+       const routes = `
+apiVersion: apisix.apache.org/v2
+kind: ApisixRoute
+metadata:
+  name: valid
+  namespace: %s
+spec:
+  ingressClassName: %s
+  http:
+  - name: rule0
+    match:
+      hosts:
+      - valid.example.com
+      paths:
+      - /*
+    backends:
+    - serviceName: httpbin-service-e2e-test
+      servicePort: 80
+---
+apiVersion: apisix.apache.org/v2
+kind: ApisixRoute
+metadata:
+  name: rejected
+  namespace: %s
+spec:
+  ingressClassName: %s
+  http:
+  - name: rule0
+    match:
+      hosts:
+      - rejected.example.com
+      paths:
+      - /*
+    backends:
+    - serviceName: httpbin-service-e2e-test
+      servicePort: 80
+%s
+`
+
+       expectServed := func(host string) {
+               s.RequestAssert(&scaffold.RequestAssert{
+                       Method: "GET",
+                       Path:   "/get",
+                       Host:   host,
+                       Check:  scaffold.WithExpectedStatus(http.StatusOK),
+               })
+       }
+       expectStatus := func(resource, name string, matchers 
...gomegatypes.GomegaMatcher) {
+               s.RetryAssertion(func() string {
+                       output, _ := s.GetOutputFromString(resource, name, 
"-o", "yaml", "-n", s.Namespace())
+                       return output
+               }).Should(And(matchers...))
+       }
+       apply := func(yaml string) {
+               Expect(s.CreateResourceFromString(yaml)).NotTo(HaveOccurred(), 
"applying resources")
+       }
+
+       BeforeEach(func() {
+               By("create GatewayProxy")
+               
Expect(s.CreateResourceFromString(s.GetGatewayProxySpec())).NotTo(HaveOccurred(),
 "creating GatewayProxy")
+
+               By("create IngressClass")
+               
Expect(s.CreateResourceFromStringWithNamespace(s.GetIngressClassYaml(), 
"")).NotTo(HaveOccurred(), "creating IngressClass")
+       })
+
+       It("isolates a rejected route", func() {
+               By("apply a valid and a rejected ApisixRoute")
+               apply(fmt.Sprintf(routes, s.Namespace(), s.Namespace(), 
s.Namespace(), s.Namespace(), rejectedPlugin))
+
+               By("the valid ApisixRoute is served and the rejected one 
reports why it is not")
+               expectServed("valid.example.com")
+               // The rejected route is the only one this ApisixRoute has, so 
none of it is served.
+               expectStatus("ar", "rejected",
+                       ContainSubstring(`status: "False"`),
+                       ContainSubstring(`reason: SyncFailed`),
+                       ContainSubstring(`limit-count`),
+               )
+
+               By("fix the rejected ApisixRoute")
+               apply(fmt.Sprintf(routes, s.Namespace(), s.Namespace(), 
s.Namespace(), s.Namespace(), acceptedPlugin))
+
+               By("both ApisixRoutes are served")
+               expectServed("valid.example.com")
+               expectServed("rejected.example.com")
+               expectStatus("ar", "rejected", ContainSubstring(`reason: 
Accepted`))
+       })
+
+       It("isolates a route whose upstream configuration is rejected", func() {
+               const upstream = `
+apiVersion: apisix.apache.org/v2
+kind: ApisixUpstream
+metadata:
+  name: httpbin-service-e2e-test
+  namespace: %s
+spec:
+  ingressClassName: %s
+  loadbalancer:
+    type: chash
+    hashOn: vars
+%s
+`
+               By("apply a valid and a rejected ApisixRoute, the rejected one 
using a rejected upstream configuration")
+               apply(fmt.Sprintf(routes, s.Namespace(), s.Namespace(), 
s.Namespace(), s.Namespace(), ""))
+               apply(fmt.Sprintf(upstream, s.Namespace(), s.Namespace(), 
rejectedHashKey))

Review Comment:
   This setup does not actually leave a valid control route: both manifests in 
`routes` use `httpbin-service-e2e-test`, and an ApisixUpstream with that name 
is applied to every such backend. Consequently both routes receive the rejected 
hash configuration, and before the fix the test only checks one status without 
verifying that any route is served. Use a different backend for the valid route 
and call `expectServed` before fixing the upstream so the case fails if the 
whole sync is blocked.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to