Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
package wait
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"time"
)
// Implement interface
var (
_ Strategy = (*MultiStrategy)(nil)
_ StrategyTimeout = (*MultiStrategy)(nil)
)
type MultiStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
deadline *time.Duration
// additional properties
Strategies []Strategy
}
// WithStartupTimeoutDefault sets the default timeout for all inner wait strategies
func (ms *MultiStrategy) WithStartupTimeoutDefault(timeout time.Duration) *MultiStrategy {
ms.timeout = &timeout
return ms
}
// WithStartupTimeout sets a time.Duration which limits all wait strategies
//
// Deprecated: use WithDeadline
func (ms *MultiStrategy) WithStartupTimeout(timeout time.Duration) Strategy {
return ms.WithDeadline(timeout)
}
// WithDeadline sets a time.Duration which limits all wait strategies
func (ms *MultiStrategy) WithDeadline(deadline time.Duration) *MultiStrategy {
ms.deadline = &deadline
return ms
}
func ForAll(strategies ...Strategy) *MultiStrategy {
return &MultiStrategy{
Strategies: strategies,
}
}
func (ms *MultiStrategy) Timeout() *time.Duration {
return ms.timeout
}
// String returns a human-readable description of the wait strategy.
func (ms *MultiStrategy) String() string {
if len(ms.Strategies) == 0 {
return "all of: (none)"
}
var strategies []string
for _, strategy := range ms.Strategies {
if strategy == nil || reflect.ValueOf(strategy).IsNil() {
continue
}
if s, ok := strategy.(fmt.Stringer); ok {
strategies = append(strategies, s.String())
} else {
strategies = append(strategies, fmt.Sprintf("%T", strategy))
}
}
// Always include "all of:" prefix to make it clear this is a MultiStrategy
// even when there's only one strategy after filtering out nils
return "all of: [" + strings.Join(strategies, ", ") + "]"
}
func (ms *MultiStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
var cancel context.CancelFunc
if ms.deadline != nil {
ctx, cancel = context.WithTimeout(ctx, *ms.deadline)
defer cancel()
}
if len(ms.Strategies) == 0 {
return errors.New("no wait strategy supplied")
}
for _, strategy := range ms.Strategies {
if strategy == nil || reflect.ValueOf(strategy).IsNil() {
// A module could be appending strategies after part of the container initialization,
// and use wait.ForAll on a not initialized strategy.
// In this case, we just skip the nil strategy.
continue
}
strategyCtx := ctx
// Set default Timeout when strategy implements StrategyTimeout
if st, ok := strategy.(StrategyTimeout); ok {
if ms.Timeout() != nil && st.Timeout() == nil {
strategyCtx, cancel = context.WithTimeout(ctx, *ms.Timeout())
defer cancel()
}
}
err := strategy.WaitUntilReady(strategyCtx, target)
if err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,12 @@
//go:build !windows
package wait
import (
"errors"
"syscall"
)
func isConnRefusedErr(err error) bool {
return errors.Is(err, syscall.ECONNREFUSED)
}
@@ -0,0 +1,9 @@
package wait
import (
"golang.org/x/sys/windows"
)
func isConnRefusedErr(err error) bool {
return err == windows.WSAECONNREFUSED
}
+124
View File
@@ -0,0 +1,124 @@
package wait
import (
"context"
"fmt"
"io"
"time"
tcexec "github.com/testcontainers/testcontainers-go/exec"
)
// Implement interface
var (
_ Strategy = (*ExecStrategy)(nil)
_ StrategyTimeout = (*ExecStrategy)(nil)
)
type ExecStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
cmd []string
// additional properties
ExitCodeMatcher func(exitCode int) bool
ResponseMatcher func(body io.Reader) bool
PollInterval time.Duration
}
// NewExecStrategy constructs an Exec strategy ...
func NewExecStrategy(cmd []string) *ExecStrategy {
return &ExecStrategy{
cmd: cmd,
ExitCodeMatcher: defaultExitCodeMatcher,
ResponseMatcher: func(_ io.Reader) bool { return true },
PollInterval: defaultPollInterval(),
}
}
func defaultExitCodeMatcher(exitCode int) bool {
return exitCode == 0
}
// WithStartupTimeout can be used to change the default startup timeout
func (ws *ExecStrategy) WithStartupTimeout(startupTimeout time.Duration) *ExecStrategy {
ws.timeout = &startupTimeout
return ws
}
func (ws *ExecStrategy) WithExitCode(exitCode int) *ExecStrategy {
return ws.WithExitCodeMatcher(func(actualCode int) bool {
return actualCode == exitCode
})
}
func (ws *ExecStrategy) WithExitCodeMatcher(exitCodeMatcher func(exitCode int) bool) *ExecStrategy {
ws.ExitCodeMatcher = exitCodeMatcher
return ws
}
func (ws *ExecStrategy) WithResponseMatcher(matcher func(body io.Reader) bool) *ExecStrategy {
ws.ResponseMatcher = matcher
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *ExecStrategy) WithPollInterval(pollInterval time.Duration) *ExecStrategy {
ws.PollInterval = pollInterval
return ws
}
// ForExec is a convenience method to assign ExecStrategy
func ForExec(cmd []string) *ExecStrategy {
return NewExecStrategy(cmd)
}
func (ws *ExecStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *ExecStrategy) String() string {
if len(ws.cmd) == 0 {
return "exec command"
}
// Only show the command name and argument count to avoid exposing sensitive data
argCount := len(ws.cmd) - 1
if argCount == 0 {
return fmt.Sprintf("exec command %q", ws.cmd[0])
}
if argCount == 1 {
return fmt.Sprintf("exec command %q with 1 argument", ws.cmd[0])
}
return fmt.Sprintf("exec command %q with %d arguments", ws.cmd[0], argCount)
}
func (ws *ExecStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(ws.PollInterval):
exitCode, resp, err := target.Exec(ctx, ws.cmd, tcexec.Multiplexed())
if err != nil {
return err
}
if !ws.ExitCodeMatcher(exitCode) {
continue
}
if ws.ResponseMatcher != nil && !ws.ResponseMatcher(resp) {
continue
}
return nil
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package wait
import (
"context"
"strings"
"time"
)
// Implement interface
var (
_ Strategy = (*ExitStrategy)(nil)
_ StrategyTimeout = (*ExitStrategy)(nil)
)
// ExitStrategy will wait until container exit
type ExitStrategy struct {
// all Strategies should have a timeout to avoid waiting infinitely
timeout *time.Duration
// additional properties
PollInterval time.Duration
}
// NewExitStrategy constructs with polling interval of 100 milliseconds without timeout by default
func NewExitStrategy() *ExitStrategy {
return &ExitStrategy{
PollInterval: defaultPollInterval(),
}
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones
// WithExitTimeout can be used to change the default exit timeout
func (ws *ExitStrategy) WithExitTimeout(exitTimeout time.Duration) *ExitStrategy {
ws.timeout = &exitTimeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *ExitStrategy) WithPollInterval(pollInterval time.Duration) *ExitStrategy {
ws.PollInterval = pollInterval
return ws
}
// ForExit is the default construction for the fluid interface.
//
// For Example:
//
// wait.
// ForExit().
// WithPollInterval(1 * time.Second)
func ForExit() *ExitStrategy {
return NewExitStrategy()
}
func (ws *ExitStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *ExitStrategy) String() string {
return "container to exit"
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (ws *ExitStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
if ws.timeout != nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, *ws.timeout)
defer cancel()
}
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
state, err := target.State(ctx)
if err != nil {
if !strings.Contains(err.Error(), "No such container") {
return err
}
return nil
}
if state.Running {
time.Sleep(ws.PollInterval)
continue
}
return nil
}
}
}
+120
View File
@@ -0,0 +1,120 @@
package wait
import (
"context"
"fmt"
"io"
"time"
"github.com/containerd/errdefs"
)
var (
_ Strategy = (*FileStrategy)(nil)
_ StrategyTimeout = (*FileStrategy)(nil)
)
// FileStrategy waits for a file to exist in the container.
type FileStrategy struct {
timeout *time.Duration
file string
pollInterval time.Duration
matcher func(io.Reader) error
}
// NewFileStrategy constructs an FileStrategy strategy.
func NewFileStrategy(file string) *FileStrategy {
return &FileStrategy{
file: file,
pollInterval: defaultPollInterval(),
}
}
// WithStartupTimeout can be used to change the default startup timeout
func (ws *FileStrategy) WithStartupTimeout(startupTimeout time.Duration) *FileStrategy {
ws.timeout = &startupTimeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *FileStrategy) WithPollInterval(pollInterval time.Duration) *FileStrategy {
ws.pollInterval = pollInterval
return ws
}
// WithMatcher can be used to consume the file content.
// The matcher can return an errdefs.ErrNotFound to indicate that the file is not ready.
// Any other error will be considered a failure.
// Default: nil, will only wait for the file to exist.
func (ws *FileStrategy) WithMatcher(matcher func(io.Reader) error) *FileStrategy {
ws.matcher = matcher
return ws
}
// ForFile is a convenience method to assign FileStrategy
func ForFile(file string) *FileStrategy {
return NewFileStrategy(file)
}
// Timeout returns the timeout for the strategy
func (ws *FileStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *FileStrategy) String() string {
if ws.matcher != nil {
return fmt.Sprintf("file %q to exist and match condition", ws.file)
}
return fmt.Sprintf("file %q to exist", ws.file)
}
// WaitUntilReady waits until the file exists in the container and copies it to the target.
func (ws *FileStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
timer := time.NewTicker(ws.pollInterval)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
if err := ws.matchFile(ctx, target); err != nil {
if errdefs.IsNotFound(err) {
// Not found, continue polling.
continue
}
return fmt.Errorf("copy from container: %w", err)
}
return nil
}
}
}
// matchFile tries to copy the file from the container and match it.
func (ws *FileStrategy) matchFile(ctx context.Context, target StrategyTarget) error {
rc, err := target.CopyFileFromContainer(ctx, ws.file)
if err != nil {
return fmt.Errorf("copy from container: %w", err)
}
defer rc.Close()
if ws.matcher == nil {
// No matcher, just check if the file exists.
return nil
}
if err = ws.matcher(rc); err != nil {
return fmt.Errorf("matcher: %w", err)
}
return nil
}
@@ -0,0 +1,97 @@
package wait
import (
"context"
"time"
"github.com/moby/moby/api/types/container"
)
// Implement interface
var (
_ Strategy = (*HealthStrategy)(nil)
_ StrategyTimeout = (*HealthStrategy)(nil)
)
// HealthStrategy will wait until the container becomes healthy
type HealthStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
// additional properties
PollInterval time.Duration
}
// NewHealthStrategy constructs with polling interval of 100 milliseconds and startup timeout of 60 seconds by default
func NewHealthStrategy() *HealthStrategy {
return &HealthStrategy{
PollInterval: defaultPollInterval(),
}
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones like startupTimeout
// WithStartupTimeout can be used to change the default startup timeout
func (ws *HealthStrategy) WithStartupTimeout(startupTimeout time.Duration) *HealthStrategy {
ws.timeout = &startupTimeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *HealthStrategy) WithPollInterval(pollInterval time.Duration) *HealthStrategy {
ws.PollInterval = pollInterval
return ws
}
// ForHealthCheck is the default construction for the fluid interface.
//
// For Example:
//
// wait.
// ForHealthCheck().
// WithPollInterval(1 * time.Second)
func ForHealthCheck() *HealthStrategy {
return NewHealthStrategy()
}
func (ws *HealthStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *HealthStrategy) String() string {
return "container to become healthy"
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (ws *HealthStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
state, err := target.State(ctx)
if err != nil {
return err
}
if err := checkState(state); err != nil {
return err
}
if state.Health == nil || state.Health.Status != container.Healthy {
time.Sleep(ws.PollInterval)
continue
}
return nil
}
}
}
@@ -0,0 +1,321 @@
package wait
import (
"context"
"errors"
"fmt"
"net"
"os"
"time"
"github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go/log"
)
const (
exitEaccess = 126 // container cmd can't be invoked (permission denied)
exitCmdNotFound = 127 // container cmd not found/does not exist or invalid bind-mount
)
// Implement interface
var (
_ Strategy = (*HostPortStrategy)(nil)
_ StrategyTimeout = (*HostPortStrategy)(nil)
)
var (
errShellNotExecutable = errors.New("/bin/sh command not executable")
errShellNotFound = errors.New("/bin/sh command not found")
)
type HostPortStrategy struct {
// Port is a string containing port number and protocol in the format "80/tcp"
// which
Port string
// all WaitStrategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
PollInterval time.Duration
// skipInternalCheck is a flag to skip the internal check, which is useful when
// a shell is not available in the container or when the container doesn't bind
// the port internally until additional conditions are met.
skipInternalCheck bool
// skipExternalCheck is a flag to skip the external check, which, if used with
// skipInternalCheck, makes strategy waiting only for port mapping completion
// without accessing port.
skipExternalCheck bool
}
// NewHostPortStrategy constructs a default host port strategy that waits for the given
// port to be exposed. The default startup timeout is 60 seconds.
func NewHostPortStrategy(port string) *HostPortStrategy {
return &HostPortStrategy{
Port: port,
PollInterval: defaultPollInterval(),
}
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones like startupTimeout
// ForListeningPort returns a host port strategy that waits for the given port
// to be exposed and bound internally the container.
// Alias for `NewHostPortStrategy(port)`.
func ForListeningPort(port string) *HostPortStrategy {
return NewHostPortStrategy(port)
}
// ForExposedPort returns a host port strategy that waits for the first port
// to be exposed and bound internally the container.
func ForExposedPort() *HostPortStrategy {
return NewHostPortStrategy("")
}
// ForMappedPort returns a host port strategy that waits for the given port
// to be mapped without accessing the port itself.
func ForMappedPort(port string) *HostPortStrategy {
return NewHostPortStrategy(port).SkipInternalCheck().SkipExternalCheck()
}
// SkipInternalCheck changes the host port strategy to skip the internal check,
// which is useful when a shell is not available in the container or when the
// container doesn't bind the port internally until additional conditions are met.
func (hp *HostPortStrategy) SkipInternalCheck() *HostPortStrategy {
hp.skipInternalCheck = true
return hp
}
// SkipExternalCheck changes the host port strategy to skip the external check,
// which, if used with SkipInternalCheck, makes strategy waiting only for port
// mapping completion without accessing port.
func (hp *HostPortStrategy) SkipExternalCheck() *HostPortStrategy {
hp.skipExternalCheck = true
return hp
}
// WithStartupTimeout can be used to change the default startup timeout
func (hp *HostPortStrategy) WithStartupTimeout(startupTimeout time.Duration) *HostPortStrategy {
hp.timeout = &startupTimeout
return hp
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (hp *HostPortStrategy) WithPollInterval(pollInterval time.Duration) *HostPortStrategy {
hp.PollInterval = pollInterval
return hp
}
func (hp *HostPortStrategy) Timeout() *time.Duration {
return hp.timeout
}
// String returns a human-readable description of the wait strategy.
func (hp *HostPortStrategy) String() string {
port := "first exposed port"
if hp.Port != "" {
port = "port " + hp.Port
}
var checks string
switch {
case hp.skipInternalCheck && hp.skipExternalCheck:
checks = " to be mapped"
case hp.skipInternalCheck:
checks = " to be accessible externally"
case hp.skipExternalCheck:
checks = " to be listening internally"
default:
checks = " to be listening"
}
return fmt.Sprintf("%s%s", port, checks)
}
// detectInternalPort returns the lowest internal port that is currently bound.
// If no internal port is found, it returns the zero nat.Port value which
// can be checked against an empty string.
func (hp *HostPortStrategy) detectInternalPort(ctx context.Context, target StrategyTarget) (network.Port, error) {
var internalPort network.Port
inspect, err := target.Inspect(ctx)
if err != nil {
return internalPort, fmt.Errorf("inspect: %w", err)
}
for port := range inspect.NetworkSettings.Ports {
if internalPort.IsZero() || port.Num() < internalPort.Num() {
internalPort = port
}
}
return internalPort, nil
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (hp *HostPortStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if hp.timeout != nil {
timeout = *hp.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
waitInterval := hp.PollInterval
var internalPort network.Port
if hp.Port != "" {
p, err := network.ParsePort(hp.Port)
if err != nil {
return err
}
internalPort = p
}
i := 0
if internalPort.IsZero() {
var err error
// Port is not specified, so we need to detect it.
internalPort, err = hp.detectInternalPort(ctx, target)
if err != nil {
return fmt.Errorf("detect internal port: %w", err)
}
for internalPort.IsZero() {
select {
case <-ctx.Done():
return fmt.Errorf("detect internal port: retries: %d, last err: %w, ctx err: %w", i, err, ctx.Err())
case <-time.After(waitInterval):
if err := checkTarget(ctx, target); err != nil {
return fmt.Errorf("detect internal port: check target: retries: %d, last err: %w", i, err)
}
internalPort, err = hp.detectInternalPort(ctx, target)
if err != nil {
return fmt.Errorf("detect internal port: %w", err)
}
}
}
}
port, err := target.MappedPort(ctx, internalPort.String())
i = 0
for port.IsZero() {
i++
select {
case <-ctx.Done():
return fmt.Errorf("mapped port: retries: %d, port: %q, last err: %w, ctx err: %w", i, port, err, ctx.Err())
case <-time.After(waitInterval):
if err := checkTarget(ctx, target); err != nil {
return fmt.Errorf("mapped port: check target: retries: %d, port: %q, last err: %w", i, port, err)
}
port, err = target.MappedPort(ctx, internalPort.String())
if err != nil {
log.Printf("mapped port: retries: %d, port: %q, err: %s\n", i, port, err)
}
}
}
if !hp.skipExternalCheck {
ipAddress, err := target.Host(ctx)
if err != nil {
return fmt.Errorf("host: %w", err)
}
if err := externalCheck(ctx, ipAddress, port, target, waitInterval); err != nil {
return fmt.Errorf("external check: %w", err)
}
}
if hp.skipInternalCheck {
return nil
}
if err = internalCheck(ctx, internalPort, target); err != nil {
switch {
case errors.Is(err, errShellNotExecutable):
log.Printf("Shell not executable in container, only external port validated")
return nil
case errors.Is(err, errShellNotFound):
log.Printf("Shell not found in container")
return nil
default:
return fmt.Errorf("internal check: %w", err)
}
}
return nil
}
func externalCheck(ctx context.Context, ipAddress string, port network.Port, target StrategyTarget, waitInterval time.Duration) error {
proto := port.Proto()
dialer := net.Dialer{}
address := net.JoinHostPort(ipAddress, port.Port())
for i := 0; ; i++ {
if err := checkTarget(ctx, target); err != nil {
return fmt.Errorf("check target: retries: %d address: %s: %w", i, address, err)
}
conn, err := dialer.DialContext(ctx, string(proto), address)
if err != nil {
var v *net.OpError
if errors.As(err, &v) {
var v2 *os.SyscallError
if errors.As(v.Err, &v2) {
if isConnRefusedErr(v2.Err) {
time.Sleep(waitInterval)
continue
}
}
}
return fmt.Errorf("dial: %w", err)
}
_ = conn.Close()
return nil
}
}
func internalCheck(ctx context.Context, internalPort network.Port, target StrategyTarget) error {
command := buildInternalCheckCommand(internalPort.Num())
for {
if ctx.Err() != nil {
return ctx.Err()
}
if err := checkTarget(ctx, target); err != nil {
return err
}
exitCode, _, err := target.Exec(ctx, []string{"/bin/sh", "-c", command})
if err != nil {
return fmt.Errorf("%w, host port waiting failed", err)
}
// Docker has an issue which override exit code 127 to 126 due to:
// https://github.com/moby/moby/issues/45795
// Handle both to ensure compatibility with Docker and Podman for now.
switch exitCode {
case 0:
return nil
case exitEaccess:
return errShellNotExecutable
case exitCmdNotFound:
return errShellNotFound
}
}
}
func buildInternalCheckCommand(internalPort uint16) string {
command := `(
cat /proc/net/tcp* | awk '{print $2}' | grep -i :%04x ||
nc -vz -w 1 localhost %d ||
/bin/sh -c '</dev/tcp/localhost/%d'
)
`
return "true && " + fmt.Sprintf(command, internalPort, internalPort, internalPort)
}
+357
View File
@@ -0,0 +1,357 @@
package wait
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/moby/moby/api/types/network"
)
// Implement interface
var (
_ Strategy = (*HTTPStrategy)(nil)
_ StrategyTimeout = (*HTTPStrategy)(nil)
)
type HTTPStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
// additional properties
Port network.Port
Path string
StatusCodeMatcher func(status int) bool
ResponseMatcher func(body io.Reader) bool
UseTLS bool
AllowInsecure bool
TLSConfig *tls.Config // TLS config for HTTPS
Method string // http method
Body io.Reader // http request body
Headers map[string]string
ResponseHeadersMatcher func(headers http.Header) bool
PollInterval time.Duration
UserInfo *url.Userinfo
ForceIPv4LocalHost bool
}
// NewHTTPStrategy constructs an HTTP strategy waiting on port 80 and status code 200
func NewHTTPStrategy(path string) *HTTPStrategy {
return &HTTPStrategy{
Port: network.Port{},
Path: path,
StatusCodeMatcher: defaultStatusCodeMatcher,
ResponseMatcher: func(_ io.Reader) bool { return true },
UseTLS: false,
TLSConfig: nil,
Method: http.MethodGet,
Body: nil,
Headers: map[string]string{},
ResponseHeadersMatcher: func(_ http.Header) bool { return true },
PollInterval: defaultPollInterval(),
UserInfo: nil,
}
}
func defaultStatusCodeMatcher(status int) bool {
return status == http.StatusOK
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones like startupTimeout
// WithStartupTimeout can be used to change the default startup timeout
func (ws *HTTPStrategy) WithStartupTimeout(timeout time.Duration) *HTTPStrategy {
ws.timeout = &timeout
return ws
}
// WithPort set the port to wait for.
// Default is the lowest numbered port.
func (ws *HTTPStrategy) WithPort(port string) *HTTPStrategy {
if p, err := network.ParsePort(port); err == nil {
ws.Port = p
}
return ws
}
func (ws *HTTPStrategy) WithStatusCodeMatcher(statusCodeMatcher func(status int) bool) *HTTPStrategy {
ws.StatusCodeMatcher = statusCodeMatcher
return ws
}
func (ws *HTTPStrategy) WithResponseMatcher(matcher func(body io.Reader) bool) *HTTPStrategy {
ws.ResponseMatcher = matcher
return ws
}
func (ws *HTTPStrategy) WithTLS(useTLS bool, tlsconf ...*tls.Config) *HTTPStrategy {
ws.UseTLS = useTLS
if useTLS && len(tlsconf) > 0 {
ws.TLSConfig = tlsconf[0]
}
return ws
}
func (ws *HTTPStrategy) WithAllowInsecure(allowInsecure bool) *HTTPStrategy {
ws.AllowInsecure = allowInsecure
return ws
}
func (ws *HTTPStrategy) WithMethod(method string) *HTTPStrategy {
ws.Method = method
return ws
}
func (ws *HTTPStrategy) WithBody(reqdata io.Reader) *HTTPStrategy {
ws.Body = reqdata
return ws
}
func (ws *HTTPStrategy) WithHeaders(headers map[string]string) *HTTPStrategy {
ws.Headers = headers
return ws
}
func (ws *HTTPStrategy) WithResponseHeadersMatcher(matcher func(http.Header) bool) *HTTPStrategy {
ws.ResponseHeadersMatcher = matcher
return ws
}
func (ws *HTTPStrategy) WithBasicAuth(username, password string) *HTTPStrategy {
ws.UserInfo = url.UserPassword(username, password)
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *HTTPStrategy) WithPollInterval(pollInterval time.Duration) *HTTPStrategy {
ws.PollInterval = pollInterval
return ws
}
// WithForcedIPv4LocalHost forces usage of localhost to be ipv4 127.0.0.1
// to avoid ipv6 docker bugs https://github.com/moby/moby/issues/42442 https://github.com/moby/moby/issues/42375
func (ws *HTTPStrategy) WithForcedIPv4LocalHost() *HTTPStrategy {
ws.ForceIPv4LocalHost = true
return ws
}
// ForHTTP is a convenience method similar to Wait.java
// https://github.com/testcontainers/testcontainers-java/blob/1d85a3834bd937f80aad3a4cec249c027f31aeb4/core/src/main/java/org/testcontainers/containers/wait/strategy/Wait.java
func ForHTTP(path string) *HTTPStrategy {
return NewHTTPStrategy(path)
}
func (ws *HTTPStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *HTTPStrategy) String() string {
proto := "HTTP"
if ws.UseTLS {
proto = "HTTPS"
}
port := "default"
if !ws.Port.IsZero() {
port = ws.Port.Port()
}
return fmt.Sprintf("%s %s request on port %s path %q", proto, ws.Method, port, ws.Path)
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (ws *HTTPStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ipAddress, err := target.Host(ctx)
if err != nil {
return err
}
// to avoid ipv6 docker bugs https://github.com/moby/moby/issues/42442 https://github.com/moby/moby/issues/42375
if ws.ForceIPv4LocalHost {
ipAddress = strings.Replace(ipAddress, "localhost", "127.0.0.1", 1)
}
var mappedPort network.Port
if ws.Port.IsZero() {
// No specific port requested; inspect container to find lowest exposed TCP port.
// We wait one polling interval before we grab the ports
// otherwise they might not be bound yet on startup.
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(ws.PollInterval):
// Port should now be bound so just continue.
}
if err := checkTarget(ctx, target); err != nil {
return err
}
inspect, err := target.Inspect(ctx)
if err != nil {
return err
}
// Find the lowest numbered exposed tcp port.
var lowestPort network.Port
var hostPort string
for port, bindings := range inspect.NetworkSettings.Ports {
if len(bindings) == 0 || port.Proto() != "tcp" {
continue
}
if lowestPort.IsZero() || port.Num() < lowestPort.Num() {
lowestPort = port
hostPort = bindings[0].HostPort
}
}
if lowestPort.IsZero() {
return errors.New("no exposed tcp ports or mapped ports - cannot wait for status")
}
hPort, _ := strconv.ParseUint(hostPort, 10, 16)
mappedPort, _ = network.PortFrom(uint16(hPort), lowestPort.Proto())
} else {
// Specific port requested; use MappedPort to resolve it.
mappedPort, err = target.MappedPort(ctx, ws.Port.String())
for mappedPort.IsZero() {
select {
case <-ctx.Done():
return fmt.Errorf("%w: %w", ctx.Err(), err)
case <-time.After(ws.PollInterval):
if err := checkTarget(ctx, target); err != nil {
return err
}
mappedPort, err = target.MappedPort(ctx, ws.Port.String())
}
}
if mappedPort.Proto() != "tcp" {
return errors.New("cannot use HTTP client on non-TCP ports")
}
}
switch ws.Method {
case http.MethodGet, http.MethodHead, http.MethodPost,
http.MethodPut, http.MethodPatch, http.MethodDelete,
http.MethodConnect, http.MethodOptions, http.MethodTrace:
default:
if ws.Method != "" {
return fmt.Errorf("invalid http method %q", ws.Method)
}
ws.Method = http.MethodGet
}
tripper := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: ws.TLSConfig,
}
var proto string
if ws.UseTLS {
proto = "https"
if ws.AllowInsecure {
if ws.TLSConfig == nil {
tripper.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
} else {
ws.TLSConfig.InsecureSkipVerify = true
}
}
} else {
proto = "http"
}
client := http.Client{Transport: tripper, Timeout: time.Second}
address := net.JoinHostPort(ipAddress, mappedPort.Port())
endpoint, err := url.Parse(ws.Path)
if err != nil {
return err
}
endpoint.Scheme = proto
endpoint.Host = address
if ws.UserInfo != nil {
endpoint.User = ws.UserInfo
}
// cache the body into a byte-slice so that it can be iterated over multiple times
var body []byte
if ws.Body != nil {
body, err = io.ReadAll(ws.Body)
if err != nil {
return err
}
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(ws.PollInterval):
if err := checkTarget(ctx, target); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, ws.Method, endpoint.String(), bytes.NewReader(body))
if err != nil {
return err
}
for k, v := range ws.Headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
continue
}
if ws.StatusCodeMatcher != nil && !ws.StatusCodeMatcher(resp.StatusCode) {
_ = resp.Body.Close()
continue
}
if ws.ResponseMatcher != nil && !ws.ResponseMatcher(resp.Body) {
_ = resp.Body.Close()
continue
}
if ws.ResponseHeadersMatcher != nil && !ws.ResponseHeadersMatcher(resp.Header) {
_ = resp.Body.Close()
continue
}
if err := resp.Body.Close(); err != nil {
continue
}
return nil
}
}
}
+229
View File
@@ -0,0 +1,229 @@
package wait
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"regexp"
"time"
)
// Implement interface
var (
_ Strategy = (*LogStrategy)(nil)
_ StrategyTimeout = (*LogStrategy)(nil)
)
// PermanentError is a special error that will stop the wait and return an error.
type PermanentError struct {
err error
}
// Error implements the error interface.
func (e *PermanentError) Error() string {
return e.err.Error()
}
// NewPermanentError creates a new PermanentError.
func NewPermanentError(err error) *PermanentError {
return &PermanentError{err: err}
}
// LogStrategy will wait until a given log entry shows up in the docker logs
type LogStrategy struct {
// all Strategies should have a startupTimeout to avoid waiting infinitely
timeout *time.Duration
// additional properties
Log string
IsRegexp bool
Occurrence int
PollInterval time.Duration
// check is the function that will be called to check if the log entry is present.
check func([]byte) error
// submatchCallback is a callback that will be called with the sub matches of the regexp.
submatchCallback func(pattern string, matches [][][]byte) error
// re is the optional compiled regexp.
re *regexp.Regexp
// log byte slice version of [LogStrategy.Log] used for count checks.
log []byte
}
// NewLogStrategy constructs with polling interval of 100 milliseconds and startup timeout of 60 seconds by default
func NewLogStrategy(log string) *LogStrategy {
return &LogStrategy{
Log: log,
IsRegexp: false,
Occurrence: 1,
PollInterval: defaultPollInterval(),
}
}
// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones like startupTimeout
// AsRegexp can be used to change the default behavior of the log strategy to use regexp instead of plain text
func (ws *LogStrategy) AsRegexp() *LogStrategy {
ws.IsRegexp = true
return ws
}
// Submatch configures a function that will be called with the result of
// [regexp.Regexp.FindAllSubmatch], allowing the caller to process the results.
// If the callback returns nil, the strategy will be considered successful.
// Returning a [PermanentError] will stop the wait and return an error, otherwise
// it will retry until the timeout is reached.
// [LogStrategy.Occurrence] is ignored if this option is set.
func (ws *LogStrategy) Submatch(callback func(pattern string, matches [][][]byte) error) *LogStrategy {
ws.submatchCallback = callback
return ws
}
// WithStartupTimeout can be used to change the default startup timeout
func (ws *LogStrategy) WithStartupTimeout(timeout time.Duration) *LogStrategy {
ws.timeout = &timeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (ws *LogStrategy) WithPollInterval(pollInterval time.Duration) *LogStrategy {
ws.PollInterval = pollInterval
return ws
}
func (ws *LogStrategy) WithOccurrence(o int) *LogStrategy {
// the number of occurrence needs to be positive
if o <= 0 {
o = 1
}
ws.Occurrence = o
return ws
}
// ForLog is the default construction for the fluid interface.
//
// For Example:
//
// wait.
// ForLog("some text").
// WithPollInterval(1 * time.Second)
func ForLog(log string) *LogStrategy {
return NewLogStrategy(log)
}
func (ws *LogStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *LogStrategy) String() string {
logType := "log message"
if ws.IsRegexp {
logType = "log pattern"
}
occurrence := ""
if ws.Occurrence > 1 {
occurrence = fmt.Sprintf(" (occurrence: %d)", ws.Occurrence)
}
return fmt.Sprintf("%s %q%s", logType, ws.Log, occurrence)
}
// WaitUntilReady implements Strategy.WaitUntilReady
func (ws *LogStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if ws.timeout != nil {
timeout = *ws.timeout
}
switch {
case ws.submatchCallback != nil:
ws.re = regexp.MustCompile(ws.Log)
ws.check = ws.checkSubmatch
case ws.IsRegexp:
ws.re = regexp.MustCompile(ws.Log)
ws.check = ws.checkRegexp
default:
ws.log = []byte(ws.Log)
ws.check = ws.checkCount
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var lastLen int
var lastError error
for {
select {
case <-ctx.Done():
return errors.Join(lastError, ctx.Err())
default:
checkErr := checkTarget(ctx, target)
reader, err := target.Logs(ctx)
if err != nil {
// TODO: fix as this will wait for timeout if the logs are not available.
time.Sleep(ws.PollInterval)
continue
}
b, err := io.ReadAll(reader)
if err != nil {
// TODO: fix as this will wait for timeout if the logs are not readable.
time.Sleep(ws.PollInterval)
continue
}
if lastLen == len(b) && checkErr != nil {
// Log length hasn't changed so we're not making progress.
return checkErr
}
if err := ws.check(b); err != nil {
var errPermanent *PermanentError
if errors.As(err, &errPermanent) {
return err
}
lastError = err
lastLen = len(b)
time.Sleep(ws.PollInterval)
continue
}
return nil
}
}
}
// checkCount checks if the log entry is present in the logs using a string count.
func (ws *LogStrategy) checkCount(b []byte) error {
if count := bytes.Count(b, ws.log); count < ws.Occurrence {
return fmt.Errorf("%q matched %d times, expected %d", ws.Log, count, ws.Occurrence)
}
return nil
}
// checkRegexp checks if the log entry is present in the logs using a regexp count.
func (ws *LogStrategy) checkRegexp(b []byte) error {
if matches := ws.re.FindAll(b, -1); len(matches) < ws.Occurrence {
return fmt.Errorf("`%s` matched %d times, expected %d", ws.Log, len(matches), ws.Occurrence)
}
return nil
}
// checkSubmatch checks if the log entry is present in the logs using a regexp sub match callback.
func (ws *LogStrategy) checkSubmatch(b []byte) error {
return ws.submatchCallback(ws.Log, ws.re.FindAllSubmatch(b, -1))
}
+89
View File
@@ -0,0 +1,89 @@
package wait
import (
"context"
"io"
"time"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go/exec"
)
var (
_ Strategy = (*NopStrategy)(nil)
_ StrategyTimeout = (*NopStrategy)(nil)
)
type NopStrategy struct {
timeout *time.Duration
waitUntilReady func(context.Context, StrategyTarget) error
}
func ForNop(
waitUntilReady func(context.Context, StrategyTarget) error,
) *NopStrategy {
return &NopStrategy{
waitUntilReady: waitUntilReady,
}
}
func (ws *NopStrategy) Timeout() *time.Duration {
return ws.timeout
}
// String returns a human-readable description of the wait strategy.
func (ws *NopStrategy) String() string {
return "custom wait condition"
}
func (ws *NopStrategy) WithStartupTimeout(timeout time.Duration) *NopStrategy {
ws.timeout = &timeout
return ws
}
func (ws *NopStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
return ws.waitUntilReady(ctx, target)
}
type NopStrategyTarget struct {
ReaderCloser io.ReadCloser
ContainerState container.State
}
func (st NopStrategyTarget) Host(_ context.Context) (string, error) {
return "", nil
}
func (st NopStrategyTarget) Inspect(_ context.Context) (*container.InspectResponse, error) {
return nil, nil
}
// Deprecated: use Inspect instead
func (st NopStrategyTarget) Ports(_ context.Context) (network.PortMap, error) {
return nil, nil
}
func (st NopStrategyTarget) MappedPort(_ context.Context, n string) (network.Port, error) {
if n == "" {
return network.Port{}, nil
}
return network.ParsePort(n)
}
func (st NopStrategyTarget) Logs(_ context.Context) (io.ReadCloser, error) {
return st.ReaderCloser, nil
}
func (st NopStrategyTarget) Exec(_ context.Context, _ []string, _ ...exec.ProcessOption) (int, io.Reader, error) {
return 0, nil, nil
}
func (st NopStrategyTarget) State(_ context.Context) (*container.State, error) {
return &st.ContainerState, nil
}
func (st NopStrategyTarget) CopyFileFromContainer(context.Context, string) (io.ReadCloser, error) {
return st.ReaderCloser, nil
}
+136
View File
@@ -0,0 +1,136 @@
package wait
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/moby/moby/api/types/network"
)
var (
_ Strategy = (*waitForSQL)(nil)
_ StrategyTimeout = (*waitForSQL)(nil)
)
const defaultForSQLQuery = "SELECT 1"
// ForSQL constructs a new waitForSql strategy for the given driver
func ForSQL(port string, driver string, url func(host string, port string) string) *waitForSQL {
return &waitForSQL{
Port: port,
URL: url,
Driver: driver,
startupTimeout: defaultStartupTimeout(),
PollInterval: defaultPollInterval(),
query: defaultForSQLQuery,
}
}
type waitForSQL struct {
timeout *time.Duration
URL func(host string, port string) string
Driver string
Port string
startupTimeout time.Duration
PollInterval time.Duration
query string
}
// WithStartupTimeout can be used to change the default startup timeout
func (w *waitForSQL) WithStartupTimeout(timeout time.Duration) *waitForSQL {
w.timeout = &timeout
return w
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (w *waitForSQL) WithPollInterval(pollInterval time.Duration) *waitForSQL {
w.PollInterval = pollInterval
return w
}
// WithQuery can be used to override the default query used in the strategy.
func (w *waitForSQL) WithQuery(query string) *waitForSQL {
w.query = query
return w
}
func (w *waitForSQL) Timeout() *time.Duration {
return w.timeout
}
// String returns a human-readable description of the wait strategy.
func (w *waitForSQL) String() string {
port := "default"
if w.Port != "" {
p, err := network.ParsePort(w.Port)
if err == nil {
port = p.Port()
}
}
query := ""
if w.query != defaultForSQLQuery {
query = fmt.Sprintf(" with query %q", w.query)
}
return fmt.Sprintf("SQL database on port %s using driver %q%s", port, w.Driver, query)
}
// WaitUntilReady repeatedly tries to run "SELECT 1" or user defined query on the given port using sql and driver.
//
// If it doesn't succeed until the timeout value which defaults to 60 seconds, it will return an error.
func (w *waitForSQL) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
timeout := defaultStartupTimeout()
if w.timeout != nil {
timeout = *w.timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
host, err := target.Host(ctx)
if err != nil {
return err
}
ticker := time.NewTicker(w.PollInterval)
defer ticker.Stop()
var port network.Port
port, err = target.MappedPort(ctx, w.Port)
for port.IsZero() {
select {
case <-ctx.Done():
return fmt.Errorf("%w: %w", ctx.Err(), err)
case <-ticker.C:
if err := checkTarget(ctx, target); err != nil {
return err
}
port, err = target.MappedPort(ctx, w.Port)
}
}
db, err := sql.Open(w.Driver, w.URL(host, port.String()))
if err != nil {
return fmt.Errorf("sql.Open: %w", err)
}
defer db.Close()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := checkTarget(ctx, target); err != nil {
return err
}
if _, err := db.ExecContext(ctx, w.query); err != nil {
continue
}
return nil
}
}
}
+187
View File
@@ -0,0 +1,187 @@
package wait
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"strings"
"time"
)
// Validate we implement interface.
var _ Strategy = (*TLSStrategy)(nil)
// TLSStrategy is a strategy for handling TLS.
type TLSStrategy struct {
// General Settings.
timeout *time.Duration
pollInterval time.Duration
// Custom Settings.
certFiles *x509KeyPair
rootFiles []string
// State.
tlsConfig *tls.Config
}
// x509KeyPair is a pair of certificate and key files.
type x509KeyPair struct {
certPEMFile string
keyPEMFile string
}
// ForTLSCert returns a CertStrategy that will add a Certificate to the [tls.Config]
// constructed from PEM formatted certificate key file pair in the container.
func ForTLSCert(certPEMFile, keyPEMFile string) *TLSStrategy {
return &TLSStrategy{
certFiles: &x509KeyPair{
certPEMFile: certPEMFile,
keyPEMFile: keyPEMFile,
},
tlsConfig: &tls.Config{},
pollInterval: defaultPollInterval(),
}
}
// ForTLSRootCAs returns a CertStrategy that sets the root CAs for the [tls.Config]
// using the given PEM formatted files from the container.
func ForTLSRootCAs(pemFiles ...string) *TLSStrategy {
return &TLSStrategy{
rootFiles: pemFiles,
tlsConfig: &tls.Config{},
pollInterval: defaultPollInterval(),
}
}
// WithRootCAs sets the root CAs for the [tls.Config] using the given files from
// the container.
func (ws *TLSStrategy) WithRootCAs(files ...string) *TLSStrategy {
ws.rootFiles = files
return ws
}
// WithCert sets the [tls.Config] Certificates using the given files from the container.
func (ws *TLSStrategy) WithCert(certPEMFile, keyPEMFile string) *TLSStrategy {
ws.certFiles = &x509KeyPair{
certPEMFile: certPEMFile,
keyPEMFile: keyPEMFile,
}
return ws
}
// WithServerName sets the server for the [tls.Config].
func (ws *TLSStrategy) WithServerName(serverName string) *TLSStrategy {
ws.tlsConfig.ServerName = serverName
return ws
}
// WithStartupTimeout can be used to change the default startup timeout.
func (ws *TLSStrategy) WithStartupTimeout(startupTimeout time.Duration) *TLSStrategy {
ws.timeout = &startupTimeout
return ws
}
// WithPollInterval can be used to override the default polling interval of 100 milliseconds.
func (ws *TLSStrategy) WithPollInterval(pollInterval time.Duration) *TLSStrategy {
ws.pollInterval = pollInterval
return ws
}
// TLSConfig returns the TLS config once the strategy is ready.
// If the strategy is nil, it returns nil.
func (ws *TLSStrategy) TLSConfig() *tls.Config {
if ws == nil {
return nil
}
return ws.tlsConfig
}
// String returns a human-readable description of the wait strategy.
func (ws *TLSStrategy) String() string {
var parts []string
if len(ws.rootFiles) > 0 {
parts = append(parts, fmt.Sprintf("root CAs %v", ws.rootFiles))
}
if ws.certFiles != nil {
parts = append(parts, fmt.Sprintf("cert %q and key %q", ws.certFiles.certPEMFile, ws.certFiles.keyPEMFile))
}
if len(parts) == 0 {
return "TLS certificates"
}
return strings.Join(parts, " and ")
}
// WaitUntilReady implements the [Strategy] interface.
// It waits for the CA, client cert and key files to be available in the container and
// uses them to setup the TLS config.
func (ws *TLSStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
size := len(ws.rootFiles)
if ws.certFiles != nil {
size += 2
}
strategies := make([]Strategy, 0, size)
for _, file := range ws.rootFiles {
strategies = append(strategies,
ForFile(file).WithMatcher(func(r io.Reader) error {
buf, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("read CA cert file %q: %w", file, err)
}
if ws.tlsConfig.RootCAs == nil {
ws.tlsConfig.RootCAs = x509.NewCertPool()
}
if !ws.tlsConfig.RootCAs.AppendCertsFromPEM(buf) {
return fmt.Errorf("invalid CA cert file %q", file)
}
return nil
}).WithPollInterval(ws.pollInterval),
)
}
if ws.certFiles != nil {
var certPEMBlock []byte
strategies = append(strategies,
ForFile(ws.certFiles.certPEMFile).WithMatcher(func(r io.Reader) error {
var err error
if certPEMBlock, err = io.ReadAll(r); err != nil {
return fmt.Errorf("read certificate cert %q: %w", ws.certFiles.certPEMFile, err)
}
return nil
}).WithPollInterval(ws.pollInterval),
ForFile(ws.certFiles.keyPEMFile).WithMatcher(func(r io.Reader) error {
keyPEMBlock, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("read certificate key %q: %w", ws.certFiles.keyPEMFile, err)
}
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
if err != nil {
return fmt.Errorf("x509 key pair %q %q: %w", ws.certFiles.certPEMFile, ws.certFiles.keyPEMFile, err)
}
ws.tlsConfig.Certificates = []tls.Certificate{cert}
return nil
}).WithPollInterval(ws.pollInterval),
)
}
strategy := ForAll(strategies...)
if ws.timeout != nil {
strategy.WithStartupTimeout(*ws.timeout)
}
return strategy.WaitUntilReady(ctx, target)
}
+65
View File
@@ -0,0 +1,65 @@
package wait
import (
"context"
"errors"
"fmt"
"io"
"time"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go/exec"
)
// Strategy defines the basic interface for a Wait Strategy
type Strategy interface {
WaitUntilReady(context.Context, StrategyTarget) error
}
// StrategyTimeout allows MultiStrategy to configure a Strategy's Timeout
type StrategyTimeout interface {
Timeout() *time.Duration
}
type StrategyTarget interface {
Host(context.Context) (string, error)
Inspect(context.Context) (*container.InspectResponse, error)
Ports(ctx context.Context) (network.PortMap, error) // Deprecated: use Inspect instead
MappedPort(context.Context, string) (network.Port, error)
Logs(context.Context) (io.ReadCloser, error)
Exec(context.Context, []string, ...exec.ProcessOption) (int, io.Reader, error)
State(context.Context) (*container.State, error)
CopyFileFromContainer(ctx context.Context, filePath string) (io.ReadCloser, error)
}
func checkTarget(ctx context.Context, target StrategyTarget) error {
state, err := target.State(ctx)
if err != nil {
return fmt.Errorf("get state: %w", err)
}
return checkState(state)
}
func checkState(state *container.State) error {
switch {
case state.Running:
return nil
case state.OOMKilled:
return errors.New("container crashed with out-of-memory (OOMKilled)")
case state.Status == container.StateExited:
return fmt.Errorf("container exited with code %d", state.ExitCode)
default:
return fmt.Errorf("unexpected container status %q", state.Status)
}
}
func defaultStartupTimeout() time.Duration {
return 60 * time.Second
}
func defaultPollInterval() time.Duration {
return 100 * time.Millisecond
}
+81
View File
@@ -0,0 +1,81 @@
package wait
import (
"errors"
"slices"
)
var (
// ErrVisitStop is used as a return value from [VisitFunc] to stop the walk.
// It is not returned as an error by any function.
ErrVisitStop = errors.New("stop the walk")
// Deprecated: use [ErrVisitStop] instead.
VisitStop = ErrVisitStop
// ErrVisitRemove is used as a return value from [VisitFunc] to have the current node removed.
// It is not returned as an error by any function.
ErrVisitRemove = errors.New("remove this strategy")
// Deprecated: use [ErrVisitRemove] instead.
VisitRemove = ErrVisitRemove
)
// VisitFunc is a function that visits a strategy node.
// If it returns [ErrVisitStop], the walk stops.
// If it returns [ErrVisitRemove], the current node is removed.
type VisitFunc func(root Strategy) error
// Walk walks the strategies tree and calls the visit function for each node.
func Walk(root *Strategy, visit VisitFunc) error {
if root == nil {
return errors.New("root strategy is nil")
}
if err := walk(root, visit); err != nil {
if errors.Is(err, ErrVisitRemove) || errors.Is(err, ErrVisitStop) {
return nil
}
return err
}
return nil
}
// walk walks the strategies tree and calls the visit function for each node.
// It returns an error if the visit function returns an error.
func walk(root *Strategy, visit VisitFunc) error {
if *root == nil {
// No strategy.
return nil
}
// Allow the visit function to customize the behaviour of the walk before visiting the children.
if err := visit(*root); err != nil {
if errors.Is(err, ErrVisitRemove) {
*root = nil
}
return err
}
if s, ok := (*root).(*MultiStrategy); ok {
var i int
for range s.Strategies {
if err := walk(&s.Strategies[i], visit); err != nil {
if errors.Is(err, ErrVisitRemove) {
s.Strategies = slices.Delete(s.Strategies, i, i+1)
if errors.Is(err, VisitStop) {
return VisitStop
}
continue
}
return err
}
i++
}
}
return nil
}