-
Notifications
You must be signed in to change notification settings - Fork 38.7k
/
tcontext.go
470 lines (416 loc) · 14.8 KB
/
tcontext.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
/*
Copyright 2023 The Kubernetes Authors.
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 ktesting
import (
"context"
"flag"
"fmt"
"time"
"github.com/onsi/gomega"
apiextensions "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
"k8s.io/client-go/dynamic"
clientset "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/klog/v2"
"k8s.io/klog/v2/ktesting"
"k8s.io/kubernetes/test/utils/format"
"k8s.io/kubernetes/test/utils/ktesting/initoption"
"k8s.io/kubernetes/test/utils/ktesting/internal"
)
// Underlier is the additional interface implemented by the per-test LogSink
// behind [TContext.Logger].
type Underlier = ktesting.Underlier
// CleanupGracePeriod is the time that a [TContext] gets canceled before the
// deadline of its underlying test suite (usually determined via "go test
// -timeout"). This gives the running test(s) time to fail with an informative
// timeout error. After that, all cleanup callbacks then have the remaining
// time to complete before the test binary is killed.
//
// For this to work, each blocking calls in a test must respect the
// cancellation of the [TContext].
//
// When using Ginkgo to manage the test suite and running tests, the
// CleanupGracePeriod is ignored because Ginkgo itself manages timeouts.
const CleanupGracePeriod = 5 * time.Second
// TContext combines [context.Context], [TB] and some additional
// methods. Log output is associated with the current test. Errors ([Error],
// [Errorf]) are recorded with "ERROR" as prefix, fatal errors ([Fatal],
// [Fatalf]) with "FATAL ERROR".
//
// TContext provides features offered by Ginkgo also when using normal Go [testing]:
// - The context contains a deadline that expires soon enough before
// the overall timeout that cleanup code can still run.
// - Cleanup callbacks can get their own, separate contexts when
// registered via [CleanupCtx].
// - CTRL-C aborts, prints a progress report, and then cleans up
// before terminating.
// - SIGUSR1 prints a progress report without aborting.
//
// Progress reporting is more informative when doing polling with
// [gomega.Eventually] and [gomega.Consistently]. Without that, it
// can only report which tests are active.
type TContext interface {
context.Context
TB
// Cancel can be invoked to cancel the context before the test is completed.
// Tests which use the context to control goroutines and then wait for
// termination of those goroutines must call Cancel to avoid a deadlock.
//
// The cause, if non-empty, is turned into an error which is equivalend
// to context.Canceled. context.Cause will return that error for the
// context.
Cancel(cause string)
// Cleanup registers a callback that will get invoked when the test
// has finished. Callbacks get invoked in last-in-first-out order (LIFO).
//
// Beware of context cancellation. The following cleanup code
// will use a canceled context, which is not desirable:
//
// tCtx.Cleanup(func() { /* do something with tCtx */ })
// tCtx.Cancel()
//
// A safer way to run cleanup code is:
//
// tCtx.CleanupCtx(func (tCtx ktesting.TContext) { /* do something with cleanup tCtx */ })
Cleanup(func())
// CleanupCtx is an alternative for Cleanup. The callback is passed a
// new TContext with the same logger and clients as the one CleanupCtx
// was invoked for.
CleanupCtx(func(TContext))
// Expect wraps [gomega.Expect] such that a failure will be reported via
// [TContext.Fatal]. As with [gomega.Expect], additional values
// may get passed. Those values then all must be nil for the assertion
// to pass. This can be used with functions which return a value
// plus error:
//
// myAmazingThing := func(int, error) { ...}
// tCtx.Expect(myAmazingThing()).Should(gomega.Equal(1))
Expect(actual interface{}, extra ...interface{}) gomega.Assertion
// ExpectNoError asserts that no error has occurred.
//
// As in [gomega], the optional explanation can be:
// - a [fmt.Sprintf] format string plus its argument
// - a function returning a string, which will be called
// lazy to construct the explanation if needed
//
// If an explanation is provided, then it replaces the default "Unexpected
// error" in the failure message. It's combined with additional details by
// adding a colon at the end, as when wrapping an error. Therefore it should
// not end with a punctuation mark or line break.
//
// Using ExpectNoError instead of the corresponding Gomega or testify
// assertions has the advantage that the failure message is short (good for
// aggregation in https://go.k8s.io/triage) with more details captured in the
// test log output (good when investigating one particular failure).
ExpectNoError(err error, explain ...interface{})
// Logger returns a logger for the current test. This is a shortcut
// for calling klog.FromContext.
//
// Output emitted via this logger and the TB interface (like Logf)
// is formatted consistently. The TB interface generates a single
// message string, while Logger enables structured logging and can
// be passed down into code which expects a logger.
//
// To skip intermediate helper functions during stack unwinding,
// TB.Helper can be called in those functions.
Logger() klog.Logger
// TB returns the underlying TB. This can be used to "break the glass"
// and cast back into a testing.T or TB. Calling TB is necessary
// because TContext wraps the underlying TB.
TB() TB
// RESTConfig returns a config for a rest client with the UserAgent set
// to include the current test name or nil if not available. Several
// typed clients using this config are available through [Client],
// [Dynamic], [APIExtensions].
RESTConfig() *rest.Config
RESTMapper() *restmapper.DeferredDiscoveryRESTMapper
Client() clientset.Interface
Dynamic() dynamic.Interface
APIExtensions() apiextensions.Interface
// The following methods must be implemented by every implementation
// of TContext to ensure that the leaf TContext is used, not some
// embedded TContext:
// - CleanupCtx
// - Expect
// - ExpectNoError
// - Logger
//
// Usually these methods would be stand-alone functions with a TContext
// parameter. Offering them as methods simplifies the test code.
}
// TB is the interface common to [testing.T], [testing.B], [testing.F] and
// [github.com/onsi/ginkgo/v2]. In contrast to [testing.TB], it can be
// implemented also outside of the testing package.
type TB interface {
Cleanup(func())
Error(args ...any)
Errorf(format string, args ...any)
Fail()
FailNow()
Failed() bool
Fatal(args ...any)
Fatalf(format string, args ...any)
Helper()
Log(args ...any)
Logf(format string, args ...any)
Name() string
Setenv(key, value string)
Skip(args ...any)
SkipNow()
Skipf(format string, args ...any)
Skipped() bool
TempDir() string
}
// ContextTB adds support for cleanup callbacks with explicit context
// parameter. This is used when integrating with Ginkgo: then CleanupCtx
// gets implemented via ginkgo.DeferCleanup.
type ContextTB interface {
TB
CleanupCtx(func(ctx context.Context))
}
// Init can be called in a unit or integration test to create
// a test context which:
// - has a per-test logger with verbosity derived from the -v command line flag
// - gets canceled when the test finishes (via [TB.Cleanup])
//
// Note that the test context supports the interfaces of [TB] and
// [context.Context] and thus can be used like one of those where needed.
// It also has additional methods for retrieving the logger and canceling
// the context early, which can be useful in tests which want to wait
// for goroutines to terminate after cancellation.
//
// If the [TB] implementation also implements [ContextTB], then
// [TContext.CleanupCtx] uses [ContextTB.CleanupCtx] and uses
// the context passed into that callback. This can be used to let
// Ginkgo create a fresh context for cleanup code.
//
// Can be called more than once per test to get different contexts with
// independent cancellation. The default behavior describe above can be
// modified via optional functional options defined in [initoption].
func Init(tb TB, opts ...InitOption) TContext {
tb.Helper()
c := internal.InitConfig{
PerTestOutput: true,
}
for _, opt := range opts {
opt(&c)
}
// We don't need a Deadline implementation, testing.B doesn't have it.
// But if we have one, we'll use it to set a timeout shortly before
// the deadline. This needs to come before we wrap tb.
deadlineTB, deadlineOK := tb.(interface {
Deadline() (time.Time, bool)
})
ctx := interruptCtx
if c.PerTestOutput {
config := ktesting.NewConfig(
ktesting.AnyToString(func(v interface{}) string {
return format.Object(v, 1)
}),
ktesting.VerbosityFlagName("v"),
ktesting.VModuleFlagName("vmodule"),
)
// Copy klog settings instead of making the ktesting logger
// configurable directly.
var fs flag.FlagSet
config.AddFlags(&fs)
for _, name := range []string{"v", "vmodule"} {
from := flag.CommandLine.Lookup(name)
to := fs.Lookup(name)
if err := to.Value.Set(from.Value.String()); err != nil {
panic(err)
}
}
// Ensure consistent logging: this klog.Logger writes to tb, adding the
// date/time header, and our own wrapper emulates that behavior for
// Log/Logf/...
logger := ktesting.NewLogger(tb, config)
ctx = klog.NewContext(interruptCtx, logger)
tb = withKlogHeader(tb)
}
if deadlineOK {
if deadline, ok := deadlineTB.Deadline(); ok {
timeLeft := time.Until(deadline)
timeLeft -= CleanupGracePeriod
ctx, cancel := withTimeout(ctx, tb, timeLeft, fmt.Sprintf("test suite deadline (%s) is close, need to clean up before the %s cleanup grace period", deadline.Truncate(time.Second), CleanupGracePeriod))
tCtx := tContext{
Context: ctx,
testingTB: testingTB{TB: tb},
cancel: cancel,
}
return tCtx
}
}
return WithCancel(InitCtx(ctx, tb))
}
type InitOption = initoption.InitOption
// InitCtx is a variant of [Init] which uses an already existing context and
// whatever logger and timeouts are stored there.
// Functional options are part of the API, but currently
// there are none which have an effect.
func InitCtx(ctx context.Context, tb TB, _ ...InitOption) TContext {
tCtx := tContext{
Context: ctx,
testingTB: testingTB{TB: tb},
}
return tCtx
}
// WithTB constructs a new TContext with a different TB instance.
// This can be used to set up some of the context, in particular
// clients, in the root test and then run sub-tests:
//
// func TestSomething(t *testing.T) {
// tCtx := ktesting.Init(t)
// ...
// tCtx = ktesting.WithRESTConfig(tCtx, config)
//
// t.Run("sub", func (t *testing.T) {
// tCtx := ktesting.WithTB(tCtx, t)
// ...
// })
//
// WithTB sets up cancellation for the sub-test.
func WithTB(parentCtx TContext, tb TB) TContext {
tCtx := InitCtx(parentCtx, tb)
tCtx = WithCancel(tCtx)
tCtx = WithClients(tCtx,
parentCtx.RESTConfig(),
parentCtx.RESTMapper(),
parentCtx.Client(),
parentCtx.Dynamic(),
parentCtx.APIExtensions(),
)
return tCtx
}
// WithContext constructs a new TContext with a different Context instance.
// This can be used in callbacks which receive a Context, for example
// from Gomega:
//
// gomega.Eventually(tCtx, func(ctx context.Context) {
// tCtx := ktesting.WithContext(tCtx, ctx)
// ...
//
// This is important because the Context in the callback could have
// a different deadline than in the parent TContext.
func WithContext(parentCtx TContext, ctx context.Context) TContext {
tCtx := InitCtx(ctx, parentCtx.TB())
tCtx = WithClients(tCtx,
parentCtx.RESTConfig(),
parentCtx.RESTMapper(),
parentCtx.Client(),
parentCtx.Dynamic(),
parentCtx.APIExtensions(),
)
return tCtx
}
// WithValue wraps context.WithValue such that the result is again a TContext.
func WithValue(parentCtx TContext, key, val any) TContext {
ctx := context.WithValue(parentCtx, key, val)
return WithContext(parentCtx, ctx)
}
type tContext struct {
context.Context
testingTB
cancel func(cause string)
}
// testingTB is needed to avoid a name conflict
// between field and method in tContext.
type testingTB struct {
TB
}
func (tCtx tContext) Cancel(cause string) {
if tCtx.cancel != nil {
tCtx.cancel(cause)
}
}
func (tCtx tContext) CleanupCtx(cb func(TContext)) {
tCtx.Helper()
cleanupCtx(tCtx, cb)
}
func (tCtx tContext) Expect(actual interface{}, extra ...interface{}) gomega.Assertion {
tCtx.Helper()
return expect(tCtx, actual, extra...)
}
func (tCtx tContext) ExpectNoError(err error, explain ...interface{}) {
tCtx.Helper()
expectNoError(tCtx, err, explain...)
}
func cleanupCtx(tCtx TContext, cb func(TContext)) {
tCtx.Helper()
if tb, ok := tCtx.TB().(ContextTB); ok {
// Use context from base TB (most likely Ginkgo).
tb.CleanupCtx(func(ctx context.Context) {
tCtx := WithContext(tCtx, ctx)
cb(tCtx)
})
return
}
tCtx.Cleanup(func() {
// Use new context. This is the code path for "go test". The
// context then has *no* deadline. In the code path above for
// Ginkgo, Ginkgo is more sophisticated and also applies
// timeouts to cleanup calls which accept a context.
childCtx := WithContext(tCtx, context.WithoutCancel(tCtx))
cb(childCtx)
})
}
func (tCtx tContext) Logger() klog.Logger {
return klog.FromContext(tCtx)
}
func (tCtx tContext) Error(args ...any) {
tCtx.Helper()
args = append([]any{"ERROR:"}, args...)
tCtx.testingTB.Error(args...)
}
func (tCtx tContext) Errorf(format string, args ...any) {
tCtx.Helper()
error := fmt.Sprintf(format, args...)
error = "ERROR: " + error
tCtx.testingTB.Error(error)
}
func (tCtx tContext) Fatal(args ...any) {
tCtx.Helper()
args = append([]any{"FATAL ERROR:"}, args...)
tCtx.testingTB.Fatal(args...)
}
func (tCtx tContext) Fatalf(format string, args ...any) {
tCtx.Helper()
error := fmt.Sprintf(format, args...)
error = "FATAL ERROR: " + error
tCtx.testingTB.Fatal(error)
}
func (tCtx tContext) TB() TB {
// Might have to unwrap twice, depending on how
// this tContext was constructed.
tb := tCtx.testingTB.TB
if k, ok := tb.(klogTB); ok {
return k.TB
}
return tb
}
func (tCtx tContext) RESTConfig() *rest.Config {
return nil
}
func (tCtx tContext) RESTMapper() *restmapper.DeferredDiscoveryRESTMapper {
return nil
}
func (tCtx tContext) Client() clientset.Interface {
return nil
}
func (tCtx tContext) Dynamic() dynamic.Interface {
return nil
}
func (tCtx tContext) APIExtensions() apiextensions.Interface {
return nil
}