Merged in feature/testingopts (pull request #129)

Testing Options and Clean Up

* bitsandbobs

* lint

* healthchecks

* imgintests

* singlegenerate

* testclean

* network

* networkclean

* cmds
This commit is contained in:
Michael McGuinness
2025-05-03 01:16:53 +00:00
parent 15fa3b49a5
commit dd2eebf13a
73 changed files with 4657 additions and 234 deletions
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.
+4
View File
@@ -0,0 +1,4 @@
terratest
Copyright 2018 Gruntwork, Inc.
This product includes software developed at Gruntwork (https://www.gruntwork.io/).
@@ -0,0 +1,2 @@
// Package collections allows to interact with lists of things.
package collections
+17
View File
@@ -0,0 +1,17 @@
package collections
import "fmt"
// SliceValueNotFoundError is returned when a provided values file input is not found on the host path.
type SliceValueNotFoundError struct {
sourceString string
}
func (err SliceValueNotFoundError) Error() string {
return fmt.Sprintf("Could not resolve requested slice value from string %s", err.sourceString)
}
// NewSliceValueNotFoundError creates a new slice found error
func NewSliceValueNotFoundError(sourceString string) SliceValueNotFoundError {
return SliceValueNotFoundError{sourceString}
}
+40
View File
@@ -0,0 +1,40 @@
package collections
// ListIntersection returns all the items in both list1 and list2. Note that this will dedup the items so that the
// output is more predictable. Otherwise, the end list depends on which list was used as the base.
func ListIntersection(list1 []string, list2 []string) []string {
out := []string{}
// Only need to iterate list1, because we want items in both lists, not union.
for _, item := range list1 {
if ListContains(list2, item) && !ListContains(out, item) {
out = append(out, item)
}
}
return out
}
// ListSubtract removes all the items in list2 from list1.
func ListSubtract(list1 []string, list2 []string) []string {
out := []string{}
for _, item := range list1 {
if !ListContains(list2, item) {
out = append(out, item)
}
}
return out
}
// ListContains returns true if the given list of strings (haystack) contains the given string (needle).
func ListContains(haystack []string, needle string) bool {
for _, str := range haystack {
if needle == str {
return true
}
}
return false
}
@@ -0,0 +1,27 @@
package collections
import (
"strings"
)
// GetSliceLastValueE will take a source string and returns the last value when split by the separator char.
func GetSliceLastValueE(source string, separator string) (string, error) {
if len(source) > 0 && len(separator) > 0 && strings.Contains(source, separator) {
tmp := strings.Split(source, separator)
return tmp[len(tmp)-1], nil
}
return "", NewSliceValueNotFoundError(source)
}
// GetSliceIndexValueE will take a source string and returns the value at the given index when split by
// the separator char.
func GetSliceIndexValueE(source string, separator string, index int) (string, error) {
if len(source) > 0 && len(separator) > 0 && strings.Contains(source, separator) && index >= 0 {
tmp := strings.Split(source, separator)
if index > len(tmp) {
return "", NewSliceValueNotFoundError(source)
}
return tmp[index], nil
}
return "", NewSliceValueNotFoundError(source)
}
+222
View File
@@ -0,0 +1,222 @@
package docker
import (
"os"
"path/filepath"
"strings"
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/shell"
"github.com/gruntwork-io/terratest/modules/testing"
"github.com/hashicorp/go-multierror"
"github.com/stretchr/testify/require"
)
// BuildOptions defines options that can be passed to the 'docker build' command.
type BuildOptions struct {
// Tags for the Docker image
Tags []string
// Build args to pass the 'docker build' command
BuildArgs []string
// Target build arg to pass to the 'docker build' command
Target string
// All architectures to target in a multiarch build. Configuring this variable will cause terratest to use docker
// buildx to construct multiarch images.
// You can read more about multiarch docker builds in the official documentation for buildx:
// https://docs.docker.com/buildx/working-with-buildx/
// NOTE: This list does not automatically include the current platform. For example, if you are building images on
// an Apple Silicon based MacBook, and you configure this variable to []string{"linux/amd64"} to build an amd64
// image, the buildx command will not automatically include linux/arm64 - you must include that explicitly.
Architectures []string
// Whether or not to push images directly to the registry on build. Note that for multiarch images (Architectures is
// not empty), this must be true to ensure availability of all architectures - only the image for the current
// platform will be loaded into the daemon (due to a limitation of the docker daemon), so you won't be able to run a
// `docker push` command later to push the multiarch image.
// See https://github.com/moby/moby/pull/38738 for more info on the limitation of multiarch images in docker daemon.
Push bool
// Whether or not to load the image into the docker daemon at the end of a multiarch build so that it can be used
// locally. Note that this is only used when Architectures is set, and assumes the current architecture is already
// included in the Architectures list.
Load bool
// Custom CLI options that will be passed as-is to the 'docker build' command. This is an "escape hatch" that allows
// Terratest to not have to support every single command-line option offered by the 'docker build' command, and
// solely focus on the most important ones.
OtherOptions []string
// Whether ot not to enable buildkit. You can find more information about buildkit here https://docs.docker.com/build/buildkit/#getting-started.
EnableBuildKit bool
// Additional environment variables to pass in when running docker build command.
Env map[string]string
// Set a logger that should be used. See the logger package for more info.
Logger *logger.Logger
}
// Build runs the 'docker build' command at the given path with the given options and fails the test if there are any
// errors.
func Build(t testing.TestingT, path string, options *BuildOptions) {
require.NoError(t, BuildE(t, path, options))
}
// BuildE runs the 'docker build' command at the given path with the given options and returns any errors.
func BuildE(t testing.TestingT, path string, options *BuildOptions) error {
options.Logger.Logf(t, "Running 'docker build' in %s", path)
env := make(map[string]string)
if options.Env != nil {
env = options.Env
}
if options.EnableBuildKit {
env["DOCKER_BUILDKIT"] = "1"
}
cmd := shell.Command{
Command: "docker",
Args: formatDockerBuildArgs(path, options),
Logger: options.Logger,
Env: env,
}
if err := shell.RunCommandE(t, cmd); err != nil {
return err
}
// For non multiarch images, we need to call docker push for each tag since build does not have a push option like
// buildx.
if len(options.Architectures) == 0 && options.Push {
var errorsOccurred = new(multierror.Error)
for _, tag := range options.Tags {
if err := PushE(t, options.Logger, tag); err != nil {
options.Logger.Logf(t, "ERROR: error pushing tag %s", tag)
errorsOccurred = multierror.Append(err)
}
}
return errorsOccurred.ErrorOrNil()
}
// For multiarch images, if a load is requested call the load command to export the built image into the daemon.
if len(options.Architectures) > 0 && options.Load {
loadCmd := shell.Command{
Command: "docker",
Args: formatDockerBuildxLoadArgs(path, options),
Logger: options.Logger,
}
return shell.RunCommandE(t, loadCmd)
}
return nil
}
// GitCloneAndBuild builds a new Docker image from a given Git repo. This function will clone the given repo at the
// specified ref, and call the docker build command on the cloned repo from the given relative path (relative to repo
// root). This will fail the test if there are any errors.
func GitCloneAndBuild(
t testing.TestingT,
repo string,
ref string,
path string,
dockerBuildOpts *BuildOptions,
) {
require.NoError(t, GitCloneAndBuildE(t, repo, ref, path, dockerBuildOpts))
}
// GitCloneAndBuildE builds a new Docker image from a given Git repo. This function will clone the given repo at the
// specified ref, and call the docker build command on the cloned repo from the given relative path (relative to repo
// root).
func GitCloneAndBuildE(
t testing.TestingT,
repo string,
ref string,
path string,
dockerBuildOpts *BuildOptions,
) error {
workingDir, err := os.MkdirTemp("", "")
if err != nil {
return err
}
defer os.RemoveAll(workingDir)
cloneCmd := shell.Command{
Command: "git",
Args: []string{"clone", repo, workingDir},
}
if err := shell.RunCommandE(t, cloneCmd); err != nil {
return err
}
checkoutCmd := shell.Command{
Command: "git",
Args: []string{"checkout", ref},
WorkingDir: workingDir,
}
if err := shell.RunCommandE(t, checkoutCmd); err != nil {
return err
}
contextPath := filepath.Join(workingDir, path)
if err := BuildE(t, contextPath, dockerBuildOpts); err != nil {
return err
}
return nil
}
// formatDockerBuildArgs formats the arguments for the 'docker build' command.
func formatDockerBuildArgs(path string, options *BuildOptions) []string {
args := []string{}
if len(options.Architectures) > 0 {
args = append(
args,
"buildx",
"build",
"--platform",
strings.Join(options.Architectures, ","),
)
if options.Push {
args = append(args, "--push")
}
} else {
args = append(args, "build")
}
return append(args, formatDockerBuildBaseArgs(path, options)...)
}
// formatDockerBuildxLoadArgs formats the arguments for calling load on the 'docker buildx' command.
func formatDockerBuildxLoadArgs(path string, options *BuildOptions) []string {
args := []string{
"buildx",
"build",
"--load",
}
return append(args, formatDockerBuildBaseArgs(path, options)...)
}
// formatDockerBuildBaseArgs formats the common args for the build command, both for `build` and `buildx`.
func formatDockerBuildBaseArgs(path string, options *BuildOptions) []string {
args := []string{}
for _, tag := range options.Tags {
args = append(args, "--tag", tag)
}
for _, arg := range options.BuildArgs {
args = append(args, "--build-arg", arg)
}
if len(options.Target) > 0 {
args = append(args, "--target", options.Target)
}
args = append(args, options.OtherOptions...)
args = append(args, path)
return args
}
+2
View File
@@ -0,0 +1,2 @@
// Package docker allows to interact with Docker and docker compose resources.
package docker
@@ -0,0 +1,99 @@
package docker
import (
"regexp"
"strings"
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/shell"
"github.com/gruntwork-io/terratest/modules/testing"
"github.com/stretchr/testify/require"
"gotest.tools/v3/icmd"
)
// Options are Docker options.
type Options struct {
WorkingDir string
EnvVars map[string]string
// Whether ot not to enable buildkit. You can find more information about buildkit here https://docs.docker.com/build/buildkit/#getting-started.
EnableBuildKit bool
// Set a logger that should be used. See the logger package for more info.
Logger *logger.Logger
ProjectName string
}
// RunDockerCompose runs docker compose with the given arguments and options and return stdout/stderr.
func RunDockerCompose(t testing.TestingT, options *Options, args ...string) string {
out, err := runDockerComposeE(t, false, options, args...)
if err != nil {
t.Fatal(err)
}
return out
}
// RunDockerComposeAndGetStdout runs docker compose with the given arguments and options and returns only stdout.
func RunDockerComposeAndGetStdOut(t testing.TestingT, options *Options, args ...string) string {
out, err := runDockerComposeE(t, true, options, args...)
require.NoError(t, err)
return out
}
// RunDockerComposeE runs docker compose with the given arguments and options and return stdout/stderr.
func RunDockerComposeE(t testing.TestingT, options *Options, args ...string) (string, error) {
return runDockerComposeE(t, false, options, args...)
}
func runDockerComposeE(t testing.TestingT, stdout bool, options *Options, args ...string) (string, error) {
var cmd shell.Command
projectName := options.ProjectName
if len(projectName) <= 0 {
projectName = strings.ToLower(t.Name())
}
dockerComposeVersionCmd := icmd.Command("docker", "compose", "version")
result := icmd.RunCmd(dockerComposeVersionCmd)
if options.EnableBuildKit {
if options.EnvVars == nil {
options.EnvVars = make(map[string]string)
}
options.EnvVars["DOCKER_BUILDKIT"] = "1"
options.EnvVars["COMPOSE_DOCKER_CLI_BUILD"] = "1"
}
if result.ExitCode == 0 {
cmd = shell.Command{
Command: "docker",
Args: append([]string{"compose", "--project-name", generateValidDockerComposeProjectName(projectName)}, args...),
WorkingDir: options.WorkingDir,
Env: options.EnvVars,
Logger: options.Logger,
}
} else {
cmd = shell.Command{
Command: "docker-compose",
// We append --project-name to ensure containers from multiple different tests using Docker Compose don't end
// up in the same project and end up conflicting with each other.
Args: append([]string{"--project-name", generateValidDockerComposeProjectName(projectName)}, args...),
WorkingDir: options.WorkingDir,
Env: options.EnvVars,
Logger: options.Logger,
}
}
if stdout {
return shell.RunCommandAndGetStdOut(t, cmd), nil
}
return shell.RunCommandAndGetOutputE(t, cmd)
}
// Note: docker-compose command doesn't like lower case or special characters, other than -.
func generateValidDockerComposeProjectName(str string) string {
lower_str := strings.ToLower(str)
return regexp.MustCompile(`[^a-zA-Z0-9 ]+`).ReplaceAllString(lower_str, "-")
}
+40
View File
@@ -0,0 +1,40 @@
package docker
import (
"os"
"strings"
)
// GetDockerHost returns the name or address of the host on which the Docker engine is running.
func GetDockerHost() string {
return getDockerHostFromEnv(os.Environ())
}
func getDockerHostFromEnv(env []string) string {
// Parses the DOCKER_HOST environment variable to find the address
//
// For valid formats see:
// https://github.com/docker/cli/blob/6916b427a0b07e8581d121967633235ced6db9a1/opts/hosts.go#L69
var dockerUrl []string
for _, item := range env {
envVar := strings.Split(item, "=")
if len(envVar) == 2 && envVar[0] == "DOCKER_HOST" {
dockerUrl = strings.Split(envVar[1], ":")
break
}
}
if len(dockerUrl) < 2 {
// DOCKER_HOST was empty, not present or not a valid URL
return "localhost"
}
switch dockerUrl[0] {
case "tcp", "ssh", "fd":
return strings.TrimPrefix(dockerUrl[1], "//")
default:
// if DOCKER_HOST is not in one of the formats listed above, return default
return "localhost"
}
}
+112
View File
@@ -0,0 +1,112 @@
package docker
import (
"bufio"
"encoding/json"
"fmt"
"strings"
"github.com/gruntwork-io/terratest/modules/collections"
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/shell"
"github.com/gruntwork-io/terratest/modules/testing"
"github.com/stretchr/testify/require"
)
// Image represents a docker image, and exports all the fields that the docker images command returns for the
// image.
type Image struct {
// ID is the image ID in docker, and can be used to identify the image in place of the repo and tag.
ID string
// Repository is the image repository.
Repository string
// Tag is the image tag wichin the repository.
Tag string
// CreatedAt represents a timestamp for when the image was created.
CreatedAt string
// CreatedSince is a diff between when the image was created to now.
CreatedSince string
// SharedSize is the amount of space that an image shares with another one (i.e. their common data).
SharedSize string
// UniqueSize is the amount of space that is only used by a given image.
UniqueSize string
// VirtualSize is the total size of the image, combining SharedSize and UniqueSize.
VirtualSize string
// Containers represents the list of containers that are using the image.
Containers string
// Digest is the hash digest of the image, if requested.
Digest string
}
func (image Image) String() string {
return fmt.Sprintf("%s:%s", image.Repository, image.Tag)
}
// DeleteImage removes a docker image using the Docker CLI. This will fail the test if there is an error.
func DeleteImage(t testing.TestingT, img string, logger *logger.Logger) {
require.NoError(t, DeleteImageE(t, img, logger))
}
// DeleteImageE removes a docker image using the Docker CLI.
func DeleteImageE(t testing.TestingT, img string, logger *logger.Logger) error {
cmd := shell.Command{
Command: "docker",
Args: []string{"rmi", img},
Logger: logger,
}
return shell.RunCommandE(t, cmd)
}
// ListImages calls docker images using the Docker CLI to list the available images on the local docker daemon.
func ListImages(t testing.TestingT, logger *logger.Logger) []Image {
out, err := ListImagesE(t, logger)
require.NoError(t, err)
return out
}
// ListImagesE calls docker images using the Docker CLI to list the available images on the local docker daemon.
func ListImagesE(t testing.TestingT, logger *logger.Logger) ([]Image, error) {
cmd := shell.Command{
Command: "docker",
Args: []string{"images", "--format", "{{ json . }}"},
Logger: logger,
}
out, err := shell.RunCommandAndGetOutputE(t, cmd)
if err != nil {
return nil, err
}
// Parse and return the list of image objects.
images := []Image{}
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
var image Image
err := json.Unmarshal([]byte(line), &image)
if err != nil {
return nil, err
}
images = append(images, image)
}
return images, nil
}
// DoesImageExist lists the images in the docker daemon and returns true if the given image label (repo:tag) exists.
// This will fail the test if there is an error.
func DoesImageExist(t testing.TestingT, imgLabel string, logger *logger.Logger) bool {
images := ListImages(t, logger)
imageTags := []string{}
for _, image := range images {
imageTags = append(imageTags, image.String())
}
return collections.ListContains(imageTags, imgLabel)
}
+270
View File
@@ -0,0 +1,270 @@
package docker
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"testing"
"time"
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/shell"
"github.com/stretchr/testify/require"
)
// ContainerInspect defines the output of the Inspect method, with the options returned by 'docker inspect'
// converted into a more friendly and testable interface
type ContainerInspect struct {
// ID of the inspected container
ID string
// Name of the inspected container
Name string
// time.Time that the container was created
Created time.Time
// String representing the container's status
Status string
// Whether the container is currently running or not
Running bool
// Container's exit code
ExitCode uint8
// String with the container's error message, if there is any
Error string
// Ports exposed by the container
Ports []Port
// Volume bindings made to the container
Binds []VolumeBind
// Health check
Health HealthCheck
}
// Port represents a single port mapping exported by the container
type Port struct {
HostPort uint16
ContainerPort uint16
Protocol string
}
// VolumeBind represents a single volume binding made to the container
type VolumeBind struct {
Source string
Destination string
}
// HealthCheck represents the current health history of the container
type HealthCheck struct {
// Health check status
Status string
// Current count of failing health checks
FailingStreak uint8
// Log of failures
Log []HealthLog
}
// HealthLog represents the output of a single Health check of the container
type HealthLog struct {
// Start time of health check
Start string
// End time of health check
End string
// Exit code of health check
ExitCode uint8
// Output of health check
Output string
}
// inspectOutput defines options that will be returned by 'docker inspect', in JSON format.
// Not all options are included here, only the ones that we might need
type inspectOutput struct {
Id string
Created string
Name string
State struct {
Health HealthCheck
Status string
Running bool
ExitCode uint8
Error string
}
NetworkSettings struct {
Ports map[string][]struct {
HostIp string
HostPort string
}
}
HostConfig struct {
Binds []string
}
}
// Inspect runs the 'docker inspect {container id}' command and returns a ContainerInspect
// struct, converted from the output JSON, along with any errors
func Inspect(t *testing.T, id string) *ContainerInspect {
out, err := InspectE(t, id)
require.NoError(t, err)
return out
}
// InspectE runs the 'docker inspect {container id}' command and returns a ContainerInspect
// struct, converted from the output JSON, along with any errors
func InspectE(t *testing.T, id string) (*ContainerInspect, error) {
cmd := shell.Command{
Command: "docker",
Args: []string{"container", "inspect", id},
// inspect is a short-running command, don't print the output.
Logger: logger.Discard,
}
out, err := shell.RunCommandAndGetStdOutE(t, cmd)
if err != nil {
return nil, err
}
var containers []inspectOutput
err = json.Unmarshal([]byte(out), &containers)
if err != nil {
return nil, err
}
if len(containers) == 0 {
return nil, fmt.Errorf("no container found with ID %s", id)
}
container := containers[0]
return transformContainer(t, container)
}
// transformContainerPorts converts 'docker inspect' output JSON into a more friendly and testable format
func transformContainer(t *testing.T, container inspectOutput) (*ContainerInspect, error) {
name := strings.TrimLeft(container.Name, "/")
ports, err := transformContainerPorts(container)
if err != nil {
return nil, err
}
volumes := transformContainerVolumes(container)
created, err := time.Parse(time.RFC3339Nano, container.Created)
if err != nil {
return nil, err
}
inspect := ContainerInspect{
ID: container.Id,
Name: name,
Created: created,
Status: container.State.Status,
Running: container.State.Running,
ExitCode: container.State.ExitCode,
Error: container.State.Error,
Ports: ports,
Binds: volumes,
Health: HealthCheck{
Status: container.State.Health.Status,
FailingStreak: container.State.Health.FailingStreak,
Log: container.State.Health.Log,
},
}
return &inspect, nil
}
// transformContainerPorts converts Docker's ports from the following json into a more testable format
//
// {
// "80/tcp": [
// {
// "HostIp": ""
// "HostPort": "8080"
// }
// ]
// }
func transformContainerPorts(container inspectOutput) ([]Port, error) {
var ports []Port
cPorts := container.NetworkSettings.Ports
for key, portBinding := range cPorts {
split := strings.Split(key, "/")
containerPort, err := strconv.ParseUint(split[0], 10, 16)
if err != nil {
return nil, err
}
var protocol string
if len(split) > 1 {
protocol = split[1]
}
for _, port := range portBinding {
hostPort, err := strconv.ParseUint(port.HostPort, 10, 16)
if err != nil {
return nil, err
}
ports = append(ports, Port{
HostPort: uint16(hostPort),
ContainerPort: uint16(containerPort),
Protocol: protocol,
})
}
}
return ports, nil
}
// GetExposedHostPort returns an exposed host port according to requested container port. Returns 0 if the requested port is not exposed.
func (inspectOutput ContainerInspect) GetExposedHostPort(containerPort uint16) uint16 {
for _, port := range inspectOutput.Ports {
if port.ContainerPort == containerPort {
return port.HostPort
}
}
return uint16(0)
}
// transformContainerVolumes converts Docker's volume bindings from the
// format "/foo/bar:/foo/baz" into a more testable one
func transformContainerVolumes(container inspectOutput) []VolumeBind {
binds := container.HostConfig.Binds
volumes := make([]VolumeBind, 0, len(binds))
for _, bind := range binds {
var source, dest string
split := strings.Split(bind, ":")
// Considering it as an unbound volume
dest = split[0]
if len(split) == 2 {
source = split[0]
dest = split[1]
}
volumes = append(volumes, VolumeBind{
Source: source,
Destination: dest,
})
}
return volumes
}
+25
View File
@@ -0,0 +1,25 @@
package docker
import (
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/shell"
"github.com/gruntwork-io/terratest/modules/testing"
"github.com/stretchr/testify/require"
)
// Push runs the 'docker push' command to push the given tag. This will fail the test if there are any errors.
func Push(t testing.TestingT, logger *logger.Logger, tag string) {
require.NoError(t, PushE(t, logger, tag))
}
// PushE runs the 'docker push' command to push the given tag.
func PushE(t testing.TestingT, logger *logger.Logger, tag string) error {
logger.Logf(t, "Running 'docker push' for tag %s", tag)
cmd := shell.Command{
Command: "docker",
Args: []string{"push", tag},
Logger: logger,
}
return shell.RunCommandE(t, cmd)
}
+166
View File
@@ -0,0 +1,166 @@
package docker
import (
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/shell"
"github.com/gruntwork-io/terratest/modules/testing"
"github.com/stretchr/testify/require"
)
// RunOptions defines options that can be passed to the 'docker run' command.
type RunOptions struct {
// Override the default COMMAND of the Docker image
Command []string
// If set to true, pass the --detach flag to 'docker run' to run the container in the background
Detach bool
// Override the default ENTRYPOINT of the Docker image
Entrypoint string
// Set environment variables
EnvironmentVariables []string
// If set to true, pass the --init flag to 'docker run' to run an init inside the container that forwards signals
// and reaps processes
Init bool
// Assign a name to the container
Name string
// Set platform
Platform string
// If set to true, pass the --privileged flag to 'docker run' to give extended privileges to the container
Privileged bool
// If set to true, pass the --rm flag to 'docker run' to automatically remove the container when it exits
Remove bool
// If set to true, pass the -tty flag to 'docker run' to allocate a pseudo-TTY
Tty bool
// Username or UID
User string
// Bind mount these volume(s) when running the container
Volumes []string
// Custom CLI options that will be passed as-is to the 'docker run' command. This is an "escape hatch" that allows
// Terratest to not have to support every single command-line option offered by the 'docker run' command, and
// solely focus on the most important ones.
OtherOptions []string
// Set a logger that should be used. See the logger package for more info.
Logger *logger.Logger
}
// Run runs the 'docker run' command on the given image with the given options and return stdout/stderr. This method
// fails the test if there are any errors.
func Run(t testing.TestingT, image string, options *RunOptions) string {
out, err := RunE(t, image, options)
require.NoError(t, err)
return out
}
// RunE runs the 'docker run' command on the given image with the given options and return stdout/stderr, or any error.
func RunE(t testing.TestingT, image string, options *RunOptions) (string, error) {
options.Logger.Logf(t, "Running 'docker run' on image '%s'", image)
args, err := formatDockerRunArgs(image, options)
if err != nil {
return "", err
}
cmd := shell.Command{
Command: "docker",
Args: args,
Logger: options.Logger,
}
return shell.RunCommandAndGetOutputE(t, cmd)
}
// RunAndGetID runs the 'docker run' command on the given image with the given options and returns the container ID
// that is returned in stdout. This method fails the test if there are any errors.
func RunAndGetID(t testing.TestingT, image string, options *RunOptions) string {
out, err := RunAndGetIDE(t, image, options)
require.NoError(t, err)
return out
}
// RunAndGetIDE runs the 'docker run' command on the given image with the given options and returns the container ID
// that is returned in stdout, or any error.
func RunAndGetIDE(t testing.TestingT, image string, options *RunOptions) (string, error) {
options.Logger.Logf(t, "Running 'docker run' on image '%s', returning stdout", image)
args, err := formatDockerRunArgs(image, options)
if err != nil {
return "", err
}
cmd := shell.Command{
Command: "docker",
Args: args,
Logger: options.Logger,
}
return shell.RunCommandAndGetStdOutE(t, cmd)
}
// formatDockerRunArgs formats the arguments for the 'docker run' command.
func formatDockerRunArgs(image string, options *RunOptions) ([]string, error) {
args := []string{"run"}
if options.Detach {
args = append(args, "--detach")
}
if options.Entrypoint != "" {
args = append(args, "--entrypoint", options.Entrypoint)
}
for _, envVar := range options.EnvironmentVariables {
args = append(args, "--env", envVar)
}
if options.Init {
args = append(args, "--init")
}
if options.Name != "" {
args = append(args, "--name", options.Name)
}
if options.Platform != "" {
args = append(args, "--platform", options.Platform)
}
if options.Privileged {
args = append(args, "--privileged")
}
if options.Remove {
args = append(args, "--rm")
}
if options.Tty {
args = append(args, "--tty")
}
if options.User != "" {
args = append(args, "--user", options.User)
}
for _, volume := range options.Volumes {
args = append(args, "--volume", volume)
}
args = append(args, options.OtherOptions...)
args = append(args, image)
args = append(args, options.Command...)
return args, nil
}
+59
View File
@@ -0,0 +1,59 @@
package docker
import (
"strconv"
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/shell"
"github.com/gruntwork-io/terratest/modules/testing"
"github.com/stretchr/testify/require"
)
// StopOptions defines the options that can be passed to the 'docker stop' command
type StopOptions struct {
// Seconds to wait for stop before killing the container (default 10)
Time int
// Set a logger that should be used. See the logger package for more info.
Logger *logger.Logger
}
// Stop runs the 'docker stop' command for the given containers and return the stdout/stderr. This method fails
// the test if there are any errors
func Stop(t testing.TestingT, containers []string, options *StopOptions) string {
out, err := StopE(t, containers, options)
require.NoError(t, err)
return out
}
// StopE runs the 'docker stop' command for the given containers and returns any errors.
func StopE(t testing.TestingT, containers []string, options *StopOptions) (string, error) {
options.Logger.Logf(t, "Running 'docker stop' on containers '%s'", containers)
args, err := formatDockerStopArgs(containers, options)
if err != nil {
return "", err
}
cmd := shell.Command{
Command: "docker",
Args: args,
Logger: options.Logger,
}
return shell.RunCommandAndGetOutputE(t, cmd)
}
// formatDockerStopArgs formats the arguments for the 'docker stop' command
func formatDockerStopArgs(containers []string, options *StopOptions) ([]string, error) {
args := []string{"stop"}
if options.Time != 0 {
args = append(args, "--time", strconv.Itoa(options.Time))
}
args = append(args, containers...)
return args, nil
}
+178
View File
@@ -0,0 +1,178 @@
// Package logger contains different methods to log.
package logger
import (
"fmt"
"io"
"os"
"runtime"
"strings"
"sync"
gotesting "testing"
"time"
"github.com/gruntwork-io/terratest/modules/testing"
)
var (
// Default is the default logger that is used for the Logf function, if no one is provided. It uses the
// TerratestLogger to log messages. This can be overwritten to change the logging globally.
Default = New(terratestLogger{})
// Discard discards all logging.
Discard = New(discardLogger{})
// Terratest logs the given format and arguments, formatted using fmt.Sprintf, to stdout, along with a timestamp and
// information about what test and file is doing the logging. Before Go 1.14, this is an alternative to t.Logf as it
// logs to stdout immediately, rather than buffering all log output and only displaying it at the very end of the test.
// This is useful because:
//
// 1. It allows you to iterate faster locally, as you get feedback on whether your code changes are working as expected
// right away, rather than at the very end of the test run.
//
// 2. If you have a bug in your code that causes a test to never complete or if the test code crashes, t.Logf would
// show you no log output whatsoever, making debugging very hard, where as this method will show you all the log
// output available.
//
// 3. If you have a test that takes a long time to complete, some CI systems will kill the test suite prematurely
// because there is no log output with t.Logf (e.g., CircleCI kills tests after 10 minutes of no log output). With
// this log method, you get log output continuously.
//
Terratest = New(terratestLogger{})
// TestingT can be used to use Go's testing.T to log. If this is used, but no testing.T is provided, it will fallback
// to Default.
TestingT = New(testingT{})
)
type TestLogger interface {
Logf(t testing.TestingT, format string, args ...interface{})
}
type Logger struct {
l TestLogger
}
func New(l TestLogger) *Logger {
return &Logger{
l,
}
}
func (l *Logger) Logf(t testing.TestingT, format string, args ...interface{}) {
if tt, ok := t.(helper); ok {
tt.Helper()
}
// methods can be called on (typed) nil pointers. In this case, use the Default function to log. This enables the
// caller to do `var l *Logger` and then use the logger already.
if l == nil || l.l == nil {
Default.Logf(t, format, args...)
return
}
l.l.Logf(t, format, args...)
}
// helper is used to mark this library as a "helper", and thus not appearing in the line numbers. testing.T implements
// this interface, for example.
type helper interface {
Helper()
}
type discardLogger struct{}
func (_ discardLogger) Logf(_ testing.TestingT, format string, args ...interface{}) {}
type testingT struct{}
func (_ testingT) Logf(t testing.TestingT, format string, args ...interface{}) {
// this should never fail
tt, ok := t.(*gotesting.T)
if !ok {
// fallback
DoLog(t, 2, os.Stdout, fmt.Sprintf(format, args...))
return
}
tt.Helper()
tt.Logf(format, args...)
return
}
type terratestLogger struct{}
func (_ terratestLogger) Logf(t testing.TestingT, format string, args ...interface{}) {
DoLog(t, 3, os.Stdout, fmt.Sprintf(format, args...))
}
// Deprecated: use Logger instead, as it provides more flexibility on logging.
// Logf logs the given format and arguments, formatted using fmt.Sprintf, to stdout, along with a timestamp and information
// about what test and file is doing the logging. Before Go 1.14, this is an alternative to t.Logf as it logs to stdout
// immediately, rather than buffering all log output and only displaying it at the very end of the test. This is useful
// because:
//
// 1. It allows you to iterate faster locally, as you get feedback on whether your code changes are working as expected
// right away, rather than at the very end of the test run.
//
// 2. If you have a bug in your code that causes a test to never complete or if the test code crashes, t.Logf would
// show you no log output whatsoever, making debugging very hard, where as this method will show you all the log
// output available.
//
// 3. If you have a test that takes a long time to complete, some CI systems will kill the test suite prematurely
// because there is no log output with t.Logf (e.g., CircleCI kills tests after 10 minutes of no log output). With
// this log method, you get log output continuously.
//
// Although t.Logf now supports streaming output since Go 1.14, this is kept for compatibility purposes.
func Logf(t testing.TestingT, format string, args ...interface{}) {
if tt, ok := t.(helper); ok {
tt.Helper()
}
mutexStdout.Lock()
defer mutexStdout.Unlock()
DoLog(t, 2, os.Stdout, fmt.Sprintf(format, args...))
}
// Log logs the given arguments to stdout, along with a timestamp and information about what test and file is doing the
// logging. This is an alternative to t.Logf that logs to stdout immediately, rather than buffering all log output and
// only displaying it at the very end of the test. See the Logf method for more info.
func Log(t testing.TestingT, args ...interface{}) {
if tt, ok := t.(helper); ok {
tt.Helper()
}
mutexStdout.Lock()
defer mutexStdout.Unlock()
DoLog(t, 2, os.Stdout, args...)
}
var mutexStdout sync.Mutex
// DoLog logs the given arguments to the given writer, along with a timestamp and information about what test and file is
// doing the logging.
func DoLog(t testing.TestingT, callDepth int, writer io.Writer, args ...interface{}) {
date := time.Now()
prefix := fmt.Sprintf("%s %s %s:", t.Name(), date.Format(time.RFC3339), CallerPrefix(callDepth+1))
allArgs := append([]interface{}{prefix}, args...)
fmt.Fprintln(writer, allArgs...)
}
// CallerPrefix returns the file and line number information about the methods that called this method, based on the current
// goroutine's stack. The argument callDepth is the number of stack frames to ascend, with 0 identifying the method
// that called CallerPrefix, 1 identifying the method that called that method, and so on.
//
// This code is adapted from testing.go, where it is in a private method called decorate.
func CallerPrefix(callDepth int) string {
_, file, line, ok := runtime.Caller(callDepth)
if ok {
// Truncate file name at last file name separator.
if index := strings.LastIndex(file, "/"); index >= 0 {
file = file[index+1:]
} else if index = strings.LastIndex(file, "\\"); index >= 0 {
file = file[index+1:]
}
} else {
file = "???"
line = 1
}
return fmt.Sprintf("%s:%d", file, line)
}
+252
View File
@@ -0,0 +1,252 @@
package shell
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/testing"
"github.com/stretchr/testify/require"
)
// Command is a simpler struct for defining commands than Go's built-in Cmd.
type Command struct {
Command string // The command to run
Args []string // The args to pass to the command
WorkingDir string // The working directory
Env map[string]string // Additional environment variables to set
// Use the specified logger for the command's output. Use logger.Discard to not print the output while executing the command.
Logger *logger.Logger
}
// RunCommand runs a shell command and redirects its stdout and stderr to the stdout of the atomic script itself. If
// there are any errors, fail the test.
func RunCommand(t testing.TestingT, command Command) {
err := RunCommandE(t, command)
require.NoError(t, err)
}
// RunCommandE runs a shell command and redirects its stdout and stderr to the stdout of the atomic script itself. Any
// returned error will be of type ErrWithCmdOutput, containing the output streams and the underlying error.
func RunCommandE(t testing.TestingT, command Command) error {
output, err := runCommand(t, command)
if err != nil {
return &ErrWithCmdOutput{err, output}
}
return nil
}
// RunCommandAndGetOutput runs a shell command and returns its stdout and stderr as a string. The stdout and stderr of
// that command will also be logged with Command.Log to make debugging easier. If there are any errors, fail the test.
func RunCommandAndGetOutput(t testing.TestingT, command Command) string {
out, err := RunCommandAndGetOutputE(t, command)
require.NoError(t, err)
return out
}
// RunCommandAndGetOutputE runs a shell command and returns its stdout and stderr as a string. The stdout and stderr of
// that command will also be logged with Command.Log to make debugging easier. Any returned error will be of type
// ErrWithCmdOutput, containing the output streams and the underlying error.
func RunCommandAndGetOutputE(t testing.TestingT, command Command) (string, error) {
output, err := runCommand(t, command)
if err != nil {
return output.Combined(), &ErrWithCmdOutput{err, output}
}
return output.Combined(), nil
}
// RunCommandAndGetStdOut runs a shell command and returns solely its stdout (but not stderr) as a string. The stdout and
// stderr of that command will also be logged with Command.Log to make debugging easier. If there are any errors, fail
// the test.
func RunCommandAndGetStdOut(t testing.TestingT, command Command) string {
output, err := RunCommandAndGetStdOutE(t, command)
require.NoError(t, err)
return output
}
// RunCommandAndGetStdOutE runs a shell command and returns solely its stdout (but not stderr) as a string. The stdout
// and stderr of that command will also be printed to the stdout and stderr of this Go program to make debugging easier.
// Any returned error will be of type ErrWithCmdOutput, containing the output streams and the underlying error.
func RunCommandAndGetStdOutE(t testing.TestingT, command Command) (string, error) {
output, err := runCommand(t, command)
if err != nil {
return output.Stdout(), &ErrWithCmdOutput{err, output}
}
return output.Stdout(), nil
}
// RunCommandAndGetStdOutErr runs a shell command and returns solely its stdout and stderr as a string. The stdout and
// stderr of that command will also be logged with Command.Log to make debugging easier. If there are any errors, fail
// the test.
func RunCommandAndGetStdOutErr(t testing.TestingT, command Command) (stdout string, stderr string) {
stdout, stderr, err := RunCommandAndGetStdOutErrE(t, command)
require.NoError(t, err)
return stdout, stderr
}
// RunCommandAndGetStdOutErrE runs a shell command and returns solely its stdout and stderr as a string. The stdout
// and stderr of that command will also be printed to the stdout and stderr of this Go program to make debugging easier.
// Any returned error will be of type ErrWithCmdOutput, containing the output streams and the underlying error.
func RunCommandAndGetStdOutErrE(t testing.TestingT, command Command) (stdout string, stderr string, err error) {
output, err := runCommand(t, command)
if err != nil {
return output.Stdout(), output.Stderr(), &ErrWithCmdOutput{err, output}
}
return output.Stdout(), output.Stderr(), nil
}
type ErrWithCmdOutput struct {
Underlying error
Output *output
}
func (e *ErrWithCmdOutput) Error() string {
return fmt.Sprintf("error while running command: %v; %s", e.Underlying, e.Output.Stderr())
}
// runCommand runs a shell command and stores each line from stdout and stderr in Output. Depending on the logger, the
// stdout and stderr of that command will also be printed to the stdout and stderr of this Go program to make debugging
// easier.
func runCommand(t testing.TestingT, command Command) (*output, error) {
command.Logger.Logf(t, "Running command %s with args %s", command.Command, command.Args)
cmd := exec.Command(command.Command, command.Args...)
cmd.Dir = command.WorkingDir
cmd.Stdin = os.Stdin
cmd.Env = formatEnvVars(command)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
err = cmd.Start()
if err != nil {
return nil, err
}
output, err := readStdoutAndStderr(t, command.Logger, stdout, stderr)
if err != nil {
return output, err
}
return output, cmd.Wait()
}
// This function captures stdout and stderr into the given variables while still printing it to the stdout and stderr
// of this Go program
func readStdoutAndStderr(t testing.TestingT, log *logger.Logger, stdout, stderr io.ReadCloser) (*output, error) {
out := newOutput()
stdoutReader := bufio.NewReader(stdout)
stderrReader := bufio.NewReader(stderr)
wg := &sync.WaitGroup{}
wg.Add(2)
var stdoutErr, stderrErr error
go func() {
defer wg.Done()
stdoutErr = readData(t, log, stdoutReader, out.stdout)
}()
go func() {
defer wg.Done()
stderrErr = readData(t, log, stderrReader, out.stderr)
}()
wg.Wait()
if stdoutErr != nil {
return out, stdoutErr
}
if stderrErr != nil {
return out, stderrErr
}
return out, nil
}
func readData(t testing.TestingT, log *logger.Logger, reader *bufio.Reader, writer io.StringWriter) error {
var line string
var readErr error
for {
line, readErr = reader.ReadString('\n')
// remove newline, our output is in a slice,
// one element per line.
line = strings.TrimSuffix(line, "\n")
// only return early if the line does not have
// any contents. We could have a line that does
// not not have a newline before io.EOF, we still
// need to add it to the output.
if len(line) == 0 && readErr == io.EOF {
break
}
// logger.Logger has a Logf method, but not a Log method.
// We have to use the format string indirection to avoid
// interpreting any possible formatting characters in
// the line.
//
// See https://github.com/gruntwork-io/terratest/issues/982.
log.Logf(t, "%s", line)
if _, err := writer.WriteString(line); err != nil {
return err
}
if readErr != nil {
break
}
}
if readErr != io.EOF {
return readErr
}
return nil
}
// GetExitCodeForRunCommandError tries to read the exit code for the error object returned from running a shell command. This is a bit tricky to do
// in a way that works across platforms.
func GetExitCodeForRunCommandError(err error) (int, error) {
if errWithOutput, ok := err.(*ErrWithCmdOutput); ok {
err = errWithOutput.Underlying
}
// http://stackoverflow.com/a/10385867/483528
if exitErr, ok := err.(*exec.ExitError); ok {
// The program has exited with an exit code != 0
// This works on both Unix and Windows. Although package
// syscall is generally platform dependent, WaitStatus is
// defined for both Unix and Windows and in both cases has
// an ExitStatus() method with the same signature.
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
return status.ExitStatus(), nil
}
return 1, errors.New("could not determine exit code")
}
return 0, nil
}
func formatEnvVars(command Command) []string {
env := os.Environ()
for key, value := range command.Env {
env = append(env, fmt.Sprintf("%s=%s", key, value))
}
return env
}
+92
View File
@@ -0,0 +1,92 @@
package shell
import (
"strings"
"sync"
)
// output contains the output after runnig a command.
type output struct {
stdout *outputStream
stderr *outputStream
// merged contains stdout and stderr merged into one stream.
merged *merged
}
func newOutput() *output {
m := new(merged)
return &output{
merged: m,
stdout: &outputStream{
merged: m,
},
stderr: &outputStream{
merged: m,
},
}
}
func (o *output) Stdout() string {
if o == nil {
return ""
}
return o.stdout.String()
}
func (o *output) Stderr() string {
if o == nil {
return ""
}
return o.stderr.String()
}
func (o *output) Combined() string {
if o == nil {
return ""
}
return o.merged.String()
}
type outputStream struct {
Lines []string
*merged
}
func (st *outputStream) WriteString(s string) (n int, err error) {
st.Lines = append(st.Lines, string(s))
return st.merged.WriteString(s)
}
func (st *outputStream) String() string {
if st == nil {
return ""
}
return strings.Join(st.Lines, "\n")
}
type merged struct {
// ensure that there are no parallel writes
sync.Mutex
Lines []string
}
func (m *merged) String() string {
if m == nil {
return ""
}
return strings.Join(m.Lines, "\n")
}
func (m *merged) WriteString(s string) (n int, err error) {
m.Lock()
defer m.Unlock()
m.Lines = append(m.Lines, string(s))
return len(s), nil
}
+2
View File
@@ -0,0 +1,2 @@
// Package shell allows to run commands in a shell.
package shell
+28
View File
@@ -0,0 +1,28 @@
package testing
// TestingT is an interface that describes the implementation of the testing object
// that the majority of Terratest functions accept as first argument.
// Using an interface that describes testing.T instead of the actual implementation
// makes terratest usable in a wider variety of contexts (e.g. use with ginkgo : https://godoc.org/github.com/onsi/ginkgo#GinkgoT)
type TestingT interface {
//Fail marks the function as having failed but continues execution.
Fail()
// FailNow marks the function as having failed and stops its execution
// by calling runtime.Goexit (which then runs all deferred calls in the
// current goroutine).
// Execution will continue at the next test or benchmark.
// FailNow must be called from the goroutine running the
// test or benchmark function, not from other goroutines
// created during the test. Calling FailNow does not stop
// those other goroutines.
FailNow()
Fatal(args ...interface{})
// Fatalf is equivalent to Logf followed by FailNow.
Fatalf(format string, args ...interface{})
// Error is equivalent to Log followed by Fail.
Error(args ...interface{})
// Errorf is equivalent to Logf followed by Fail.
Errorf(format string, args ...interface{})
// Name returns the name of the running test or benchmark.
Name() string
}