github.com/ouraigua/jenkins-library@v0.0.0-20231028010029-fbeaf2f3aa9b/integration/docker_test_executor.go (about)

     1  //go:build integration
     2  // +build integration
     3  
     4  package main
     5  
     6  import (
     7  	"archive/tar"
     8  	"bufio"
     9  	"bytes"
    10  	"fmt"
    11  	"io"
    12  	"math/rand"
    13  	"os"
    14  	"path"
    15  	"strconv"
    16  	"strings"
    17  	"testing"
    18  	"time"
    19  
    20  	"github.com/magiconair/properties/assert"
    21  	"github.com/pkg/errors"
    22  
    23  	"github.com/SAP/jenkins-library/pkg/command"
    24  	"github.com/SAP/jenkins-library/pkg/log"
    25  )
    26  
    27  // The functions in this file provide a convenient way to integration test the piper binary in docker containers.
    28  // It follows the "given, when, then" approach for structuring tests.
    29  // The general concept is that per test one container is started, one piper command is run and outcomes are asserted.
    30  // Please note that so far this was only tested with debian/ubuntu based containers.
    31  //
    32  // Non-exhaustive list of assumptions those functions make:
    33  // - the following commands are available in the container: sh, chown, sleep
    34  // - If the option TestDir is not provided, the test project must be in the container image  in the directory /project
    35  
    36  // IntegrationTestDockerExecRunnerBundle is used to construct an instance of IntegrationTestDockerExecRunner
    37  // This is what a test uses to specify the container it requires
    38  type IntegrationTestDockerExecRunnerBundle struct {
    39  	Image       string
    40  	User        string
    41  	TestDir     []string
    42  	Mounts      map[string]string
    43  	Environment map[string]string
    44  	Setup       []string
    45  	Network     string
    46  	ExecNoLogin bool
    47  }
    48  
    49  // IntegrationTestDockerExecRunner keeps the state of an instance of a docker runner
    50  type IntegrationTestDockerExecRunner struct {
    51  	// Runner is the ExecRunner to which all executions are forwarded in the end.
    52  	Runner        command.Command
    53  	Image         string
    54  	User          string
    55  	TestDir       []string
    56  	Mounts        map[string]string
    57  	Environment   map[string]string
    58  	Setup         []string
    59  	Network       string
    60  	ContainerName string
    61  	ExecNoLogin   bool
    62  }
    63  
    64  func givenThisContainer(t *testing.T, bundle IntegrationTestDockerExecRunnerBundle) IntegrationTestDockerExecRunner {
    65  
    66  	runner := command.Command{}
    67  	containerName := generateContainerName()
    68  
    69  	testRunner := IntegrationTestDockerExecRunner{
    70  		Runner:        runner,
    71  		Image:         bundle.Image,
    72  		User:          bundle.User,
    73  		Mounts:        bundle.Mounts,
    74  		Environment:   bundle.Environment,
    75  		Setup:         bundle.Setup,
    76  		Network:       bundle.Network,
    77  		ExecNoLogin:   bundle.ExecNoLogin,
    78  		ContainerName: containerName,
    79  	}
    80  
    81  	wd, _ := os.Getwd()
    82  	localPiper := path.Join(wd, "..", "piper")
    83  	if localPiper == "" {
    84  		t.Fatal("Could not locate piper binary to test")
    85  	}
    86  
    87  	params := []string{"run", "--detach", "-v", localPiper + ":/piper", "--name=" + testRunner.ContainerName}
    88  	if testRunner.User != "" {
    89  		params = append(params, fmt.Sprintf("--user=%s", testRunner.User))
    90  	}
    91  	if len(bundle.TestDir) > 0 {
    92  		projectDir := path.Join(wd, path.Join(bundle.TestDir...))
    93  		// 1. Copy test files to a temp dir in order to avoid non-repeatable test executions because of changed state
    94  		// 2. Don't remove the temp dir to allow investigation of failed tests. Maybe add an option for cleaning it later?
    95  		tempDir, err := os.MkdirTemp("", "piper-integration-test")
    96  		if err != nil {
    97  			t.Fatal(err)
    98  		}
    99  
   100  		err = copyDir(projectDir, tempDir)
   101  		if err != nil {
   102  			t.Fatalf("Failed to copy files from %s into %s", projectDir, tempDir)
   103  		}
   104  		params = append(params, "-v", fmt.Sprintf("%s:/project", tempDir))
   105  	}
   106  
   107  	if len(testRunner.Environment) > 0 {
   108  		for envVarName, envVarValue := range testRunner.Environment {
   109  			params = append(params, "--env", fmt.Sprintf("%s=%s", envVarName, envVarValue))
   110  		}
   111  	}
   112  
   113  	if testRunner.Mounts != nil {
   114  		wd, _ := os.Getwd()
   115  		for src, dst := range testRunner.Mounts {
   116  			localSrc := path.Join(wd, src)
   117  			params = append(params, "-v", fmt.Sprintf("%s:%s", localSrc, dst))
   118  		}
   119  	}
   120  
   121  	if testRunner.Network != "" {
   122  		params = append(params, "--network", testRunner.Network)
   123  	}
   124  	params = append(params, testRunner.Image, "sleep", "2000")
   125  
   126  	err := testRunner.Runner.RunExecutable("docker", params...)
   127  	if err != nil {
   128  		t.Fatalf("Starting test container has failed %s", err)
   129  	}
   130  
   131  	if len(bundle.TestDir) > 0 && testRunner.User != "" {
   132  		err = testRunner.Runner.RunExecutable("docker", "exec", "-u=root", testRunner.ContainerName, "chown", "-R", testRunner.User, "/project")
   133  		if err != nil {
   134  			t.Fatalf("Chown /project has failed %s", err)
   135  		}
   136  	}
   137  
   138  	if err = testRunner.Runner.RunExecutable(
   139  		"docker", "exec", testRunner.ContainerName, "sh", "-c",
   140  		strings.Join(testRunner.Setup, "\n"),
   141  	); err != nil {
   142  		t.Fatalf("Running setup script in test container has failed %s", err)
   143  	}
   144  
   145  	setupPiperBinary(t, testRunner, localPiper)
   146  
   147  	return testRunner
   148  }
   149  
   150  // generateContainerName creates a name with a common prefix and a random number, so we can start a new container for each test method
   151  // We don't rely on docker's random name generator for two reasons
   152  // First, it is easier to save the name here compared to getting it from stdout
   153  // Second, the common prefix allows batch stopping/deleting of containers if so desired
   154  // The test code will not automatically delete containers as they might be useful for debugging
   155  func generateContainerName() string {
   156  	var seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
   157  	return "piper-integration-test-" + strconv.Itoa(seededRand.Int())
   158  }
   159  
   160  // setupPiperBinary copies a wrapper script for calling the piper binary into the container and verifies that the piper binary is executable inside the container
   161  // The wrapper script (piper-command-wrapper.sh) only calls the piper binary and redirects its output into a file
   162  // The purpose of this is to capture piper's stdout/stderr in order to assert on the output
   163  // This is not possible via "docker logs", cf https://github.com/moby/moby/issues/8662
   164  func setupPiperBinary(t *testing.T, testRunner IntegrationTestDockerExecRunner, localPiper string) {
   165  	err := testRunner.Runner.RunExecutable("docker", "cp", "piper-command-wrapper.sh", testRunner.ContainerName+":/piper-wrapper")
   166  	if err != nil {
   167  		t.Fatalf("Copying command wrapper to container has failed %s", err)
   168  	}
   169  	err = testRunner.Runner.RunExecutable("docker", "exec", "-u=root", testRunner.ContainerName, "chmod", "+x", "/piper-wrapper")
   170  	if err != nil {
   171  		t.Fatalf("Making command wrapper in container executable has failed %s", err)
   172  	}
   173  	err = testRunner.Runner.RunExecutable("docker", "exec", testRunner.ContainerName, "/bin/sh", "/piper-wrapper", "/piper", "version")
   174  	if err != nil {
   175  		t.Fatalf("Running piper failed. "+
   176  			"Please check that '%s' is the correct binary, and is compiled for this configuration: 'GOOS=linux GOARCH=amd64'. Error text: %s", localPiper, err)
   177  	}
   178  }
   179  
   180  func (d *IntegrationTestDockerExecRunner) whenRunningPiperCommand(command string, parameters ...string) error {
   181  	args := []string{"exec", "--workdir", "/project", d.ContainerName, "/bin/sh"}
   182  
   183  	if !d.ExecNoLogin {
   184  		args = append(args, "-l")
   185  	}
   186  
   187  	args = append(args, "/piper-wrapper", "/piper", command)
   188  	err := d.Runner.RunExecutable("docker", append(args, parameters...)...)
   189  	if err != nil {
   190  		stdOut, outputErr := d.getPiperOutput()
   191  		if outputErr != nil {
   192  			return errors.Wrap(outputErr, "unable to get output after Piper command failure")
   193  		}
   194  		return errors.Wrapf(err, "piper output: \n%s", stdOut.String())
   195  	}
   196  	return err
   197  }
   198  
   199  func (d *IntegrationTestDockerExecRunner) runScriptInsideContainer(script string) error {
   200  	args := []string{"exec", "--workdir", "/project", d.ContainerName, "/bin/sh"}
   201  
   202  	if !d.ExecNoLogin {
   203  		args = append(args, "-l")
   204  	}
   205  
   206  	args = append(args, "-c", script)
   207  	return d.Runner.RunExecutable("docker", args...)
   208  }
   209  
   210  func (d *IntegrationTestDockerExecRunner) assertHasNoOutput(t *testing.T, inconsistencies ...string) {
   211  	count := len(inconsistencies)
   212  	buffer, err := d.getPiperOutput()
   213  	if err != nil {
   214  		t.Fatalf("Failed to get log output of container %s", d.ContainerName)
   215  	}
   216  	scanner := bufio.NewScanner(buffer)
   217  	for scanner.Scan() && (len(inconsistencies) != 0) {
   218  		for i, str := range inconsistencies {
   219  			if strings.Contains(scanner.Text(), str) {
   220  				inconsistencies = append(inconsistencies[:i], inconsistencies[i+1:]...)
   221  				break
   222  			}
   223  		}
   224  	}
   225  	assert.Equal(t, len(inconsistencies), count, fmt.Sprintf(
   226  		"[assertHasNoOutput] Unexpected command output:\n%s\n%s\n", buffer.String(), strings.Join(inconsistencies, "\n")),
   227  	)
   228  }
   229  
   230  func (d *IntegrationTestDockerExecRunner) assertHasOutput(t *testing.T, consistencies ...string) {
   231  	buffer, err := d.getPiperOutput()
   232  	if err != nil {
   233  		t.Fatalf("Failed to get log output of container %s", d.ContainerName)
   234  	}
   235  	scanner := bufio.NewScanner(buffer)
   236  	for scanner.Scan() && (len(consistencies) != 0) {
   237  		for i, str := range consistencies {
   238  			if strings.Contains(scanner.Text(), str) {
   239  				consistencies = append(consistencies[:i], consistencies[i+1:]...)
   240  				break
   241  			}
   242  		}
   243  	}
   244  	assert.Equal(t, len(consistencies), 0, fmt.Sprintf(
   245  		"[assertHasOutput] Unexpected command output:\n%s\n%s\n", buffer.String(), strings.Join(consistencies, "\n")),
   246  	)
   247  }
   248  
   249  func (d *IntegrationTestDockerExecRunner) getPiperOutput() (*bytes.Buffer, error) {
   250  	buffer := new(bytes.Buffer)
   251  	d.Runner.Stdout(buffer)
   252  	err := d.Runner.RunExecutable("docker", "exec", d.ContainerName, "cat", "/tmp/test-log.txt")
   253  	d.Runner.Stdout(log.Writer())
   254  	return buffer, err
   255  }
   256  
   257  func (d *IntegrationTestDockerExecRunner) assertHasFiles(t *testing.T, consistencies ...string) {
   258  	buffer := new(bytes.Buffer)
   259  	d.Runner.Stderr(buffer)
   260  	if d.Runner.RunExecutable(
   261  		"docker",
   262  		append(append(make([]string, 0), "exec", d.ContainerName, "stat"), consistencies...)...,
   263  	) != nil {
   264  		t.Fatalf("[assertHasFiles] Assertion has failed: %v", errors.New(buffer.String()))
   265  	}
   266  }
   267  
   268  func (d *IntegrationTestDockerExecRunner) assertFileContentEquals(t *testing.T, fileWant string, contentWant string) {
   269  	d.assertHasFiles(t, fileWant)
   270  
   271  	buffer := new(bytes.Buffer)
   272  	d.Runner.Stdout(buffer)
   273  	err := d.Runner.RunExecutable("docker", "cp", d.ContainerName+":/"+fileWant, "-")
   274  	if err != nil {
   275  		t.Fatalf("Copy file has failed. Expected file %s to exist in container. %s", fileWant, err)
   276  	}
   277  
   278  	tarReader := tar.NewReader(buffer)
   279  	header, err := tarReader.Next()
   280  	if err == io.EOF {
   281  		t.Fatal("Empty tar received")
   282  	}
   283  	if err != nil {
   284  		t.Fatalf("Cant read tar: %s", err)
   285  	}
   286  	if header.Typeflag != tar.TypeReg {
   287  		t.Fatalf("Expected a file, but received %c", header.Typeflag)
   288  	}
   289  	str := new(bytes.Buffer)
   290  	_, err = io.Copy(str, tarReader)
   291  	if err != nil {
   292  		t.Fatalf("unable to get tar file content: %s", err)
   293  	}
   294  
   295  	assert.Equal(t, str.String(), contentWant, fmt.Sprintf("Unexpected content of file '%s'", fileWant))
   296  }
   297  
   298  func (d *IntegrationTestDockerExecRunner) terminate(t *testing.T) {
   299  	err := d.Runner.RunExecutable("docker", "rm", "-f", d.ContainerName)
   300  	if err != nil {
   301  		t.Fatalf("Failed to terminate container '%s'", d.ContainerName)
   302  	}
   303  }