
First-in-first-out is wrong for cleanup, it's LIFO. Updated some comments to make them more informative and fixed indention.
471 lines
15 KiB
Go
471 lines
15 KiB
Go
/*
|
|
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
|
|
}
|