Skip to content

Commit 0cb95ee

Browse files
authored
Merge pull request #585 from karlkfi/karl-watch-perm-error
fix: Stop StatusWatcher on Forbidden API error
2 parents 2d68222 + 57bbe71 commit 0cb95ee

File tree

5 files changed

+199
-65
lines changed

5 files changed

+199
-65
lines changed

pkg/apply/taskrunner/runner.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ func (tsr *TaskStatusRunner) Run(
121121
statusEvent.Error)
122122
if currentTask != nil {
123123
currentTask.Cancel(taskContext)
124+
} else {
125+
// tasks not started yet - abort now
126+
return complete(abortReason)
124127
}
125128
continue
126129
}
@@ -207,6 +210,9 @@ func (tsr *TaskStatusRunner) Run(
207210
klog.V(7).Infof("Runner aborting: %v", abortReason)
208211
if currentTask != nil {
209212
currentTask.Cancel(taskContext)
213+
} else {
214+
// tasks not started yet - abort now
215+
return complete(abortReason)
210216
}
211217
}
212218
}

pkg/kstatus/watcher/dynamic_informer_factory.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package watcher
55

66
import (
77
"context"
8+
"regexp"
9+
"strings"
810
"time"
911

1012
"k8s.io/apimachinery/pkg/api/meta"
@@ -55,3 +57,36 @@ func (f *DynamicInformerFactory) NewInformer(ctx context.Context, mapping *meta.
5557
f.Indexers,
5658
)
5759
}
60+
61+
// resourceNotFoundMessage is the condition message for metav1.StatusReasonNotFound.
62+
// This is necessary because the Informer doesn't properly wrap list errors.
63+
// https://github.com/kubernetes/client-go/blob/v0.24.0/tools/cache/reflector.go#L325
64+
// https://github.com/kubernetes/apimachinery/blob/v0.24.0/pkg/api/errors/errors.go#L448
65+
// TODO: Remove once fix is released (1.25+): https://github.com/kubernetes/kubernetes/pull/110076
66+
const resourceNotFoundMessage = "the server could not find the requested resource"
67+
68+
// containsNotFoundMessage checks if the error string contains the message for
69+
// StatusReasonNotFound.
70+
func containsNotFoundMessage(err error) bool {
71+
return strings.Contains(err.Error(), resourceNotFoundMessage)
72+
}
73+
74+
// resourceForbiddenMessagePattern is a regex pattern to match the condition
75+
// message for metav1.StatusForbidden.
76+
// This is necessary because the Informer doesn't properly wrap list errors.
77+
// https://github.com/kubernetes/client-go/blob/v0.24.0/tools/cache/reflector.go#L325
78+
// https://github.com/kubernetes/apimachinery/blob/v0.24.0/pkg/api/errors/errors.go#L458
79+
// https://github.com/kubernetes/apimachinery/blob/v0.24.0/pkg/api/errors/errors.go#L208
80+
// https://github.com/kubernetes/apiserver/blob/master/pkg/endpoints/handlers/responsewriters/errors.go#L51
81+
// TODO: Remove once fix is released (1.25+): https://github.com/kubernetes/kubernetes/pull/110076
82+
const resourceForbiddenMessagePattern = `(.+) is forbidden: User "(.*)" cannot (.+) resource "(.*)" in API group "(.*)"`
83+
84+
// resourceForbiddenMessageRegexp is the pre-compiled Regexp of
85+
// resourceForbiddenMessagePattern.
86+
var resourceForbiddenMessageRegexp = regexp.MustCompile(resourceForbiddenMessagePattern)
87+
88+
// containsForbiddenMessage checks if the error string contains the message for
89+
// StatusForbidden.
90+
func containsForbiddenMessage(err error) bool {
91+
return resourceForbiddenMessageRegexp.Match([]byte(err.Error()))
92+
}

pkg/kstatus/watcher/dynamic_informer_factory_test.go

Lines changed: 104 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ package watcher
55

66
import (
77
"context"
8-
"net/http"
8+
"fmt"
99
"testing"
1010
"time"
1111

@@ -54,13 +54,7 @@ func TestResourceNotFoundError(t *testing.T) {
5454
// dynamicClient converts Status objects from the apiserver into errors.
5555
// So we can just return the right error here to simulate an error from
5656
// the apiserver.
57-
name := "" // unused by LIST requests
58-
// The apisevrer confusingly does not return apierrors.NewNotFound,
59-
// which has a nice constant for its error message.
60-
// err = apierrors.NewNotFound(exampleGR, name)
61-
// Instead it uses apierrors.NewGenericServerResponse, which uses
62-
// a hard-coded error message.
63-
err = apierrors.NewGenericServerResponse(http.StatusNotFound, "list", exampleGR, name, "unused", -1, false)
57+
err = newGenericServerResponse(action, newNotFoundResourceStatusError(action))
6458
return true, nil, err
6559
})
6660
},
@@ -88,13 +82,7 @@ func TestResourceNotFoundError(t *testing.T) {
8882
// dynamicClient converts Status objects from the apiserver into errors.
8983
// So we can just return the right error here to simulate an error from
9084
// the apiserver.
91-
name := "" // unused by LIST requests
92-
// The apisevrer confusingly does not return apierrors.NewNotFound,
93-
// which has a nice constant for its error message.
94-
// err = apierrors.NewNotFound(exampleGR, name)
95-
// Instead it uses apierrors.NewGenericServerResponse, which uses
96-
// a hard-coded error message.
97-
err = apierrors.NewGenericServerResponse(http.StatusNotFound, "list", exampleGR, name, "unused", -1, false)
85+
err = newGenericServerResponse(action, newNotFoundResourceStatusError(action))
9886
return true, nil, err
9987
})
10088
},
@@ -110,7 +98,67 @@ func TestResourceNotFoundError(t *testing.T) {
11098
t.Errorf("Expected typed NotFound error, but got untyped NotFound error: %v", err)
11199
default:
112100
// If we got this error, the test is probably broken.
113-
t.Errorf("Expected untyped NotFound error, but got a different error: %v", err)
101+
t.Errorf("Expected typed NotFound error, but got a different error: %v", err)
102+
}
103+
},
104+
},
105+
{
106+
name: "List resource forbidden error",
107+
setup: func(fakeClient *dynamicfake.FakeDynamicClient) {
108+
fakeClient.PrependReactor("list", exampleGR.Resource, func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) {
109+
listAction := action.(clienttesting.ListAction)
110+
if listAction.GetNamespace() != namespace {
111+
assert.Fail(t, "Received unexpected LIST namespace: %s", listAction.GetNamespace())
112+
return false, nil, nil
113+
}
114+
// dynamicClient converts Status objects from the apiserver into errors.
115+
// So we can just return the right error here to simulate an error from
116+
// the apiserver.
117+
err = newGenericServerResponse(action, newForbiddenResourceStatusError(action))
118+
return true, nil, err
119+
})
120+
},
121+
errorHandler: func(t *testing.T, err error) {
122+
switch {
123+
case apierrors.IsForbidden(err):
124+
// If we got this error, something changed in the apiserver or
125+
// client. If the client changed, it might be safe to stop parsing
126+
// the error string.
127+
t.Errorf("Expected untyped Forbidden error, but got typed Forbidden error: %v", err)
128+
case containsForbiddenMessage(err):
129+
// This is the expected hack, because the Informer/Reflector
130+
// doesn't wrap the error with "%w".
131+
t.Logf("Received expected untyped Forbidden error: %v", err)
132+
default:
133+
// If we got this error, the test is probably broken.
134+
t.Errorf("Expected untyped Forbidden error, but got a different error: %v", err)
135+
}
136+
},
137+
},
138+
{
139+
name: "Watch resource forbidden error",
140+
setup: func(fakeClient *dynamicfake.FakeDynamicClient) {
141+
fakeClient.PrependWatchReactor(exampleGR.Resource, func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) {
142+
// dynamicClient converts Status objects from the apiserver into errors.
143+
// So we can just return the right error here to simulate an error from
144+
// the apiserver.
145+
err = newGenericServerResponse(action, newForbiddenResourceStatusError(action))
146+
return true, nil, err
147+
})
148+
},
149+
errorHandler: func(t *testing.T, err error) {
150+
switch {
151+
case apierrors.IsForbidden(err):
152+
// This is the expected behavior, because the
153+
// Informer/Reflector DOES wrap watch errors
154+
t.Logf("Received expected untyped Forbidden error: %v", err)
155+
case containsForbiddenMessage(err):
156+
// If this happens, there was a regression.
157+
// Watch errors are expected to be wrapped with "%w"
158+
t.Errorf("Expected typed Forbidden error, but got untyped Forbidden error: %v", err)
159+
default:
160+
// If we got this error, the test is probably broken.
161+
t.Errorf("Expected typed Forbidden error, but got a different error: %v", err)
114162
}
115163
},
116164
},
@@ -164,3 +212,43 @@ func TestResourceNotFoundError(t *testing.T) {
164212
})
165213
}
166214
}
215+
216+
// newForbiddenResourceStatusError emulates a Forbidden error from the apiserver
217+
// for a namespace-scoped resource.
218+
// https://github.com/kubernetes/apiserver/blob/master/pkg/endpoints/handlers/responsewriters/errors.go#L36
219+
func newForbiddenResourceStatusError(action clienttesting.Action) *apierrors.StatusError {
220+
username := "unused"
221+
verb := action.GetVerb()
222+
resource := action.GetResource().Resource
223+
if subresource := action.GetSubresource(); len(subresource) > 0 {
224+
resource = resource + "/" + subresource
225+
}
226+
apiGroup := action.GetResource().Group
227+
namespace := action.GetNamespace()
228+
229+
// https://github.com/kubernetes/apiserver/blob/master/pkg/endpoints/handlers/responsewriters/errors.go#L51
230+
err := fmt.Errorf("User %q cannot %s resource %q in API group %q in the namespace %q",
231+
username, verb, resource, apiGroup, namespace)
232+
233+
qualifiedResource := action.GetResource().GroupResource()
234+
name := "" // unused by ListAndWatch
235+
return apierrors.NewForbidden(qualifiedResource, name, err)
236+
}
237+
238+
// newNotFoundResourceStatusError emulates a NotFOund error from the apiserver
239+
// for a resource (not an object).
240+
func newNotFoundResourceStatusError(action clienttesting.Action) *apierrors.StatusError {
241+
qualifiedResource := action.GetResource().GroupResource()
242+
name := "" // unused by ListAndWatch
243+
return apierrors.NewNotFound(qualifiedResource, name)
244+
}
245+
246+
// newGenericServerResponse emulates a StatusError from the apiserver.
247+
func newGenericServerResponse(action clienttesting.Action, statusError *apierrors.StatusError) *apierrors.StatusError {
248+
errorCode := int(statusError.ErrStatus.Code)
249+
verb := action.GetVerb()
250+
qualifiedResource := action.GetResource().GroupResource()
251+
name := statusError.ErrStatus.Details.Name
252+
// https://github.com/kubernetes/apimachinery/blob/v0.24.0/pkg/api/errors/errors.go#L435
253+
return apierrors.NewGenericServerResponse(errorCode, verb, qualifiedResource, name, statusError.Error(), -1, false)
254+
}

pkg/kstatus/watcher/event_funnel.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"fmt"
99

10+
"k8s.io/klog/v2"
1011
"sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
1112
)
1213

@@ -37,6 +38,7 @@ func newEventFunnel(ctx context.Context) *eventFunnel {
3738
go func() {
3839
defer func() {
3940
// Don't close counterCh, otherwise AddInputChannel may panic.
41+
klog.V(5).Info("Closing funnel")
4042
close(funnel.outCh)
4143
close(funnel.doneCh)
4244
}()
@@ -48,6 +50,7 @@ func newEventFunnel(ctx context.Context) *eventFunnel {
4850
select {
4951
case delta := <-funnel.counterCh:
5052
inputs += delta
53+
klog.V(5).Infof("Funnel input channels (%+d): %d", delta, inputs)
5154
case <-ctxDoneCh:
5255
// Stop waiting for context closure.
5356
// Nil channel avoids busy waiting.

0 commit comments

Comments
 (0)