package testcontainers import ( "archive/tar" "context" "errors" "fmt" "io" "maps" "os" "path/filepath" "strings" "time" "github.com/cpuguy83/dockercfg" "github.com/docker/docker/api/types/build" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/registry" "github.com/docker/go-connections/nat" "github.com/google/uuid" "github.com/moby/go-archive" "github.com/moby/patternmatcher/ignorefile" tcexec "github.com/testcontainers/testcontainers-go/exec" "github.com/testcontainers/testcontainers-go/internal/core" "github.com/testcontainers/testcontainers-go/log" "github.com/testcontainers/testcontainers-go/wait" ) // DeprecatedContainer shows methods that were supported before, but are now deprecated // Deprecated: Use Container type DeprecatedContainer interface { GetHostEndpoint(ctx context.Context, port string) (string, string, error) GetIPAddress(ctx context.Context) (string, error) LivenessCheckPorts(ctx context.Context) (nat.PortSet, error) Terminate(ctx context.Context) error } // Container allows getting info about and controlling a single container instance type Container interface { GetContainerID() string // get the container id from the provider Endpoint(context.Context, string) (string, error) // get proto://ip:port string for the lowest exposed port PortEndpoint(ctx context.Context, port nat.Port, proto string) (string, error) // get proto://ip:port string for the given exposed port Host(context.Context) (string, error) // get host where the container port is exposed Inspect(context.Context) (*container.InspectResponse, error) // get container info MappedPort(context.Context, nat.Port) (nat.Port, error) // get externally mapped port for a container port Ports(context.Context) (nat.PortMap, error) // Deprecated: Use c.Inspect(ctx).NetworkSettings.Ports instead SessionID() string // get session id IsRunning() bool // IsRunning returns true if the container is running, false otherwise. Start(context.Context) error // start the container Stop(context.Context, *time.Duration) error // stop the container // Terminate stops and removes the container and its image if it was built and not flagged as kept. Terminate(ctx context.Context, opts ...TerminateOption) error Logs(context.Context) (io.ReadCloser, error) // Get logs of the container FollowOutput(LogConsumer) // Deprecated: it will be removed in the next major release StartLogProducer(context.Context, ...LogProductionOption) error // Deprecated: Use the ContainerRequest instead StopLogProducer() error // Deprecated: it will be removed in the next major release Name(context.Context) (string, error) // Deprecated: Use c.Inspect(ctx).Name instead State(context.Context) (*container.State, error) // returns container's running state Networks(context.Context) ([]string, error) // get container networks NetworkAliases(context.Context) (map[string][]string, error) // get container network aliases for a network Exec(ctx context.Context, cmd []string, options ...tcexec.ProcessOption) (int, io.Reader, error) ContainerIP(context.Context) (string, error) // get container ip ContainerIPs(context.Context) ([]string, error) // get all container IPs CopyToContainer(ctx context.Context, fileContent []byte, containerFilePath string, fileMode int64) error CopyDirToContainer(ctx context.Context, hostDirPath string, containerParentPath string, fileMode int64) error CopyFileToContainer(ctx context.Context, hostFilePath string, containerFilePath string, fileMode int64) error CopyFileFromContainer(ctx context.Context, filePath string) (io.ReadCloser, error) GetLogProductionErrorChannel() <-chan error } // ImageBuildInfo defines what is needed to build an image type ImageBuildInfo interface { BuildOptions() (build.ImageBuildOptions, error) // converts the ImageBuildInfo to a build.ImageBuildOptions GetContext() (io.Reader, error) // the path to the build context GetDockerfile() string // the relative path to the Dockerfile, including the file itself GetRepo() string // get repo label for image GetTag() string // get tag label for image BuildLogWriter() io.Writer // for output of build log, use io.Discard to disable the output ShouldBuildImage() bool // return true if the image needs to be built GetBuildArgs() map[string]*string // return the environment args used to build the Dockerfile GetAuthConfigs() map[string]registry.AuthConfig // Deprecated. Testcontainers will detect registry credentials automatically. Return the auth configs to be able to pull from an authenticated docker registry } // FromDockerfile represents the parameters needed to build an image from a Dockerfile // rather than using a pre-built one type FromDockerfile struct { Context string // the path to the context of the docker build ContextArchive io.ReadSeeker // the tar archive file to send to docker that contains the build context Dockerfile string // the path from the context to the Dockerfile for the image, defaults to "Dockerfile" Repo string // the repo label for image, defaults to UUID Tag string // the tag label for image, defaults to UUID BuildArgs map[string]*string // enable user to pass build args to docker daemon PrintBuildLog bool // Deprecated: Use BuildLogWriter instead BuildLogWriter io.Writer // for output of build log, defaults to io.Discard AuthConfigs map[string]registry.AuthConfig // Deprecated. Testcontainers will detect registry credentials automatically. Enable auth configs to be able to pull from an authenticated docker registry // KeepImage describes whether DockerContainer.Terminate should not delete the // container image. Useful for images that are built from a Dockerfile and take a // long time to build. Keeping the image also Docker to reuse it. KeepImage bool // BuildOptionsModifier Modifier for the build options before image build. Use it for // advanced configurations while building the image. Please consider that the modifier // is called after the default build options are set. BuildOptionsModifier func(*build.ImageBuildOptions) } type ContainerFile struct { HostFilePath string // If Reader is present, HostFilePath is ignored Reader io.Reader // If Reader is present, HostFilePath is ignored ContainerFilePath string FileMode int64 } // validate validates the ContainerFile func (c *ContainerFile) validate() error { if c.HostFilePath == "" && c.Reader == nil { return errors.New("either HostFilePath or Reader must be specified") } if c.ContainerFilePath == "" { return errors.New("ContainerFilePath must be specified") } return nil } // ContainerRequest represents the parameters used to get a running container type ContainerRequest struct { FromDockerfile HostAccessPorts []int Image string ImageSubstitutors []ImageSubstitutor Entrypoint []string Env map[string]string ExposedPorts []string // allow specifying protocol info Cmd []string Labels map[string]string Mounts ContainerMounts Tmpfs map[string]string RegistryCred string // Deprecated: Testcontainers will detect registry credentials automatically WaitingFor wait.Strategy Name string // for specifying container name Hostname string // Deprecated: Use [ConfigModifier] instead. S WorkingDir string // Deprecated: Use [ConfigModifier] instead. Specify the working directory of the container ExtraHosts []string // Deprecated: Use HostConfigModifier instead Privileged bool // Deprecated: Use [HostConfigModifier] instead. For starting privileged container Networks []string // for specifying network names NetworkAliases map[string][]string // for specifying network aliases NetworkMode container.NetworkMode // Deprecated: Use HostConfigModifier instead Resources container.Resources // Deprecated: Use HostConfigModifier instead Files []ContainerFile // files which will be copied when container starts User string // Deprecated: Use [ConfigModifier] instead. For specifying uid:gid SkipReaper bool // Deprecated: The reaper is globally controlled by the .testcontainers.properties file or the TESTCONTAINERS_RYUK_DISABLED environment variable ReaperImage string // Deprecated: use WithImageName ContainerOption instead. Alternative reaper image ReaperOptions []ContainerOption // Deprecated: the reaper is configured at the properties level, for an entire test session AutoRemove bool // Deprecated: Use HostConfigModifier instead. If set to true, the container will be removed from the host when stopped AlwaysPullImage bool // Always pull image ImagePlatform string // ImagePlatform describes the platform which the image runs on. Binds []string // Deprecated: Use HostConfigModifier instead ShmSize int64 // Deprecated: Use [HostConfigModifier] instead. Amount of memory shared with the host (in bytes) CapAdd []string // Deprecated: Use HostConfigModifier instead. Add Linux capabilities CapDrop []string // Deprecated: Use HostConfigModifier instead. Drop Linux capabilities ConfigModifier func(*container.Config) // Modifier for the config before container creation HostConfigModifier func(*container.HostConfig) // Modifier for the host config before container creation EndpointSettingsModifier func(map[string]*network.EndpointSettings) // Modifier for the network settings before container creation LifecycleHooks []ContainerLifecycleHooks // define hooks to be executed during container lifecycle LogConsumerCfg *LogConsumerConfig // define the configuration for the log producer and its log consumers to follow the logs } // sessionID returns the session ID for the container request. func (c *ContainerRequest) sessionID() string { if sessionID := c.Labels[core.LabelSessionID]; sessionID != "" { return sessionID } return core.SessionID() } // containerOptions functional options for a container type containerOptions struct { ImageName string RegistryCredentials string // Deprecated: Testcontainers will detect registry credentials automatically } // Deprecated: it will be removed in the next major release // functional option for setting the reaper image type ContainerOption func(*containerOptions) // Deprecated: it will be removed in the next major release // WithImageName sets the reaper image name func WithImageName(imageName string) ContainerOption { return func(o *containerOptions) { o.ImageName = imageName } } // Deprecated: Testcontainers will detect registry credentials automatically, and it will be removed in the next major release // WithRegistryCredentials sets the reaper registry credentials func WithRegistryCredentials(registryCredentials string) ContainerOption { return func(o *containerOptions) { o.RegistryCredentials = registryCredentials } } // Validate ensures that the ContainerRequest does not have invalid parameters configured to it // ex. make sure you are not specifying both an image as well as a context func (c *ContainerRequest) Validate() error { validationMethods := []func() error{ c.validateContextAndImage, c.validateContextOrImageIsSpecified, c.validateMounts, } var err error for _, validationMethod := range validationMethods { err = validationMethod() if err != nil { return err } } return nil } // GetContext retrieve the build context for the request // Must be closed when no longer needed. func (c *ContainerRequest) GetContext() (io.Reader, error) { includes := []string{"."} if c.ContextArchive != nil { return c.ContextArchive, nil } // always pass context as absolute path abs, err := filepath.Abs(c.Context) if err != nil { return nil, fmt.Errorf("error getting absolute path: %w", err) } c.Context = abs dockerIgnoreExists, excluded, err := parseDockerIgnore(abs) if err != nil { return nil, err } if dockerIgnoreExists { // only add .dockerignore if it exists includes = append(includes, ".dockerignore") } includes = append(includes, c.GetDockerfile()) buildContext, err := archive.TarWithOptions( c.Context, &archive.TarOptions{ExcludePatterns: excluded, IncludeFiles: includes}, ) if err != nil { return nil, err } return buildContext, nil } // parseDockerIgnore returns if the file exists, the excluded files and an error if any func parseDockerIgnore(targetDir string) (bool, []string, error) { // based on https://github.com/docker/cli/blob/master/cli/command/image/build/dockerignore.go#L14 fileLocation := filepath.Join(targetDir, ".dockerignore") var excluded []string exists := false if f, openErr := os.Open(fileLocation); openErr == nil { defer f.Close() exists = true var err error excluded, err = ignorefile.ReadAll(f) if err != nil { return true, excluded, fmt.Errorf("error reading .dockerignore: %w", err) } } return exists, excluded, nil } // GetBuildArgs returns the env args to be used when creating from Dockerfile func (c *ContainerRequest) GetBuildArgs() map[string]*string { return c.BuildArgs } // GetDockerfile returns the Dockerfile from the ContainerRequest, defaults to "Dockerfile". // Sets FromDockerfile.Dockerfile to the default if blank. func (c *ContainerRequest) GetDockerfile() string { if c.Dockerfile == "" { c.Dockerfile = "Dockerfile" } return c.Dockerfile } // GetRepo returns the Repo label for image from the ContainerRequest, defaults to UUID. // Sets FromDockerfile.Repo to the default value if blank. func (c *ContainerRequest) GetRepo() string { if c.Repo == "" { c.Repo = uuid.NewString() } return strings.ToLower(c.Repo) } // GetTag returns the Tag label for image from the ContainerRequest, defaults to UUID. // Sets FromDockerfile.Tag to the default value if blank. func (c *ContainerRequest) GetTag() string { if c.Tag == "" { c.Tag = uuid.NewString() } return strings.ToLower(c.Tag) } // Deprecated: Testcontainers will detect registry credentials automatically, and it will be removed in the next major release. // GetAuthConfigs returns the auth configs to be able to pull from an authenticated docker registry. // Panics if an error occurs. func (c *ContainerRequest) GetAuthConfigs() map[string]registry.AuthConfig { auth, err := getAuthConfigsFromDockerfile(c) if err != nil { panic(fmt.Sprintf("failed to get auth configs from Dockerfile: %v", err)) } return auth } // dockerFileImages returns the images from the request Dockerfile. func (c *ContainerRequest) dockerFileImages() ([]string, error) { if c.ContextArchive == nil { // Source is a directory, we can read the Dockerfile directly. images, err := core.ExtractImagesFromDockerfile(filepath.Join(c.Context, c.GetDockerfile()), c.GetBuildArgs()) if err != nil { return nil, fmt.Errorf("extract images from Dockerfile: %w", err) } return images, nil } // Source is an archive, we need to read it to get the Dockerfile. dockerFile := c.GetDockerfile() tr := tar.NewReader(c.ContextArchive) for { hdr, err := tr.Next() if err != nil { if errors.Is(err, io.EOF) { return nil, fmt.Errorf("dockerfile %q not found in context archive", dockerFile) } return nil, fmt.Errorf("reading tar archive: %w", err) } if hdr.Name != dockerFile { continue } images, err := core.ExtractImagesFromReader(tr, c.GetBuildArgs()) if err != nil { return nil, fmt.Errorf("extract images from Dockerfile: %w", err) } // Reset the archive to the beginning. if _, err := c.ContextArchive.Seek(0, io.SeekStart); err != nil { return nil, fmt.Errorf("seek context archive to start: %w", err) } return images, nil } } // getAuthConfigsFromDockerfile returns the auth configs to be able to pull from an authenticated docker registry func getAuthConfigsFromDockerfile(c *ContainerRequest) (map[string]registry.AuthConfig, error) { images, err := c.dockerFileImages() if err != nil { return nil, fmt.Errorf("docker file images: %w", err) } // Get the auth configs once for all images as it can be a time-consuming operation. configs, err := getDockerAuthConfigs() if err != nil { return nil, err } authConfigs := map[string]registry.AuthConfig{} for _, image := range images { registry, authConfig, err := dockerImageAuth(context.Background(), image, configs) if err != nil { if !errors.Is(err, dockercfg.ErrCredentialsNotFound) { return nil, fmt.Errorf("docker image auth %q: %w", image, err) } // Credentials not found no config to add. continue } authConfigs[registry] = authConfig } return authConfigs, nil } func (c *ContainerRequest) ShouldBuildImage() bool { return c.Context != "" || c.ContextArchive != nil } func (c *ContainerRequest) ShouldKeepBuiltImage() bool { return c.KeepImage } // BuildLogWriter returns the io.Writer for output of log when building a Docker image from // a Dockerfile. It returns the BuildLogWriter from the ContainerRequest, defaults to io.Discard. // For backward compatibility, if BuildLogWriter is default and PrintBuildLog is true, // the function returns os.Stderr. // //nolint:staticcheck //FIXME func (c *ContainerRequest) BuildLogWriter() io.Writer { if c.FromDockerfile.BuildLogWriter != nil { return c.FromDockerfile.BuildLogWriter } if c.PrintBuildLog { c.FromDockerfile.BuildLogWriter = os.Stderr } else { c.FromDockerfile.BuildLogWriter = io.Discard } return c.FromDockerfile.BuildLogWriter } // BuildOptions returns the image build options when building a Docker image from a Dockerfile. // It will apply some defaults and finally call the BuildOptionsModifier from the FromDockerfile struct, // if set. func (c *ContainerRequest) BuildOptions() (build.ImageBuildOptions, error) { buildOptions := build.ImageBuildOptions{ Remove: true, ForceRemove: true, } if c.BuildOptionsModifier != nil { c.BuildOptionsModifier(&buildOptions) } // apply mandatory values after the modifier buildOptions.BuildArgs = c.GetBuildArgs() buildOptions.Dockerfile = c.GetDockerfile() // Make sure the auth configs from the Dockerfile are set right after the user-defined build options. authsFromDockerfile, err := getAuthConfigsFromDockerfile(c) if err != nil { return build.ImageBuildOptions{}, fmt.Errorf("auth configs from Dockerfile: %w", err) } if buildOptions.AuthConfigs == nil { buildOptions.AuthConfigs = map[string]registry.AuthConfig{} } maps.Copy(buildOptions.AuthConfigs, authsFromDockerfile) // make sure the first tag is the one defined in the ContainerRequest tag := fmt.Sprintf("%s:%s", c.GetRepo(), c.GetTag()) // apply substitutors to the built image for _, is := range c.ImageSubstitutors { modifiedTag, err := is.Substitute(tag) if err != nil { return build.ImageBuildOptions{}, fmt.Errorf("failed to substitute image %s with %s: %w", tag, is.Description(), err) } if modifiedTag != tag { log.Printf("✍🏼 Replacing image with %s. From: %s to %s\n", is.Description(), tag, modifiedTag) tag = modifiedTag } } if len(buildOptions.Tags) > 0 { // prepend the tag buildOptions.Tags = append([]string{tag}, buildOptions.Tags...) } else { buildOptions.Tags = []string{tag} } if !c.ShouldKeepBuiltImage() { dst := GenericLabels() if err = core.MergeCustomLabels(dst, c.Labels); err != nil { return build.ImageBuildOptions{}, err } if err = core.MergeCustomLabels(dst, buildOptions.Labels); err != nil { return build.ImageBuildOptions{}, err } buildOptions.Labels = dst } // Do this as late as possible to ensure we don't leak the context on error/panic. buildContext, err := c.GetContext() if err != nil { return build.ImageBuildOptions{}, err } buildOptions.Context = buildContext return buildOptions, nil } func (c *ContainerRequest) validateContextAndImage() error { if c.Context != "" && c.Image != "" { return errors.New("you cannot specify both an Image and Context in a ContainerRequest") } return nil } func (c *ContainerRequest) validateContextOrImageIsSpecified() error { if c.Context == "" && c.ContextArchive == nil && c.Image == "" { return errors.New("you must specify either a build context or an image") } return nil } // validateMounts ensures that the mounts do not have duplicate targets. // It will check the Mounts and HostConfigModifier.Binds fields. func (c *ContainerRequest) validateMounts() error { targets := make(map[string]bool, len(c.Mounts)) for idx := range c.Mounts { m := c.Mounts[idx] targetPath := m.Target.Target() if targets[targetPath] { return fmt.Errorf("%w: %s", ErrDuplicateMountTarget, targetPath) } targets[targetPath] = true } if c.HostConfigModifier == nil { return nil } hostConfig := container.HostConfig{} c.HostConfigModifier(&hostConfig) if len(hostConfig.Binds) > 0 { for _, bind := range hostConfig.Binds { parts := strings.Split(bind, ":") if len(parts) != 2 && len(parts) != 3 { return fmt.Errorf("%w: %s", ErrInvalidBindMount, bind) } targetPath := parts[1] if targets[targetPath] { return fmt.Errorf("%w: %s", ErrDuplicateMountTarget, targetPath) } targets[targetPath] = true } } return nil }