dev-tools/systemtests/container.go (195 lines of code) (raw):

// Licensed to Elasticsearch B.V. under one or more contributor // license agreements. See the NOTICE file distributed with // this work for additional information regarding copyright // ownership. Elasticsearch B.V. licenses this file to you 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 systemtests import ( "bytes" "context" "fmt" "io" "os" "os/exec" "runtime" "strings" "testing" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/elastic/elastic-agent-libs/logp" ) // DockerTestRunner is a simple test framework for running a given go test inside a container. // In order for this framework to work fully, tests running under this framework must use // systemtests.DockerTestResolver() to fetch the hostfs, and also set debug-level logging via `logp`. type DockerTestRunner struct { Runner *testing.T // Privileged is equivalent to the `--privileged` flag passed to `docker run`. Sets elevated permissions. Privileged bool // Sets the filepath passed to `go test` Basepath string // Testname will run a given test if set Testname string // Container name passed to `docker run`. Container string // RunAsUser will run the container as a non-root user if set to the given username // equivalent to `--user=USER` RunAsUser string // CgroupNSMode sets the cgroup namespace for the container. Newer versions of docker // will default to a private namespace. Unexpected namespace values have resulted in bugs. CgroupNSMode container.CgroupnsMode // Verbose enables debug-level logging Verbose bool // FatalLogMessages will fail the test if a given string appears in the log output for the test. // Useful for turning non-fatal errors into fatal errors. // These are just passed to strings.Contains(). I.e. []string{"Non-fatal error"} FatalLogMessages []string // MonitorPID will tell tests to specifically check the correctness of process-level monitoring for this PID MonitorPID int // CreateHostProcess: this will start a process with the following args outside of the container, // and use the integration tests to monitor it. // Useful as "monitor random running processes" as a test heuristic tends to be flaky. // This will overrite MonitorPID, so only set either this or MonitorPID. CreateHostProcess *exec.Cmd } // RunResult returns the logs and return code from the container type RunResult struct { ReturnCode int64 Stderr string Stdout string } type testCase struct { nsmode container.CgroupnsMode priv bool user string } func (tc testCase) String() string { return fmt.Sprintf("nsmode:%s_priv:%v_user:%s", tc.nsmode, tc.priv, tc.user) } // CreateAndRunPermissionMatrix is a helper that runs RunTestsOnDocker() across a range of possible docker settings. // If a given array value is supplied in the method arguments, // it will be used to override the value in DockerTestRunner, and run the test for as many times as there are supplied values. // For example, if privilegedValues=[true, false], then RunTestsOnDocker() will be run twice, // setting the privileged flag differently for each run. // if an array argument is empty, the default value set in the DockerTestRunner instance will be used. func (tr *DockerTestRunner) CreateAndRunPermissionMatrix(ctx context.Context, cgroupNSValues []container.CgroupnsMode, privilegedValues []bool, runAsUserValues []string) { cases := []testCase{} if len(cgroupNSValues) == 0 { cgroupNSValues = []container.CgroupnsMode{tr.CgroupNSMode} } if len(privilegedValues) == 0 { privilegedValues = []bool{tr.Privileged} } if len(runAsUserValues) == 0 { runAsUserValues = []string{tr.RunAsUser} } // Create a test matrix of every possible case. // This might seem like overkill, but cgroup settings and docker permissions values produce some exciting edge cases. Just run all of them. for _, ns := range cgroupNSValues { for _, user := range runAsUserValues { for _, privSetting := range privilegedValues { cases = append(cases, testCase{nsmode: ns, priv: privSetting, user: user}) } } } tr.Runner.Logf("Running %d tests", len(cases)) baseRunner := tr.Runner // some odd recursion happens here if we just refer to tr.Runner for _, tc := range cases { baseRunner.Run(tc.String(), func(t *testing.T) { runner := tr runner.Runner = t runner.CgroupNSMode = tc.nsmode runner.Privileged = tc.priv runner.RunAsUser = tc.user runner.RunTestsOnDocker(ctx) }) } } // RunTestsOnDocker runs a provided test, or all the package tests // (as in `go test ./...`) inside a docker container with the host's root FS mounted as /hostfs. // This framework relies on the tests using DockerTestResolver(). // If docker returns !0 or if there's a matching string entry from FatalLogMessages in stdout/stderr, // this will fail the test func (tr *DockerTestRunner) RunTestsOnDocker(ctx context.Context) { // do we want to run on windows? Much of what we're testing, such as host // cgroup monitoring, is invalid. if runtime.GOOS != "linux" { tr.Runner.Skip("Tests only supported on Linux.") } log := logp.L() if tr.Basepath == "" { tr.Basepath = "./..." } if tr.Container == "" { tr.Container = "golang:latest" } // setup and run apiClient, err := client.NewClientWithOpts(client.WithAPIVersionNegotiation()) require.NoError(tr.Runner, err) defer apiClient.Close() _, err = apiClient.ContainerList(ctx, container.ListOptions{}) if err != nil { tr.Runner.Skipf("got error in container list, docker isn't installed or not running: %s", err) } // create monitored process, if we need to tr.createMonitoredProcess(ctx) resp := tr.createTestContainer(ctx, apiClient) log.Infof("running test...") result := tr.runContainerTest(ctx, apiClient, resp) // check for failures require.Equal(tr.Runner, int64(0), result.ReturnCode, "got bad docker return code. stdout: %s \nstderr: %s", result.Stdout, result.Stderr) if tr.Verbose { fmt.Fprintf(os.Stdout, "stderr: %s\n", result.Stderr) fmt.Fprintf(os.Stdout, "stdout: %s\n", result.Stdout) } // iterate by lines to make this easier to read if len(tr.FatalLogMessages) > 0 { for _, badLine := range tr.FatalLogMessages { for _, line := range strings.Split(result.Stdout, "\n") { require.NotContains(tr.Runner, line, badLine) } for _, line := range strings.Split(result.Stderr, "\n") { // filter our the go mod package download messages if !strings.Contains(line, "go: downloading") { require.NotContains(tr.Runner, line, badLine) } } } } } // createTestContainer creates a container with the given test path and test name func (tr *DockerTestRunner) createTestContainer(ctx context.Context, apiClient *client.Client) container.CreateResponse { reader, err := apiClient.ImagePull(ctx, tr.Container, image.PullOptions{}) require.NoError(tr.Runner, err, "error pulling image") defer reader.Close() _, err = io.Copy(os.Stdout, reader) require.NoError(tr.Runner, err, "error copying image") wdCmd := exec.Command("git", "rev-parse", "--show-toplevel") wdPath, err := wdCmd.CombinedOutput() require.NoError(tr.Runner, err, "error finding root path") cwd := strings.TrimSpace(string(wdPath)) logp.L().Infof("using cwd: %s", cwd) testRunCmd := []string{"go", "test", "-v", tr.Basepath} if tr.Testname != "" { testRunCmd = append(testRunCmd, "-run", tr.Testname) } mountPath := "/hostfs" containerEnv := []string{fmt.Sprintf("HOSTFS=%s", mountPath)} // used by a few vendored libaries containerEnv = append(containerEnv, "HOST_PROC=%s", mountPath) if tr.Privileged { containerEnv = append(containerEnv, "PRIVILEGED=1") } if tr.MonitorPID != 0 { containerEnv = append(containerEnv, fmt.Sprintf("MONITOR_PID=%d", tr.MonitorPID)) } resp, err := apiClient.ContainerCreate(ctx, &container.Config{ Image: tr.Container, Cmd: testRunCmd, Tty: false, WorkingDir: "/app", Env: containerEnv, User: tr.RunAsUser, }, &container.HostConfig{ CgroupnsMode: tr.CgroupNSMode, Privileged: tr.Privileged, Binds: []string{fmt.Sprintf("/:%s", mountPath), fmt.Sprintf("%s:/app", cwd)}, }, nil, nil, "") require.NoError(tr.Runner, err, "error creating container") return resp } func (tr *DockerTestRunner) runContainerTest(ctx context.Context, apiClient *client.Client, resp container.CreateResponse) RunResult { err := apiClient.ContainerStart(ctx, resp.ID, container.StartOptions{}) require.NoError(tr.Runner, err, "error starting container") res := RunResult{} statusCh, errCh := apiClient.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning) select { case err := <-errCh: require.NoError(tr.Runner, err, "error in container") case status := <-statusCh: res.ReturnCode = status.StatusCode } out, err := apiClient.ContainerLogs(ctx, resp.ID, container.LogsOptions{ShowStdout: true, ShowStderr: true}) require.NoError(tr.Runner, err, "error fetching logs") stdout := bytes.NewBufferString("") stderr := bytes.NewBufferString("") _, err = stdcopy.StdCopy(stdout, stderr, out) require.NoError(tr.Runner, err, "error copying logs") res.Stderr = stderr.String() res.Stdout = stdout.String() return res } func (tr *DockerTestRunner) createMonitoredProcess(ctx context.Context) { log := logp.L() // if user has specified a process to monitor, start it now // skip if the process has already been created if tr.CreateHostProcess != nil && tr.CreateHostProcess.Process == nil { // We don't need to do this in a channel, but it prevents races between this goroutine // and the rest of test framework startPid := make(chan int) log.Infof("Creating test Process...") go func() { err := tr.CreateHostProcess.Start() // if the process fails to start up, the resulting tests will fail anyway, so just log it assert.NoError(tr.Runner, err, "error starting monitor process") startPid <- tr.CreateHostProcess.Process.Pid }() select { case pid := <-startPid: tr.MonitorPID = pid case <-ctx.Done(): } log.Infof("Monitoring pid %d", tr.MonitorPID) } }