dev-tools/mage/integtest.go (228 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.
package mage
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"github.com/magefile/mage/mg"
)
const (
// BEATS_INSIDE_INTEGRATION_TEST_ENV is used to indicate that we are inside
// of the integration test environment.
insideIntegrationTestEnvVar = "BEATS_INSIDE_INTEGRATION_TEST_ENV"
)
var (
globalIntegrationTesters map[string]IntegrationTester
globalIntegrationTestSetupSteps IntegrationTestSteps
defaultPassthroughEnvVars = []string{
"TEST_COVERAGE",
"RACE_DETECTOR",
"TEST_TAGS",
"MODULE",
"KUBECONFIG",
"KUBE_CONFIG",
}
)
// RegisterIntegrationTester registers a integration tester.
func RegisterIntegrationTester(tester IntegrationTester) {
if globalIntegrationTesters == nil {
globalIntegrationTesters = make(map[string]IntegrationTester)
}
globalIntegrationTesters[tester.Name()] = tester
}
// RegisterIntegrationTestSetupStep registers a integration step.
func RegisterIntegrationTestSetupStep(step IntegrationTestSetupStep) {
globalIntegrationTestSetupSteps = append(globalIntegrationTestSetupSteps, step)
}
// IntegrationTestSetupStep is interface used by a step in the integration setup
// chain. Example could be: Terraform -> Kind -> Kubernetes (IntegrationTester).
type IntegrationTestSetupStep interface {
// Name is the name of the step.
Name() string
// Use returns true in the case that the step should be used. Not called
// when a step is defined as a dependency of a tester.
Use(dir string) (bool, error)
// Setup sets up the environment for the integration test.
Setup(env map[string]string) error
// Teardown brings down the environment for the integration test.
Teardown(env map[string]string) error
}
// IntegrationTestSteps wraps all the steps and completes the in the order added.
type IntegrationTestSteps []IntegrationTestSetupStep
// Name is the name of the step.
func (steps IntegrationTestSteps) Name() string {
return "IntegrationTestSteps"
}
// Setup calls Setup on each step in the order defined.
//
// In the case that Setup fails on a step, Teardown will be called on the previous
// successful steps.
func (steps IntegrationTestSteps) Setup(env map[string]string) error {
for i, step := range steps {
if mg.Verbose() {
fmt.Printf("Setup %s...\n", step.Name())
}
if err := step.Setup(env); err != nil {
prev := i - 1
if prev >= 0 {
// errors ignored
_ = steps.teardownFrom(prev, env)
}
return fmt.Errorf("%s setup failed: %w", step.Name(), err)
}
}
return nil
}
// Teardown calls Teardown in the reverse order defined.
//
// In the case a teardown step fails the error is recorded but the
// previous steps teardown is still called. This guarantees that teardown
// will always be called for each step.
func (steps IntegrationTestSteps) Teardown(env map[string]string) error {
return steps.teardownFrom(len(steps)-1, env)
}
func (steps IntegrationTestSteps) teardownFrom(start int, env map[string]string) error {
var errs []error
for i := start; i >= 0; i-- {
if mg.Verbose() {
fmt.Printf("Teardown %s...\n", steps[i].Name())
}
if err := steps[i].Teardown(env); err != nil {
errs = append(errs, fmt.Errorf("%s teardown failed: %w", steps[i].Name(), err))
}
}
return errors.Join(errs...)
}
// IntegrationTester is interface used by the actual test runner.
type IntegrationTester interface {
// Name returns the name of the tester.
Name() string
// Use returns true in the case that the tester should be used.
Use(dir string) (bool, error)
// HasRequirements returns an error if requirements are missing.
HasRequirements() error
// Test performs executing the test inside the environment.
Test(dir string, mageTarget string, env map[string]string) error
// InsideTest performs the actual test on the inside of environment.
InsideTest(test func() error) error
// StepRequirements returns the steps this tester requires. These
// are always placed before other autodiscover steps.
StepRequirements() IntegrationTestSteps
}
// IntegrationRunner performs the running of the integration tests.
type IntegrationRunner struct {
steps IntegrationTestSteps
tester IntegrationTester
dir string
env map[string]string
}
// IntegrationRunners is an array of multiple runners.
type IntegrationRunners []*IntegrationRunner
// NewIntegrationRunners returns the integration test runners discovered from the provided path.
func NewIntegrationRunners(path string, passInEnv map[string]string) (IntegrationRunners, error) {
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
dir := filepath.Join(cwd, path)
// Create the runners (can only be multiple).
var runners IntegrationRunners
for _, t := range globalIntegrationTesters {
use, err := t.Use(dir)
if err != nil {
return nil, fmt.Errorf("%s tester failed on Use: %w", t.Name(), err)
}
if !use {
continue
}
runner, err := initRunner(t, dir, passInEnv)
if err != nil {
return nil, fmt.Errorf("initializing %s runner: %w", t.Name(), err)
}
runners = append(runners, runner)
}
// Keep support for modules that don't have a local environment defined at the module
// level (system, stack and cloud modules by now)
if len(runners) == 0 {
if mg.Verbose() {
fmt.Printf(">> No runner found in %s, using docker\n", path)
}
tester, ok := globalIntegrationTesters["docker"]
if !ok {
return nil, fmt.Errorf("docker integration test runner not registered")
}
runner, err := initRunner(tester, dir, passInEnv)
if err != nil {
return nil, fmt.Errorf("initializing docker runner: %w", err)
}
runners = append(runners, runner)
}
return runners, nil
}
// NewDockerIntegrationRunner returns an integration runner configured only for docker.
func NewDockerIntegrationRunner(passThroughEnvVars ...string) (*IntegrationRunner, error) {
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
tester, ok := globalIntegrationTesters["docker"]
if !ok {
return nil, fmt.Errorf("docker integration test runner not registered")
}
return initRunner(tester, cwd, nil, passThroughEnvVars...)
}
func initRunner(tester IntegrationTester, dir string, passInEnv map[string]string, passThroughEnvVars ...string) (*IntegrationRunner, error) {
var runnerSteps IntegrationTestSteps
requirements := tester.StepRequirements()
if requirements != nil {
runnerSteps = append(runnerSteps, requirements...)
}
// Create the custom env for the runner.
env := map[string]string{
insideIntegrationTestEnvVar: "true",
"GOFLAGS": "-mod=readonly",
}
for name, value := range passInEnv {
env[name] = value
}
passThroughEnvs(env, passThroughEnvVars...)
passThroughEnvs(env, defaultPassthroughEnvVars...)
if mg.Verbose() {
env["MAGEFILE_VERBOSE"] = "1"
}
runner := &IntegrationRunner{
steps: runnerSteps,
tester: tester,
dir: dir,
env: env,
}
return runner, nil
}
// Test actually performs the test.
func (r *IntegrationRunner) Test(mageTarget string, test func() error) error {
// Inside the testing environment just run the test.
if IsInIntegTestEnv() {
return r.tester.InsideTest(test)
}
// Honor the TEST_ENVIRONMENT value if set.
if testEnvVar, isSet := os.LookupEnv("TEST_ENVIRONMENT"); isSet {
enabled, err := strconv.ParseBool(testEnvVar)
if err != nil {
return fmt.Errorf("failed to parse TEST_ENVIRONMENT value: %w", err)
}
if !enabled {
return fmt.Errorf("TEST_ENVIRONMENT=%s", testEnvVar)
}
}
// log missing requirements and do nothing
err := r.tester.HasRequirements()
if err != nil {
fmt.Printf("skipping test run with %s due to missing requirements: %s\n", r.tester.Name(), err)
//nolint:nilerr // log error; and return (otherwise on machines without requirements it will mark the tests as failed)
return nil
}
if err := r.steps.Setup(r.env); err != nil {
return err
}
// catch any panics to run teardown
inTeardown := false
defer func() {
if recoverErr := recover(); recoverErr != nil {
err = recoverErr.(error)
if !inTeardown {
// ignore errors
_ = r.steps.Teardown(r.env)
}
}
}()
if mg.Verbose() {
fmt.Printf(">> Running testing inside of %s...\n", r.tester.Name())
}
err = r.tester.Test(r.dir, mageTarget, r.env)
if mg.Verbose() {
fmt.Printf(">> Done running testing inside of %s...\n", r.tester.Name())
}
inTeardown = true
if teardownErr := r.steps.Teardown(r.env); teardownErr != nil {
if err == nil {
// test didn't error, but teardown did
err = teardownErr
}
}
return err
}
// Test runs the test on each runner and collects the errors.
func (r IntegrationRunners) Test(mageTarget string, test func() error) error {
var errs []error
for _, runner := range r {
if err := runner.Test(mageTarget, test); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func passThroughEnvs(env map[string]string, passthrough ...string) {
for _, envName := range passthrough {
val, set := os.LookupEnv(envName)
if set {
env[envName] = val
}
}
}
// IsInIntegTestEnv return true if executing inside the integration test environment.
func IsInIntegTestEnv() bool {
_, found := os.LookupEnv(insideIntegrationTestEnvVar)
return found
}