diff --git a/go.mod b/go.mod index 0165308eac..06ecfbfc49 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.23.6 require ( dario.cat/mergo v1.0.0 + github.com/caarlos0/env/v11 v11.3.1 github.com/cenkalti/backoff/v4 v4.2.1 github.com/containerd/platforms v0.2.1 github.com/cpuguy83/dockercfg v0.3.2 diff --git a/go.sum b/go.sum index 8fba0707fa..1cbdd46e4c 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= diff --git a/internal/client/build_image.go b/internal/client/build_image.go new file mode 100644 index 0000000000..48c46daa05 --- /dev/null +++ b/internal/client/build_image.go @@ -0,0 +1,107 @@ +package client + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/docker/docker/api/types" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/moby/term" +) + +// buildOptions is a type that represents all options for building an image. +type buildOptions struct { + options types.ImageBuildOptions + logWriter io.Writer +} + +// LogWriter returns writer for build logs. +// Default: [io.Discard]. +func (bo buildOptions) LogWriter() io.Writer { + if bo.logWriter != nil { + return bo.logWriter + } + + return io.Discard +} + +// BuildOption is a type that represents an option for building an image. +type BuildOption func(*buildOptions) error + +// BuildOptions returns a build option that sets the options for building an image. +// TODO: Should we expose this or make options for each struct member? +func BuildOptions(options types.ImageBuildOptions) BuildOption { + return func(bo *buildOptions) error { + bo.options = options + return nil + } +} + +// BuildLogWriter returns a build option that sets the writer for the build logs. +func BuildLogWriter(w io.Writer) BuildOption { + return func(bo *buildOptions) error { + bo.logWriter = w + return nil + } +} + +// BuildImage builds an image from a build context with the specified options. +// If buildContext implements [io.Closer], it will be closed before returning. +// The first tag is returned if the build is successful. +func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options ...BuildOption) (string, error) { + defer func() { + // Clean up if necessary. + if rc, ok := buildContext.(io.Closer); ok { + rc.Close() + } + }() + + if err := c.initOnce(ctx); err != nil { + return "", fmt.Errorf("init: %w", err) + } + + var opts buildOptions + for _, opt := range options { + if err := opt(&opts); err != nil { + return "", err + } + } + + resp, err := backoff.RetryNotifyWithData( + func() (*types.ImageBuildResponse, error) { + resp, err := c.client.ImageBuild(ctx, buildContext, opts.options) + if err != nil { + if isPermanentClientError(err) { + return nil, backoff.Permanent(err) + } + + // Retryable error. + return nil, err + } + + return &resp, nil + }, + backoff.WithContext(backoff.NewExponentialBackOff(), ctx), + func(err error, _ time.Duration) { + c.log.DebugContext(ctx, "build image", "error", err) + }, + ) + if err != nil { + return "", fmt.Errorf("build image: %w", err) + } + defer resp.Body.Close() + + // Always process the output, even if it is not printed to ensure that errors + // during the build process are correctly handled. + output := opts.LogWriter() + termFd, isTerm := term.GetFdInfo(output) + if err = jsonmessage.DisplayJSONMessagesStream(resp.Body, output, termFd, isTerm, nil); err != nil { + return "", fmt.Errorf("build image: %w", err) + } + + // The first tag is the one we want. + return opts.options.Tags[0], nil +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000000..127a334c95 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,118 @@ +package client + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "sync" + + "github.com/docker/docker/client" + + "github.com/testcontainers/testcontainers-go/internal" + "github.com/testcontainers/testcontainers-go/internal/core" +) + +const ( + // Headers used for docker client requests. + headerProjectPath = "x-tc-pp" + headerSessionID = "x-tc-sid" + headerUserAgent = "User-Agent" + + // TLS certificate files. + tlsCACertFile = "ca.pem" + tlsCertFile = "cert.pem" + tlsKeyFile = "key.pem" +) + +// DefaultClient is the default client for interacting with containers. +var DefaultClient = &Client{} + +// Client is a type that represents a client for interacting with containers. +type Client struct { + log slog.Logger + + // mtx is a mutex for synchronizing access to the fields below. + mtx sync.RWMutex + client *client.Client + cfg *config + err error +} + +// ClientOption is a type that represents an option for configuring a client. +type ClientOption func(*Client) error + +// Logger returns a client option that sets the logger for the client. +func Logger(log slog.Logger) ClientOption { + return func(c *Client) error { + c.log = log + return nil + } +} + +// NewClient returns a new client for interacting with containers. +func NewClient(ctx context.Context, options ...ClientOption) (*Client, error) { + client := &Client{} + for _, opt := range options { + if err := opt(client); err != nil { + return nil, err + } + } + + if err := client.initOnce(ctx); err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + + return client, nil +} + +// initOnce initializes the client once. +// This method is safe for concurrent use by multiple goroutines. +func (c *Client) initOnce(ctx context.Context) error { + c.mtx.RLock() + if c.client != nil || c.err != nil { + err := c.err + c.mtx.RUnlock() + return err + } + c.mtx.RUnlock() + + c.mtx.Lock() + defer c.mtx.Unlock() + + if c.cfg, c.err = newConfig(); c.err != nil { + return c.err + } + + opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()} + + // TODO: handle internally / replace with context related code. + if dockerHost := core.MustExtractDockerHost(ctx); dockerHost != "" { + opts = append(opts, client.WithHost(dockerHost)) + } + + if c.cfg.TLSVerify { + // For further information see: + // https://docs.docker.com/engine/security/protect-access/#use-tls-https-to-protect-the-docker-daemon-socket + opts = append(opts, client.WithTLSClientConfig( + filepath.Join(c.cfg.CertPath, tlsCACertFile), + filepath.Join(c.cfg.CertPath, tlsCertFile), + filepath.Join(c.cfg.CertPath, tlsKeyFile), + )) + } + + opts = append(opts, client.WithHTTPHeaders( + map[string]string{ + headerProjectPath: core.ProjectPath(), + headerSessionID: core.SessionID(), + headerUserAgent: "tc-go/" + internal.Version, + }), + ) + + if c.client, c.err = client.NewClientWithOpts(opts...); c.err != nil { + c.err = fmt.Errorf("new client: %w", c.err) + return c.err + } + + return nil +} diff --git a/internal/client/config.go b/internal/client/config.go new file mode 100644 index 0000000000..8f7e9d5f20 --- /dev/null +++ b/internal/client/config.go @@ -0,0 +1,88 @@ +package client + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/caarlos0/env/v11" + "github.com/magiconair/properties" +) + +// config represents the configuration for Testcontainers. +// User values are read from ~/.testcontainers.properties file which can be overridden +// using the specified environment variables. For more information, see [Custom Configuration]. +// +// The Ryuk fields controls the [Garbage Collector] feature, which ensures that resources are +// cleaned up after the test execution. +// +// [Garbage Collector]: https://golang.testcontainers.org/features/garbage_collector/ +// [Custom Configuration]: https://golang.testcontainers.org/features/configuration/ +type config struct { // TODO: consider renaming adding default values to the struct fields. + // Host is the address of the Docker daemon. + Host string `properties:"docker.host" env:"DOCKER_HOST"` + + // TLSVerify is a flag to enable or disable TLS verification when connecting to a Docker daemon. + TLSVerify bool `properties:"docker.tls.verify" env:"DOCKER_TLS_VERIFY"` + + // CertPath is the path to the directory containing the Docker certificates. + // This is used when connecting to a Docker daemon over TLS. + CertPath string `properties:"docker.cert.path" env:"DOCKER_CERT_PATH"` + + // HubImageNamePrefix is the prefix used for the images pulled from the Docker Hub. + // This is useful when running tests in environments with restricted internet access. + HubImageNamePrefix string `properties:"hub.image.name.prefix" env:"TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX"` + + // TestcontainersHost is the address of the Testcontainers host. + TestcontainersHost string `properties:"tc.host" env:"TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"` + + // Ryuk is the configuration for the Garbage Collector. + Ryuk ryukConfig +} + +type ryukConfig struct { + // Disabled is a flag to enable or disable the Garbage Collector. + // Setting this to true will prevent testcontainers from automatically cleaning up + // resources, which is particularly important in tests which timeout as they + // don't run test clean up. + Disabled bool `properties:"ryuk.disabled" env:"TESTCONTAINERS_RYUK_DISABLED"` + + // Privileged is a flag to enable or disable the privileged mode for the Garbage Collector container. + // Setting this to true will run the Garbage Collector container in privileged mode. + Privileged bool `properties:"ryuk.container.privileged" env:"TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED"` + + // ReconnectionTimeout is the time to wait before attempting to reconnect to the Garbage Collector container. + ReconnectionTimeout time.Duration `properties:"ryuk.reconnection.timeout,default=10s" env:"TESTCONTAINERS_RYUK_RECONNECTION_TIMEOUT"` + + // ConnectionTimeout is the time to wait before timing out when connecting to the Garbage Collector container. + ConnectionTimeout time.Duration `properties:"ryuk.connection.timeout,default=1m" env:"TESTCONTAINERS_RYUK_CONNECTION_TIMEOUT"` + + // Verbose is a flag to enable or disable verbose logging for the Garbage Collector. + Verbose bool `properties:"ryuk.verbose" env:"TESTCONTAINERS_RYUK_VERBOSE"` +} + +// newConfig returns a new configuration loaded from the properties file +// located in the user's home directory and overridden by environment variables. +func newConfig() (*config, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("user home dir: %w", err) + } + + props, err := properties.LoadFiles([]string{filepath.Join(home, ".testcontainers.properties")}, properties.UTF8, true) + if err != nil { + return nil, fmt.Errorf("load properties file: %w", err) + } + + var cfg config + if err := props.Decode(&cfg); err != nil { + return nil, fmt.Errorf("decode properties: %w", err) + } + + if err := env.Parse(cfg); err != nil { + return nil, fmt.Errorf("parse env: %w", err) + } + + return &cfg, nil +} diff --git a/internal/client/errors.go b/internal/client/errors.go new file mode 100644 index 0000000000..de361b22d5 --- /dev/null +++ b/internal/client/errors.go @@ -0,0 +1,23 @@ +package client + +import ( + "github.com/docker/docker/errdefs" +) + +var permanentClientErrors = []func(error) bool{ + errdefs.IsNotFound, + errdefs.IsInvalidParameter, + errdefs.IsUnauthorized, + errdefs.IsForbidden, + errdefs.IsNotImplemented, + errdefs.IsSystem, +} + +func isPermanentClientError(err error) bool { + for _, isErrFn := range permanentClientErrors { + if isErrFn(err) { + return true + } + } + return false +}