Skip to content

Commit 5eef60a

Browse files
committed
Add warnings capability for admission webhooks
1 parent c53ce48 commit 5eef60a

File tree

10 files changed

+184
-9
lines changed

10 files changed

+184
-9
lines changed

pkg/apis/admission/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ type AdmissionResponse struct {
138138
// the admission webhook to add additional context to the audit log for this request.
139139
// +optional
140140
AuditAnnotations map[string]string
141+
// warnings is a list of warning messages to return to the requesting API client.
142+
// Warning messages describe a problem the client making the API request should correct or be aware of.
143+
// Limit warnings to 120 characters if possible.
144+
// Warnings over 256 characters and large numbers of warnings may be truncated.
145+
// +optional
146+
Warnings []string
141147
}
142148

143149
// PatchType is the type of patch being used to represent the mutated object

staging/src/k8s.io/api/admission/v1/types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ type AdmissionResponse struct {
140140
// the admission webhook to add additional context to the audit log for this request.
141141
// +optional
142142
AuditAnnotations map[string]string `json:"auditAnnotations,omitempty" protobuf:"bytes,6,opt,name=auditAnnotations"`
143+
144+
// warnings is a list of warning messages to return to the requesting API client.
145+
// Warning messages describe a problem the client making the API request should correct or be aware of.
146+
// Limit warnings to 120 characters if possible.
147+
// Warnings over 256 characters and large numbers of warnings may be truncated.
148+
// +optional
149+
Warnings []string `json:"warnings,omitempty" protobuf:"bytes,7,rep,name=warnings"`
143150
}
144151

145152
// PatchType is the type of patch being used to represent the mutated object

staging/src/k8s.io/api/admission/v1beta1/types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ type AdmissionResponse struct {
145145
// the admission webhook to add additional context to the audit log for this request.
146146
// +optional
147147
AuditAnnotations map[string]string `json:"auditAnnotations,omitempty" protobuf:"bytes,6,opt,name=auditAnnotations"`
148+
149+
// warnings is a list of warning messages to return to the requesting API client.
150+
// Warning messages describe a problem the client making the API request should correct or be aware of.
151+
// Limit warnings to 120 characters if possible.
152+
// Warnings over 256 characters and large numbers of warnings may be truncated.
153+
// +optional
154+
Warnings []string `json:"warnings,omitempty" protobuf:"bytes,7,rep,name=warnings"`
148155
}
149156

150157
// PatchType is the type of patch being used to represent the mutated object

staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/mutating/dispatcher.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343
webhookrequest "k8s.io/apiserver/pkg/admission/plugin/webhook/request"
4444
auditinternal "k8s.io/apiserver/pkg/apis/audit"
4545
webhookutil "k8s.io/apiserver/pkg/util/webhook"
46+
"k8s.io/apiserver/pkg/warning"
4647
utiltrace "k8s.io/utils/trace"
4748
)
4849

@@ -267,6 +268,9 @@ func (a *mutatingDispatcher) callAttrMutatingHook(ctx context.Context, h *admiss
267268
klog.Warningf("Failed to set admission audit annotation %s to %s for mutating webhook %s: %v", key, v, h.Name, err)
268269
}
269270
}
271+
for _, w := range result.Warnings {
272+
warning.AddWarning(ctx, "", w)
273+
}
270274

271275
if !result.Allowed {
272276
return false, &webhookutil.ErrWebhookRejection{Status: webhookerrors.ToStatusErr(h.Name, result.Result)}

staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/request/admissionreview.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type AdmissionResponse struct {
3636
Patch []byte
3737
PatchType admissionv1.PatchType
3838
Result *metav1.Status
39+
Warnings []string
3940
}
4041

4142
// VerifyAdmissionResponse checks the validity of the provided admission review object, and returns the
@@ -93,6 +94,7 @@ func VerifyAdmissionResponse(uid types.UID, mutating bool, review runtime.Object
9394
Patch: patch,
9495
PatchType: patchType,
9596
Result: r.Response.Result,
97+
Warnings: r.Response.Warnings,
9698
}, nil
9799

98100
case *admissionv1beta1.AdmissionReview:
@@ -118,6 +120,7 @@ func VerifyAdmissionResponse(uid types.UID, mutating bool, review runtime.Object
118120
Patch: patch,
119121
PatchType: patchType,
120122
Result: r.Response.Result,
123+
Warnings: r.Response.Warnings,
121124
}, nil
122125

123126
default:

staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/validating/dispatcher.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"k8s.io/apiserver/pkg/admission/plugin/webhook/generic"
3434
webhookrequest "k8s.io/apiserver/pkg/admission/plugin/webhook/request"
3535
webhookutil "k8s.io/apiserver/pkg/util/webhook"
36+
"k8s.io/apiserver/pkg/warning"
3637
"k8s.io/klog/v2"
3738
utiltrace "k8s.io/utils/trace"
3839
)
@@ -227,6 +228,9 @@ func (d *validatingDispatcher) callHook(ctx context.Context, h *v1.ValidatingWeb
227228
klog.Warningf("Failed to set admission audit annotation %s to %s for validating webhook %s: %v", key, v, h.Name, err)
228229
}
229230
}
231+
for _, w := range result.Warnings {
232+
warning.AddWarning(ctx, "", w)
233+
}
230234
if result.Allowed {
231235
return nil
232236
}

staging/src/k8s.io/apiserver/pkg/endpoints/filters/warning.go

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ limitations under the License.
1717
package filters
1818

1919
import (
20+
"fmt"
2021
"net/http"
2122
"sync"
23+
"unicode/utf8"
2224

2325
"k8s.io/apimachinery/pkg/util/net"
26+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
2427
"k8s.io/apiserver/pkg/warning"
2528
)
2629

@@ -33,10 +36,34 @@ func WithWarningRecorder(handler http.Handler) http.Handler {
3336
})
3437
}
3538

39+
var (
40+
truncateAtTotalRunes = 4 * 1024
41+
truncateItemRunes = 256
42+
)
43+
44+
type recordedWarning struct {
45+
agent string
46+
text string
47+
}
48+
3649
type recorder struct {
37-
lock sync.Mutex
50+
// lock guards calls to AddWarning from multiple threads
51+
lock sync.Mutex
52+
53+
// recorded tracks whether AddWarning was already called with a given text
3854
recorded map[string]bool
39-
writer http.ResponseWriter
55+
56+
// ordered tracks warnings added so they can be replayed and truncated if needed
57+
ordered []recordedWarning
58+
59+
// written tracks how many runes of text have been added as warning headers
60+
written int
61+
62+
// truncating tracks if we have already exceeded truncateAtTotalRunes and are now truncating warning messages as we add them
63+
truncating bool
64+
65+
// writer is the response writer to add warning headers to
66+
writer http.ResponseWriter
4067
}
4168

4269
func (r *recorder) AddWarning(agent, text string) {
@@ -47,6 +74,11 @@ func (r *recorder) AddWarning(agent, text string) {
4774
r.lock.Lock()
4875
defer r.lock.Unlock()
4976

77+
// if we've already exceeded our limit and are already truncating, return early
78+
if r.written >= truncateAtTotalRunes && r.truncating {
79+
return
80+
}
81+
5082
// init if needed
5183
if r.recorded == nil {
5284
r.recorded = map[string]bool{}
@@ -57,13 +89,45 @@ func (r *recorder) AddWarning(agent, text string) {
5789
return
5890
}
5991
r.recorded[text] = true
92+
r.ordered = append(r.ordered, recordedWarning{agent: agent, text: text})
6093

61-
// TODO(liggitt): track total message characters written:
62-
// * if this takes us over 4k truncate individual messages to 256 chars and regenerate headers
63-
// * if we're already truncating truncate this message to 256 chars
64-
// * if we're still over 4k omit this message
94+
// truncate on a rune boundary, if needed
95+
textRuneLength := utf8.RuneCountInString(text)
96+
if r.truncating && textRuneLength > truncateItemRunes {
97+
text = string([]rune(text)[:truncateItemRunes])
98+
textRuneLength = truncateItemRunes
99+
}
100+
101+
// compute the header
102+
header, err := net.NewWarningHeader(299, agent, text)
103+
if err != nil {
104+
return
105+
}
65106

66-
if header, err := net.NewWarningHeader(299, agent, text); err == nil {
107+
// if this fits within our limit, or we're already truncating, write and return
108+
if r.written+textRuneLength <= truncateAtTotalRunes || r.truncating {
109+
r.written += textRuneLength
67110
r.writer.Header().Add("Warning", header)
111+
return
112+
}
113+
114+
// otherwise, enable truncation, reset, and replay the existing items as truncated warnings
115+
r.truncating = true
116+
r.written = 0
117+
r.writer.Header().Del("Warning")
118+
utilruntime.HandleError(fmt.Errorf("exceeded max warning header size, truncating"))
119+
for _, w := range r.ordered {
120+
agent := w.agent
121+
text := w.text
122+
123+
textRuneLength := utf8.RuneCountInString(text)
124+
if textRuneLength > truncateItemRunes {
125+
text = string([]rune(text)[:truncateItemRunes])
126+
textRuneLength = truncateItemRunes
127+
}
128+
if header, err := net.NewWarningHeader(299, agent, text); err == nil {
129+
r.written += textRuneLength
130+
r.writer.Header().Add("Warning", header)
131+
}
68132
}
69133
}

staging/src/k8s.io/apiserver/pkg/endpoints/filters/warning_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,35 @@ func Test_recorder_AddWarning(t *testing.T) {
9898
})
9999
}
100100
}
101+
102+
func TestTruncation(t *testing.T) {
103+
originalTotalRunes, originalItemRunes := truncateAtTotalRunes, truncateItemRunes
104+
truncateAtTotalRunes, truncateItemRunes = 25, 5
105+
defer func() {
106+
truncateAtTotalRunes, truncateItemRunes = originalTotalRunes, originalItemRunes
107+
}()
108+
109+
responseRecorder := httptest.NewRecorder()
110+
warningRecorder := &recorder{writer: responseRecorder}
111+
112+
// add items longer than the individual length
113+
warningRecorder.AddWarning("", "aaaaaaaaaa") // long item
114+
warningRecorder.AddWarning("-", "aaaaaaaaaa") // duplicate item
115+
warningRecorder.AddWarning("", "bb") // short item
116+
warningRecorder.AddWarning("", "ccc") // short item
117+
warningRecorder.AddWarning("", "Iñtërnâtiô") // long item
118+
// check they are preserved
119+
if e, a := []string{`299 - "aaaaaaaaaa"`, `299 - "bb"`, `299 - "ccc"`, `299 - "Iñtërnâtiô"`}, responseRecorder.Header()["Warning"]; !reflect.DeepEqual(e, a) {
120+
t.Errorf("expected\n%#v\ngot\n%#v", e, a)
121+
}
122+
// add an item that exceeds the length and triggers truncation, reducing existing items to 15 runes
123+
warningRecorder.AddWarning("", "e") // triggering item, 16 total
124+
warningRecorder.AddWarning("", "ffffffffff") // long item to get truncated, 21 total
125+
warningRecorder.AddWarning("", "ffffffffff") // duplicate item
126+
warningRecorder.AddWarning("", "gggggggggg") // item to get truncated, 26 total
127+
warningRecorder.AddWarning("", "h") // item to get ignored since we're over our limit
128+
// check that existing items are truncated, and order preserved
129+
if e, a := []string{`299 - "aaaaa"`, `299 - "bb"`, `299 - "ccc"`, `299 - "Iñtër"`, `299 - "e"`, `299 - "fffff"`, `299 - "ggggg"`}, responseRecorder.Header()["Warning"]; !reflect.DeepEqual(e, a) {
130+
t.Errorf("expected\n%#v\ngot\n%#v", e, a)
131+
}
132+
}

test/images/agnhost/webhook/convert.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ func convertAdmissionResponseToV1(r *v1beta1.AdmissionResponse) *v1.AdmissionRes
7575
Patch: r.Patch,
7676
PatchType: pt,
7777
Result: r.Result,
78+
Warnings: r.Warnings,
7879
}
7980
}
8081

@@ -91,6 +92,7 @@ func convertAdmissionResponseToV1beta1(r *v1.AdmissionResponse) *v1beta1.Admissi
9192
Patch: r.Patch,
9293
PatchType: pt,
9394
Result: r.Result,
95+
Warnings: r.Warnings,
9496
}
9597
}
9698

test/integration/apiserver/admissionwebhook/admission_test.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ type holder struct {
169169

170170
t *testing.T
171171

172+
warningHandler *warningHandler
173+
172174
recordGVR metav1.GroupVersionResource
173175
recordOperation string
174176
recordNamespace string
@@ -203,6 +205,7 @@ func (h *holder) reset(t *testing.T) {
203205
h.expectOldObject = false
204206
h.expectOptionsGVK = schema.GroupVersionKind{}
205207
h.expectOptions = false
208+
h.warningHandler.reset()
206209

207210
// Set up the recorded map with nil records for all combinations
208211
h.recorded = map[webhookOptions]*admissionRequest{}
@@ -231,6 +234,7 @@ func (h *holder) expect(gvr schema.GroupVersionResource, gvk, optionsGVK schema.
231234
h.expectOldObject = oldObject
232235
h.expectOptionsGVK = optionsGVK
233236
h.expectOptions = options
237+
h.warningHandler.reset()
234238

235239
// Set up the recorded map with nil records for all combinations
236240
h.recorded = map[webhookOptions]*admissionRequest{}
@@ -314,13 +318,15 @@ func (h *holder) verify(t *testing.T) {
314318
defer h.lock.Unlock()
315319

316320
for options, value := range h.recorded {
317-
if err := h.verifyRequest(options.converted, value); err != nil {
321+
if err := h.verifyRequest(options, value); err != nil {
318322
t.Errorf("version: %v, phase:%v, converted:%v error: %v", options.version, options.phase, options.converted, err)
319323
}
320324
}
321325
}
322326

323-
func (h *holder) verifyRequest(converted bool, request *admissionRequest) error {
327+
func (h *holder) verifyRequest(webhookOptions webhookOptions, request *admissionRequest) error {
328+
converted := webhookOptions.converted
329+
324330
// Check if current resource should be exempted from Admission processing
325331
if admissionExemptResources[gvr(h.recordGVR.Group, h.recordGVR.Version, h.recordGVR.Resource)] {
326332
if request == nil {
@@ -357,6 +363,10 @@ func (h *holder) verifyRequest(converted bool, request *admissionRequest) error
357363
return fmt.Errorf("unexpected options: %#v", request.Options.Object)
358364
}
359365

366+
if !h.warningHandler.hasWarning(makeWarning(webhookOptions.version, webhookOptions.phase, webhookOptions.converted)) {
367+
return fmt.Errorf("no warning received from webhook")
368+
}
369+
360370
return nil
361371
}
362372

@@ -384,6 +394,34 @@ func (h *holder) verifyOptions(options runtime.Object) error {
384394
return nil
385395
}
386396

397+
type warningHandler struct {
398+
lock sync.Mutex
399+
warnings map[string]bool
400+
}
401+
402+
func (w *warningHandler) reset() {
403+
w.lock.Lock()
404+
defer w.lock.Unlock()
405+
w.warnings = map[string]bool{}
406+
}
407+
func (w *warningHandler) hasWarning(warning string) bool {
408+
w.lock.Lock()
409+
defer w.lock.Unlock()
410+
return w.warnings[warning]
411+
}
412+
func makeWarning(version string, phase string, converted bool) string {
413+
return fmt.Sprintf("%v/%v/%v", version, phase, converted)
414+
}
415+
416+
func (w *warningHandler) HandleWarningHeader(code int, agent string, message string) {
417+
if code != 299 || len(message) == 0 {
418+
return
419+
}
420+
w.lock.Lock()
421+
defer w.lock.Unlock()
422+
w.warnings[message] = true
423+
}
424+
387425
// TestWebhookAdmissionWithWatchCache tests communication between API server and webhook process.
388426
func TestWebhookAdmissionWithWatchCache(t *testing.T) {
389427
testWebhookAdmission(t, true)
@@ -399,6 +437,7 @@ func testWebhookAdmission(t *testing.T, watchCache bool) {
399437
// holder communicates expectations to webhooks, and results from webhooks
400438
holder := &holder{
401439
t: t,
440+
warningHandler: &warningHandler{warnings: map[string]bool{}},
402441
gvrToConvertedGVR: map[metav1.GroupVersionResource]metav1.GroupVersionResource{},
403442
gvrToConvertedGVK: map[metav1.GroupVersionResource]schema.GroupVersionKind{},
404443
}
@@ -451,6 +490,7 @@ func testWebhookAdmission(t *testing.T, watchCache bool) {
451490
clientConfig := rest.CopyConfig(server.ClientConfig)
452491
clientConfig.Impersonate.UserName = testClientUsername
453492
clientConfig.Impersonate.Groups = []string{"system:masters", "system:authenticated"}
493+
clientConfig.WarningHandler = holder.warningHandler
454494
client, err := clientset.NewForConfig(clientConfig)
455495
if err != nil {
456496
t.Fatalf("unexpected error: %v", err)
@@ -1265,6 +1305,9 @@ func newV1beta1WebhookHandler(t *testing.T, holder *holder, phase string, conver
12651305
review.Kind = ""
12661306
review.Response.UID = ""
12671307

1308+
// test plumbing warnings back to the client
1309+
review.Response.Warnings = []string{makeWarning("v1beta1", phase, converted)}
1310+
12681311
// If we're mutating, and have an object, return a patch to exercise conversion
12691312
if phase == mutation && len(review.Request.Object.Raw) > 0 {
12701313
review.Response.Patch = []byte(`[{"op":"add","path":"/foo","value":"test"}]`)
@@ -1355,6 +1398,9 @@ func newV1WebhookHandler(t *testing.T, holder *holder, phase string, converted b
13551398
Allowed: true,
13561399
UID: review.Request.UID,
13571400
Result: &metav1.Status{Message: "admitted"},
1401+
1402+
// test plumbing warnings back
1403+
Warnings: []string{makeWarning("v1", phase, converted)},
13581404
}
13591405
// If we're mutating, and have an object, return a patch to exercise conversion
13601406
if phase == mutation && len(review.Request.Object.Raw) > 0 {

0 commit comments

Comments
 (0)