blob: abcdffcfae10fc13241083af34e1552fda6b44fe [file] [log] [blame]
// Copyright Istio 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 framework
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
goruntime "runtime"
"strings"
"sync"
"testing"
"time"
)
import (
"gopkg.in/yaml.v2"
"istio.io/pkg/log"
)
import (
kubelib "github.com/apache/dubbo-go-pixiu/pkg/kube"
"github.com/apache/dubbo-go-pixiu/pkg/test/echo"
"github.com/apache/dubbo-go-pixiu/pkg/test/framework/components/cluster"
"github.com/apache/dubbo-go-pixiu/pkg/test/framework/components/environment/kube"
"github.com/apache/dubbo-go-pixiu/pkg/test/framework/config"
ferrors "github.com/apache/dubbo-go-pixiu/pkg/test/framework/errors"
"github.com/apache/dubbo-go-pixiu/pkg/test/framework/label"
"github.com/apache/dubbo-go-pixiu/pkg/test/framework/resource"
"github.com/apache/dubbo-go-pixiu/pkg/test/scopes"
"github.com/apache/dubbo-go-pixiu/pkg/test/util/file"
)
// test.Run uses 0, 1, 2 exit codes. Use different exit codes for our framework.
const (
// Indicates a framework-level init error
exitCodeInitError = -1
// Indicates an error due to the setup function supplied by the user
exitCodeSetupError = -2
)
var (
rt *runtime
rtMu sync.Mutex
// Well-known paths which are stripped when generating test IDs.
// Note: Order matters! Always specify the most specific directory first.
wellKnownPaths = mustCompileAll(
// This allows us to trim test IDs on the istio.io/istio.io repo.
".*/istio.io/istio.io/",
// These allow us to trim test IDs on istio.io/istio repo.
".*/istio.io/istio/tests/integration/",
".*/istio.io/istio/",
// These are also used for istio.io/istio, but make help to satisfy
// the feature label enforcement when running with BUILD_WITH_CONTAINER=1.
"^/work/tests/integration/",
"^/work/",
// Outside of standard Istio GOPATH
".*/istio/tests/integration/",
)
)
// getSettingsFunc is a function used to extract the default settings for the Suite.
type getSettingsFunc func(string) (*resource.Settings, error)
// mRunFn abstracts testing.M.run, so that the framework itself can be tested.
type mRunFn func(ctx *suiteContext) int
// Suite allows the test author to specify suite-related metadata and do setup in a fluent-style, before commencing execution.
type Suite interface {
// EnvironmentFactory sets a custom function used for creating the resource.Environment for this Suite.
EnvironmentFactory(fn resource.EnvironmentFactory) Suite
// Label all the tests in suite with the given labels
Label(labels ...label.Instance) Suite
// SkipIf skips the suite if the function returns true
SkipIf(reason string, fn resource.ShouldSkipFn) Suite
// Skip marks a suite as skipped with the given reason. This will prevent any setup functions from occurring.
Skip(reason string) Suite
// RequireMinClusters ensures that the current environment contains at least the given number of clusters.
// Otherwise it stops test execution.
RequireMinClusters(minClusters int) Suite
// RequireMaxClusters ensures that the current environment contains at least the given number of clusters.
// Otherwise it stops test execution.
RequireMaxClusters(maxClusters int) Suite
// RequireSingleCluster is a utility method that requires that there be exactly 1 cluster in the environment.
//
// Deprecated: All new tests should support multiple clusters.
RequireSingleCluster() Suite
// RequireMultiPrimary ensures that each cluster is running a control plane.
//
// Deprecated: All new tests should work for any control plane topology.
RequireMultiPrimary() Suite
// RequireMinVersion validates the environment meets a minimum version
RequireMinVersion(minorVersion uint) Suite
// RequireMaxVersion validates the environment meets a maximum version
RequireMaxVersion(minorVersion uint) Suite
// Setup runs enqueues the given setup function to run before test execution.
Setup(fn resource.SetupFn) Suite
// Run the suite. This method calls os.Exit and does not return.
Run()
}
// suiteImpl will actually run the test suite
type suiteImpl struct {
testID string
skipMessage string
skipFn resource.ShouldSkipFn
mRun mRunFn
osExit func(int)
labels label.Set
requireFns []resource.SetupFn
setupFns []resource.SetupFn
getSettings getSettingsFunc
envFactory resource.EnvironmentFactory
}
// Given the filename of a test, derive its suite name
func deriveSuiteName(caller string) string {
d := filepath.Dir(caller)
// We will trim out paths preceding some well known paths. This should handle anything in istio or docs repo,
// as well as special case tests/integration. The end result is a test under ./tests/integration/pilot/ingress
// will become pilot_ingress
// Note: if this fails to trim, we end up with "ugly" suite names but otherwise no real impact.
for _, wellKnownPath := range wellKnownPaths {
// Try removing this path from the directory name.
result := wellKnownPath.ReplaceAllString(d, "")
if len(result) < len(d) {
// Successfully found and removed this path from the directory.
d = result
break
}
}
return strings.ReplaceAll(d, "/", "_")
}
// NewSuite returns a new suite instance.
func NewSuite(m *testing.M) Suite {
_, f, _, _ := goruntime.Caller(1)
suiteName := deriveSuiteName(f)
if analyze() {
return newSuiteAnalyzer(
suiteName,
func(_ *suiteContext) int {
return m.Run()
},
os.Exit)
}
return newSuite(suiteName,
func(_ *suiteContext) int {
return m.Run()
},
os.Exit,
getSettings)
}
func newSuite(testID string, fn mRunFn, osExit func(int), getSettingsFn getSettingsFunc) *suiteImpl {
s := &suiteImpl{
testID: testID,
mRun: fn,
osExit: osExit,
getSettings: getSettingsFn,
labels: label.NewSet(),
}
return s
}
func (s *suiteImpl) EnvironmentFactory(fn resource.EnvironmentFactory) Suite {
if fn != nil && s.envFactory != nil {
scopes.Framework.Warn("EnvironmentFactory overridden multiple times for Suite")
}
s.envFactory = fn
return s
}
func (s *suiteImpl) Label(labels ...label.Instance) Suite {
s.labels = s.labels.Add(labels...)
return s
}
func (s *suiteImpl) Skip(reason string) Suite {
s.skipMessage = reason
s.skipFn = func(ctx resource.Context) bool {
return true
}
return s
}
func (s *suiteImpl) SkipIf(reason string, fn resource.ShouldSkipFn) Suite {
s.skipMessage = reason
s.skipFn = fn
return s
}
func (s *suiteImpl) RequireMinClusters(minClusters int) Suite {
if minClusters <= 0 {
minClusters = 1
}
fn := func(ctx resource.Context) error {
if len(clusters(ctx)) < minClusters {
s.Skip(fmt.Sprintf("Number of clusters %d does not exceed minimum %d",
len(clusters(ctx)), minClusters))
}
return nil
}
s.requireFns = append(s.requireFns, fn)
return s
}
func (s *suiteImpl) RequireMaxClusters(maxClusters int) Suite {
if maxClusters <= 0 {
maxClusters = 1
}
fn := func(ctx resource.Context) error {
if len(clusters(ctx)) > maxClusters {
s.Skip(fmt.Sprintf("Number of clusters %d exceeds maximum %d",
len(clusters(ctx)), maxClusters))
}
return nil
}
s.requireFns = append(s.requireFns, fn)
return s
}
func (s *suiteImpl) RequireSingleCluster() Suite {
return s.RequireMinClusters(1).RequireMaxClusters(1)
}
func (s *suiteImpl) RequireMultiPrimary() Suite {
fn := func(ctx resource.Context) error {
for _, c := range ctx.Clusters() {
if !c.IsPrimary() {
s.Skip(fmt.Sprintf("Cluster %s is not using a local control plane",
c.Name()))
}
}
return nil
}
s.requireFns = append(s.requireFns, fn)
return s
}
func (s *suiteImpl) RequireMinVersion(minorVersion uint) Suite {
fn := func(ctx resource.Context) error {
for _, c := range ctx.Clusters().Kube() {
ver, err := c.GetKubernetesVersion()
if err != nil {
return fmt.Errorf("failed to get Kubernetes version: %v", err)
}
if !kubelib.IsAtLeastVersion(c, minorVersion) {
s.Skip(fmt.Sprintf("Required Kubernetes version (1.%v) is greater than current: %v",
minorVersion, ver.String()))
}
}
return nil
}
s.requireFns = append(s.requireFns, fn)
return s
}
func (s *suiteImpl) RequireMaxVersion(minorVersion uint) Suite {
fn := func(ctx resource.Context) error {
for _, c := range ctx.Clusters().Kube() {
ver, err := c.GetKubernetesVersion()
if err != nil {
return fmt.Errorf("failed to get Kubernetes version: %v", err)
}
if !kubelib.IsLessThanVersion(c, minorVersion+1) {
s.Skip(fmt.Sprintf("Maximum Kubernetes version (1.%v) is less than current: %v",
minorVersion, ver.String()))
}
}
return nil
}
s.requireFns = append(s.requireFns, fn)
return s
}
func (s *suiteImpl) Setup(fn resource.SetupFn) Suite {
s.setupFns = append(s.setupFns, fn)
return s
}
func (s *suiteImpl) runSetupFn(fn resource.SetupFn, ctx SuiteContext) (err error) {
defer func() {
// Dump if the setup function fails
if err != nil && ctx.Settings().CIMode {
rt.Dump(ctx)
}
}()
err = fn(ctx)
return
}
func (s *suiteImpl) Run() {
s.osExit(s.run())
}
func (s *suiteImpl) isSkipped(ctx SuiteContext) bool {
if s.skipFn != nil && s.skipFn(ctx) {
return true
}
return false
}
func (s *suiteImpl) doSkip(ctx *suiteContext) int {
scopes.Framework.Infof("Skipping suite %q: %s", ctx.Settings().TestID, s.skipMessage)
// Mark this suite as skipped in the context.
ctx.skipped = true
// Run the tests so that the golang test framework exits normally. The tests will not run because
// they see that this suite has been skipped.
_ = s.mRun(ctx)
// Return success.
return 0
}
func (s *suiteImpl) run() (errLevel int) {
if err := initRuntime(s); err != nil {
scopes.Framework.Errorf("Error during test framework init: %v", err)
return exitCodeInitError
}
ctx := rt.suiteContext()
// Skip the test if its explicitly skipped
if s.isSkipped(ctx) {
return s.doSkip(ctx)
}
// Before starting, check whether the current set of labels & label selectors will ever allow us to run tests.
// if not, simply exit now.
if ctx.Settings().Selector.Excludes(s.labels) {
s.Skip(fmt.Sprintf("Label mismatch: labels=%v, selector=%v",
s.labels,
ctx.Settings().Selector))
return s.doSkip(ctx)
}
start := time.Now()
defer func() {
if errLevel != 0 && ctx.Settings().CIMode {
rt.Dump(ctx)
}
if err := rt.Close(); err != nil {
scopes.Framework.Errorf("Error during close: %v", err)
if rt.context.settings.FailOnDeprecation {
if ferrors.IsOrContainsDeprecatedError(err) {
errLevel = 1
}
}
}
rt = nil
}()
if err := s.runSetupFns(ctx); err != nil {
scopes.Framework.Errorf("Exiting due to setup failure: %v", err)
return exitCodeSetupError
}
// Check if one of the setup functions ended up skipping the suite.
if s.isSkipped(ctx) {
return s.doSkip(ctx)
}
defer func() {
end := time.Now()
scopes.Framework.Infof("=== Suite %q run time: %v ===", ctx.Settings().TestID, end.Sub(start))
ctx.RecordTraceEvent("suite-runtime", end.Sub(start).Seconds())
ctx.RecordTraceEvent("echo-calls", echo.GlobalEchoRequests.Load())
ctx.RecordTraceEvent("yaml-apply", GlobalYAMLWrites.Load())
_ = appendToFile(ctx.marshalTraceEvent(), filepath.Join(ctx.Settings().BaseDir, "trace.yaml"))
}()
attempt := 0
for attempt <= ctx.settings.Retries {
attempt++
scopes.Framework.Infof("=== BEGIN: Test Run: '%s' ===", ctx.Settings().TestID)
errLevel = s.mRun(ctx)
if errLevel == 0 {
scopes.Framework.Infof("=== DONE: Test Run: '%s' ===", ctx.Settings().TestID)
break
}
scopes.Framework.Infof("=== FAILED: Test Run: '%s' (exitCode: %v) ===",
ctx.Settings().TestID, errLevel)
if attempt <= ctx.settings.Retries {
scopes.Framework.Warnf("=== RETRY: Test Run: '%s' ===", ctx.Settings().TestID)
}
}
s.writeOutput()
return
}
type SuiteOutcome struct {
Name string
Environment string
Multicluster bool
TestOutcomes []TestOutcome
}
func environmentName(ctx resource.Context) string {
if ctx.Environment() != nil {
return ctx.Environment().EnvironmentName()
}
return ""
}
func isMulticluster(ctx resource.Context) bool {
if ctx.Environment() != nil {
return ctx.Clusters().IsMulticluster()
}
return false
}
func clusters(ctx resource.Context) []cluster.Cluster {
if ctx.Environment() != nil {
return ctx.Environment().Clusters()
}
return nil
}
func (s *suiteImpl) writeOutput() {
// the ARTIFACTS env var is set by prow, and uploaded to GCS as part of the job artifact
artifactsPath, err := file.NormalizePath(os.Getenv("ARTIFACTS"))
if err != nil {
artifactsPath = os.Getenv("ARTIFACTS")
log.Warnf("failed normalizing %s: %v", artifactsPath, err)
}
if artifactsPath != "" {
ctx := rt.suiteContext()
ctx.outcomeMu.RLock()
out := SuiteOutcome{
Name: ctx.Settings().TestID,
Environment: environmentName(ctx),
Multicluster: isMulticluster(ctx),
TestOutcomes: ctx.testOutcomes,
}
ctx.outcomeMu.RUnlock()
outbytes, err := yaml.Marshal(out)
if err != nil {
log.Errorf("failed writing test suite outcome to yaml: %s", err)
}
err = os.WriteFile(path.Join(artifactsPath, out.Name+".yaml"), outbytes, 0o644)
if err != nil {
log.Errorf("failed writing test suite outcome to file: %s", err)
}
}
}
func (s *suiteImpl) runSetupFns(ctx SuiteContext) (err error) {
scopes.Framework.Infof("=== BEGIN: Setup: '%s' ===", ctx.Settings().TestID)
// Run all the require functions first, then the setup functions.
setupFns := append(append([]resource.SetupFn{}, s.requireFns...), s.setupFns...)
start := time.Now()
for _, fn := range setupFns {
err := s.runSetupFn(fn, ctx)
if err != nil {
scopes.Framework.Errorf("Test setup error: %v", err)
scopes.Framework.Infof("=== FAILED: Setup: '%s' (%v) ===", ctx.Settings().TestID, err)
return err
}
if s.isSkipped(ctx) {
return nil
}
}
elapsed := time.Since(start)
scopes.Framework.Infof("=== DONE: Setup: '%s' (%v) ===", ctx.Settings().TestID, elapsed)
return nil
}
func initRuntime(s *suiteImpl) error {
rtMu.Lock()
defer rtMu.Unlock()
if rt != nil {
return errors.New("framework is already initialized")
}
settings, err := s.getSettings(s.testID)
if err != nil {
return err
}
// Get the EnvironmentFactory.
environmentFactory := s.envFactory
if environmentFactory == nil {
environmentFactory = settings.EnvironmentFactory
}
if environmentFactory == nil {
environmentFactory = newEnvironment
}
if err := configureLogging(); err != nil {
return err
}
scopes.Framework.Infof("=== Test Framework Settings ===")
scopes.Framework.Info(settings.String())
scopes.Framework.Infof("===============================")
// Ensure that the work dir is set.
if err := os.MkdirAll(settings.RunDir(), os.ModePerm); err != nil {
return fmt.Errorf("error creating rundir %q: %v", settings.RunDir(), err)
}
scopes.Framework.Infof("Test run dir: %v", settings.RunDir())
rt, err = newRuntime(settings, environmentFactory, s.labels)
return err
}
func newEnvironment(ctx resource.Context) (resource.Environment, error) {
s, err := kube.NewSettingsFromCommandLine()
if err != nil {
return nil, err
}
return kube.New(ctx, s)
}
func getSettings(testID string) (*resource.Settings, error) {
// Parse flags and init logging.
if !config.Parsed() {
config.Parse()
}
return resource.SettingsFromCommandLine(testID)
}
func mustCompileAll(patterns ...string) []*regexp.Regexp {
out := make([]*regexp.Regexp, 0, len(patterns))
for _, pattern := range patterns {
out = append(out, regexp.MustCompile(pattern))
}
return out
}
func appendToFile(contents []byte, file string) error {
f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
if err != nil {
return err
}
defer func() {
_ = f.Close()
}()
if _, err = f.Write(contents); err != nil {
return err
}
return nil
}