Skip to content

Commit e1cd01f

Browse files
committed
feat: client proof of concept
This is a client proof of concept which contains basic config support alongside an example method BuildImage.
1 parent 2fffed1 commit e1cd01f

File tree

6 files changed

+339
-0
lines changed

6 files changed

+339
-0
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ toolchain go1.23.6
66

77
require (
88
dario.cat/mergo v1.0.0
9+
github.com/caarlos0/env/v11 v11.3.1
910
github.com/cenkalti/backoff/v4 v4.2.1
1011
github.com/containerd/platforms v0.2.1
1112
github.com/cpuguy83/dockercfg v0.3.2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
66
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
77
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
88
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
9+
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
10+
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
911
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
1012
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
1113
github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao=

internal/client/build_image.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package client
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"time"
8+
9+
"github.com/cenkalti/backoff/v4"
10+
"github.com/docker/docker/api/types"
11+
"github.com/docker/docker/pkg/jsonmessage"
12+
"github.com/moby/term"
13+
)
14+
15+
// buildOptions is a type that represents all options for building an image.
16+
type buildOptions struct {
17+
options types.ImageBuildOptions
18+
logWriter io.Writer
19+
}
20+
21+
// LogWriter returns writer for build logs.
22+
// Default: [io.Discard].
23+
func (bo buildOptions) LogWriter() io.Writer {
24+
if bo.logWriter != nil {
25+
return bo.logWriter
26+
}
27+
28+
return io.Discard
29+
}
30+
31+
// BuildOption is a type that represents an option for building an image.
32+
type BuildOption func(*buildOptions) error
33+
34+
// BuildOptions returns a build option that sets the options for building an image.
35+
// TODO: Should we expose this or make options for each struct member?
36+
func BuildOptions(options types.ImageBuildOptions) BuildOption {
37+
return func(bo *buildOptions) error {
38+
bo.options = options
39+
return nil
40+
}
41+
}
42+
43+
// BuildLogWriter returns a build option that sets the writer for the build logs.
44+
func BuildLogWriter(w io.Writer) BuildOption {
45+
return func(bo *buildOptions) error {
46+
bo.logWriter = w
47+
return nil
48+
}
49+
}
50+
51+
// BuildImage builds an image from a build context with the specified options.
52+
// If buildContext is an [io.Closer], it will be closed after the build is complete.
53+
// The first tag is returned if the build is successful.
54+
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options ...BuildOption) (string, error) {
55+
defer func() {
56+
// Clean up if necessary.
57+
if rc, ok := buildContext.(io.Closer); ok {
58+
rc.Close()
59+
}
60+
}()
61+
62+
if err := c.initOnce(ctx); err != nil {
63+
return "", fmt.Errorf("init: %w", err)
64+
}
65+
66+
var opts buildOptions
67+
for _, opt := range options {
68+
if err := opt(&opts); err != nil {
69+
return "", err
70+
}
71+
}
72+
73+
resp, err := backoff.RetryNotifyWithData(
74+
func() (*types.ImageBuildResponse, error) {
75+
resp, err := c.client.ImageBuild(ctx, buildContext, opts.options)
76+
if err != nil {
77+
if isPermanentClientError(err) {
78+
return nil, backoff.Permanent(err)
79+
}
80+
81+
// Retryable error.
82+
return nil, err
83+
}
84+
85+
return &resp, nil
86+
},
87+
backoff.WithContext(backoff.NewExponentialBackOff(), ctx),
88+
func(err error, _ time.Duration) {
89+
c.log.DebugContext(ctx, "build image", "error", err)
90+
},
91+
)
92+
if err != nil {
93+
return "", fmt.Errorf("build image: %w", err)
94+
}
95+
defer resp.Body.Close()
96+
97+
// Always process the output, even if it is not printed to ensure that errors
98+
// during the build process are correctly handled.
99+
output := opts.LogWriter()
100+
termFd, isTerm := term.GetFdInfo(output)
101+
if err = jsonmessage.DisplayJSONMessagesStream(resp.Body, output, termFd, isTerm, nil); err != nil {
102+
return "", fmt.Errorf("build image: %w", err)
103+
}
104+
105+
// The first tag is the one we want.
106+
return opts.options.Tags[0], nil
107+
}

internal/client/client.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package client
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"path/filepath"
8+
"sync"
9+
10+
"github.com/docker/docker/client"
11+
12+
"github.com/testcontainers/testcontainers-go/internal"
13+
"github.com/testcontainers/testcontainers-go/internal/core"
14+
)
15+
16+
const (
17+
// Headers used for docker client requests.
18+
headerProjectPath = "x-tc-pp"
19+
headerSessionID = "x-tc-sid"
20+
headerUserAgent = "User-Agent"
21+
22+
// TLS certificate files.
23+
tlsCACertFile = "ca.pem"
24+
tlsCertFile = "cert.pem"
25+
tlsKeyFile = "key.pem"
26+
)
27+
28+
// DefaultClient is the default client for interacting with containers.
29+
var DefaultClient = &Client{}
30+
31+
// Client is a type that represents a client for interacting with containers.
32+
type Client struct {
33+
log slog.Logger
34+
35+
// mtx is a mutex for synchronizing access to the fields below.
36+
mtx sync.RWMutex
37+
client *client.Client
38+
cfg *config
39+
err error
40+
}
41+
42+
// ClientOption is a type that represents an option for configuring a client.
43+
type ClientOption func(*Client) error
44+
45+
// Logger returns a client option that sets the logger for the client.
46+
func Logger(log slog.Logger) ClientOption {
47+
return func(c *Client) error {
48+
c.log = log
49+
return nil
50+
}
51+
}
52+
53+
// NewClient returns a new client for interacting with containers.
54+
func NewClient(ctx context.Context, options ...ClientOption) (*Client, error) {
55+
client := &Client{}
56+
for _, opt := range options {
57+
if err := opt(client); err != nil {
58+
return nil, err
59+
}
60+
}
61+
62+
if err := client.initOnce(ctx); err != nil {
63+
return nil, fmt.Errorf("load config: %w", err)
64+
}
65+
66+
return client, nil
67+
}
68+
69+
// initOnce initializes the client once.
70+
// This method is safe for concurrent use by multiple goroutines.
71+
func (c *Client) initOnce(ctx context.Context) error {
72+
c.mtx.RLock()
73+
if c.client != nil || c.err != nil {
74+
err := c.err
75+
c.mtx.RUnlock()
76+
return err
77+
}
78+
c.mtx.RUnlock()
79+
80+
c.mtx.Lock()
81+
defer c.mtx.Unlock()
82+
83+
if c.cfg, c.err = newConfig(); c.err != nil {
84+
return c.err
85+
}
86+
87+
opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()}
88+
89+
// TODO: handle internally / replace with context related code.
90+
if dockerHost := core.MustExtractDockerHost(ctx); dockerHost != "" {
91+
opts = append(opts, client.WithHost(dockerHost))
92+
}
93+
94+
if c.cfg.TLSVerify {
95+
// For further information see:
96+
// https://docs.docker.com/engine/security/protect-access/#use-tls-https-to-protect-the-docker-daemon-socket
97+
opts = append(opts, client.WithTLSClientConfig(
98+
filepath.Join(c.cfg.CertPath, tlsCACertFile),
99+
filepath.Join(c.cfg.CertPath, tlsCertFile),
100+
filepath.Join(c.cfg.CertPath, tlsKeyFile),
101+
))
102+
}
103+
104+
opts = append(opts, client.WithHTTPHeaders(
105+
map[string]string{
106+
headerProjectPath: core.ProjectPath(),
107+
headerSessionID: core.SessionID(),
108+
headerUserAgent: "tc-go/" + internal.Version,
109+
}),
110+
)
111+
112+
if c.client, c.err = client.NewClientWithOpts(opts...); c.err != nil {
113+
c.err = fmt.Errorf("new client: %w", c.err)
114+
return c.err
115+
}
116+
117+
return nil
118+
}

internal/client/config.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package client
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"time"
8+
9+
"github.com/caarlos0/env/v11"
10+
"github.com/magiconair/properties"
11+
)
12+
13+
// config represents the configuration for Testcontainers.
14+
// User values are read from ~/.testcontainers.properties file which can be overridden
15+
// using the specified environment variables. For more information, see [Custom Configuration].
16+
//
17+
// The Ryuk fields controls the [Garbage Collector] feature, which ensures that resources are
18+
// cleaned up after the test execution.
19+
//
20+
// [Garbage Collector]: https://golang.testcontainers.org/features/garbage_collector/
21+
// [Custom Configuration]: https://golang.testcontainers.org/features/configuration/
22+
type config struct { // TODO: consider renaming adding default values to the struct fields.
23+
// Host is the address of the Docker daemon.
24+
Host string `properties:"docker.host" env:"DOCKER_HOST"`
25+
26+
// TLSVerify is a flag to enable or disable TLS verification when connecting to a Docker daemon.
27+
TLSVerify bool `properties:"docker.tls.verify" env:"DOCKER_TLS_VERIFY"`
28+
29+
// CertPath is the path to the directory containing the Docker certificates.
30+
// This is used when connecting to a Docker daemon over TLS.
31+
CertPath string `properties:"docker.cert.path" env:"DOCKER_CERT_PATH"`
32+
33+
// HubImageNamePrefix is the prefix used for the images pulled from the Docker Hub.
34+
// This is useful when running tests in environments with restricted internet access.
35+
HubImageNamePrefix string `properties:"hub.image.name.prefix" env:"TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX"`
36+
37+
// TestcontainersHost is the address of the Testcontainers host.
38+
TestcontainersHost string `properties:"tc.host" env:"TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"`
39+
40+
// Ryuk is the configuration for the Garbage Collector.
41+
Ryuk ryukConfig
42+
}
43+
44+
type ryukConfig struct {
45+
// Disabled is a flag to enable or disable the Garbage Collector.
46+
// Setting this to true will prevent testcontainers from automatically cleaning up
47+
// resources, which is particularly important in tests which timeout as they
48+
// don't run test clean up.
49+
Disabled bool `properties:"ryuk.disabled" env:"TESTCONTAINERS_RYUK_DISABLED"`
50+
51+
// Privileged is a flag to enable or disable the privileged mode for the Garbage Collector container.
52+
// Setting this to true will run the Garbage Collector container in privileged mode.
53+
Privileged bool `properties:"ryuk.container.privileged" env:"TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED"`
54+
55+
// ReconnectionTimeout is the time to wait before attempting to reconnect to the Garbage Collector container.
56+
ReconnectionTimeout time.Duration `properties:"ryuk.reconnection.timeout,default=10s" env:"TESTCONTAINERS_RYUK_RECONNECTION_TIMEOUT"`
57+
58+
// ConnectionTimeout is the time to wait before timing out when connecting to the Garbage Collector container.
59+
ConnectionTimeout time.Duration `properties:"ryuk.connection.timeout,default=1m" env:"TESTCONTAINERS_RYUK_CONNECTION_TIMEOUT"`
60+
61+
// Verbose is a flag to enable or disable verbose logging for the Garbage Collector.
62+
Verbose bool `properties:"ryuk.verbose" env:"TESTCONTAINERS_RYUK_VERBOSE"`
63+
}
64+
65+
// newConfig returns a new configuration loaded from the properties file
66+
// located in the user's home directory and overridden by environment variables.
67+
func newConfig() (*config, error) {
68+
home, err := os.UserHomeDir()
69+
if err != nil {
70+
return nil, fmt.Errorf("user home dir: %w", err)
71+
}
72+
73+
props, err := properties.LoadFiles([]string{filepath.Join(home, ".testcontainers.properties")}, properties.UTF8, true)
74+
if err != nil {
75+
return nil, fmt.Errorf("load properties file: %w", err)
76+
}
77+
78+
var cfg config
79+
if err := props.Decode(&cfg); err != nil {
80+
return nil, fmt.Errorf("decode properties: %w", err)
81+
}
82+
83+
if err := env.Parse(cfg); err != nil {
84+
return nil, fmt.Errorf("parse env: %w", err)
85+
}
86+
87+
return &cfg, nil
88+
}

internal/client/errors.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package client
2+
3+
import (
4+
"github.com/docker/docker/errdefs"
5+
)
6+
7+
var permanentClientErrors = []func(error) bool{
8+
errdefs.IsNotFound,
9+
errdefs.IsInvalidParameter,
10+
errdefs.IsUnauthorized,
11+
errdefs.IsForbidden,
12+
errdefs.IsNotImplemented,
13+
errdefs.IsSystem,
14+
}
15+
16+
func isPermanentClientError(err error) bool {
17+
for _, isErrFn := range permanentClientErrors {
18+
if isErrFn(err) {
19+
return true
20+
}
21+
}
22+
return false
23+
}

0 commit comments

Comments
 (0)