pkg/testing/define/define.go (230 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 define
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"testing"
"github.com/gofrs/uuid/v5"
"github.com/elastic/elastic-agent-libs/kibana"
"github.com/elastic/elastic-agent/pkg/utils"
"github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-sysinfo"
"github.com/elastic/go-sysinfo/types"
atesting "github.com/elastic/elastic-agent/pkg/testing"
semver "github.com/elastic/elastic-agent/pkg/version"
"github.com/elastic/elastic-agent/version"
"sigs.k8s.io/e2e-framework/klient"
)
var osInfo *types.OSInfo
var osInfoErr error
var osInfoOnce sync.Once
var noSpecialCharsRegexp = regexp.MustCompile("[^a-zA-Z0-9]+")
// Require defines what this test requires for it to be run by the test runner.
//
// This must be defined as the first line of a test, or `ValidateDir` will fail
// and the test runner will not be able to determine the requirements for a test.
func Require(t *testing.T, req Requirements) *Info {
return defineAction(t, req)
}
type Info struct {
// ESClient is the elasticsearch client to communicate with elasticsearch.
// This is only present if you say a cloud is required in the `define.Require`.
ESClient *elasticsearch.Client
// KibanaClient is the kibana client to communicate with kibana.
// This is only present if you say a cloud is required in the `define.Require`.
KibanaClient *kibana.Client
// Namespace should be used for isolating data and actions per test.
//
// This is unique to each test and instance combination so a test that need to
// read/write data to a data stream in elasticsearch do not collide.
Namespace string
}
func (i *Info) KubeClient() (klient.Client, error) {
c, err := klient.NewWithKubeConfigFile(os.Getenv("KUBECONFIG"))
if err != nil {
return nil, err
}
return c, nil
}
// Version returns the version of the Elastic Agent the tests should be using.
func Version() string {
ver := os.Getenv("AGENT_VERSION")
if ver == "" {
return version.GetDefaultVersion()
}
return ver
}
// NewFixtureFromLocalBuild returns a new Elastic Agent testing fixture with a LocalFetcher and
// the agent logging to the test logger.
func NewFixtureFromLocalBuild(t *testing.T, version string, opts ...atesting.FixtureOpt) (*atesting.Fixture, error) {
buildsDir := os.Getenv("AGENT_BUILD_DIR")
if buildsDir == "" {
projectDir, err := findProjectRoot()
if err != nil {
return nil, err
}
buildsDir = filepath.Join(projectDir, "build", "distributions")
}
return NewFixtureWithBinary(t, version, "elastic-agent", buildsDir, opts...)
}
// NewFixtureWithBinary returns a new Elastic Agent testing fixture with a LocalFetcher and
// the agent logging to the test logger.
func NewFixtureWithBinary(t *testing.T, version string, binary string, buildsDir string, opts ...atesting.FixtureOpt) (*atesting.Fixture, error) {
ver, err := semver.ParseVersion(version)
if err != nil {
return nil, fmt.Errorf("%q is an invalid agent version: %w", version, err)
}
var binFetcher atesting.Fetcher
if ver.IsSnapshot() {
binFetcher = atesting.LocalFetcher(buildsDir, atesting.WithLocalSnapshotOnly(), atesting.WithCustomBinaryName(binary))
} else {
binFetcher = atesting.LocalFetcher(buildsDir, atesting.WithCustomBinaryName(binary))
}
opts = append(opts, atesting.WithFetcher(binFetcher), atesting.WithLogOutput())
if binary != "elastic-agent" {
opts = append(opts, atesting.WithBinaryName(binary))
}
return atesting.NewFixture(t, version, opts...)
}
// findProjectRoot finds the root directory of the project, by finding the go.mod file.
func findProjectRoot() (string, error) {
_, caller, _, ok := runtime.Caller(1)
if !ok {
return "", errors.New("unable to determine callers file path")
}
dir := caller
for {
dir = filepath.Dir(dir)
fi, err := os.Stat(filepath.Join(dir, "go.mod"))
if (err == nil || os.IsExist(err)) && !fi.IsDir() {
return dir, nil
}
if strings.HasSuffix(dir, string(filepath.Separator)) {
// made it to root directory
return "", fmt.Errorf("unable to find golang root directory from caller path %s", caller)
}
}
}
func runOrSkip(t *testing.T, req Requirements, local bool, kubernetes bool) *Info {
// always validate requirement is valid
if err := req.Validate(); err != nil {
panic(fmt.Sprintf("test %s has invalid requirements: %s", t.Name(), err))
}
filteredGroups := GroupsFilter.values
if len(filteredGroups) > 0 && !slices.Contains(filteredGroups, req.Group) {
t.Skipf("group %s not found in groups filter %s. Skipping", req.Group, filteredGroups)
return nil
}
if SudoFilter.value != nil && req.Sudo != *SudoFilter.value {
t.Skipf("sudo requirement %t not matching sudo filter %t. Skipping", req.Sudo, *SudoFilter.value)
}
if FipsFilter.value != nil && req.FIPS != *FipsFilter.value {
t.Skipf("FIPS requirement %t not matching FIPS filter %t. Skipping.", req.FIPS, *FipsFilter.value)
}
// record autodiscover after filtering by group and sudo and before validating against the actual environment
if AutoDiscover {
discoverTest(t, req)
}
if !req.Local && local {
t.Skip("running local only tests and this test doesn't support local")
return nil
}
for _, o := range req.OS {
if o.Type == Kubernetes && !kubernetes {
t.Skip("test requires kubernetes")
return nil
}
}
if req.Sudo {
// we can run sudo tests if we are being executed as root
root, err := utils.HasRoot()
if err != nil {
panic(fmt.Sprintf("test %s failed to determine if running as root: %s", t.Name(), err))
}
if !root {
t.Skip("not running as root and test requires root")
return nil
}
}
// need OS info to determine if the test can run
osInfo, err := getOSInfo()
if err != nil {
panic("failed to get OS information")
}
dockerVariant := os.Getenv("DOCKER_VARIANT")
if !req.runtimeAllowed(runtime.GOOS, runtime.GOARCH, osInfo.Version, osInfo.Platform, dockerVariant) {
t.Skipf("platform: %s, architecture: %s, version: %s, and distro: %s combination is not supported by test. required: %v", runtime.GOOS, runtime.GOARCH, osInfo.Version, osInfo.Platform, req.OS)
return nil
}
if DryRun {
return dryRun(t, req)
}
namespace, err := getNamespace(t, local)
if err != nil {
panic(err)
}
info := &Info{
Namespace: namespace,
}
if req.Stack != nil {
info.ESClient, err = getESClient()
if err != nil {
if local {
t.Skipf("test requires a stack but failed to create a valid client to elasticsearch: %s", err)
return nil
}
// non-local test and stack was required
panic(err)
}
info.KibanaClient, err = getKibanaClient()
if err != nil {
if local {
t.Skipf("test requires a stack but failed to create a valid client to kibana: %s", err)
return nil
}
// non-local test and stack was required
panic(err)
}
}
return info
}
func getOSInfo() (*types.OSInfo, error) {
osInfoOnce.Do(func() {
sysInfo, err := sysinfo.Host()
if err != nil {
osInfoErr = err
} else {
osInfo = sysInfo.Info().OS
}
})
return osInfo, osInfoErr
}
// getNamespace is a general namespace that the test can use that will ensure that it
// is unique and won't collide with other tests (even the same test from a different batch).
//
// This function uses a sha256 of an UUIDv4 to ensure that the
// length of the namespace is not over the 100 byte limit from Fleet
// see: https://www.elastic.co/guide/en/fleet/current/data-streams.html#data-streams-naming-scheme
func getNamespace(t *testing.T, local bool) (string, error) {
nsUUID, err := uuid.NewV4()
if err != nil {
return "", fmt.Errorf("cannot generate UUID V4: %w", err)
}
hasher := sha256.New()
hasher.Write([]byte(nsUUID.String()))
// Fleet API requires the namespace to be lowercased and not contain
// special characters.
namespace := strings.ToLower(base64.URLEncoding.EncodeToString(hasher.Sum(nil)))
namespace = noSpecialCharsRegexp.ReplaceAllString(namespace, "")
return namespace, nil
}
// getESClient creates the elasticsearch client from the information passed from the test runner.
func getESClient() (*elasticsearch.Client, error) {
esHost := os.Getenv("ELASTICSEARCH_HOST")
esUser := os.Getenv("ELASTICSEARCH_USERNAME")
esPass := os.Getenv("ELASTICSEARCH_PASSWORD")
if esHost == "" || esUser == "" || esPass == "" {
return nil, errors.New("ELASTICSEARCH_* must be defined by the test runner")
}
c, err := elasticsearch.NewClient(elasticsearch.Config{
Addresses: []string{esHost},
Username: esUser,
Password: esPass,
})
if err != nil {
return nil, fmt.Errorf("failed to create elasticsearch client: %w", err)
}
return c, nil
}
// getKibanaClient creates the kibana client from the information passed from the test runner.
func getKibanaClient() (*kibana.Client, error) {
kibanaHost := os.Getenv("KIBANA_HOST")
kibanaUser := os.Getenv("KIBANA_USERNAME")
kibanaPass := os.Getenv("KIBANA_PASSWORD")
if kibanaHost == "" || kibanaUser == "" || kibanaPass == "" {
return nil, errors.New("KIBANA_* must be defined by the test runner")
}
c, err := kibana.NewClientWithConfigDefault(&kibana.ClientConfig{
Host: kibanaHost,
Username: kibanaUser,
Password: kibanaPass,
IgnoreVersion: false,
}, 0, "Elastic-Agent-Test-Define", version.GetDefaultVersion(), version.Commit(), version.BuildTime().String())
if err != nil {
return nil, fmt.Errorf("failed to create kibana client: %w", err)
}
return c, nil
}