Initial QSfera import
This commit is contained in:
+115
@@ -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
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package wait
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func isConnRefusedErr(err error) bool {
|
||||
return errors.Is(err, syscall.ECONNREFUSED)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package wait
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func isConnRefusedErr(err error) bool {
|
||||
return err == windows.WSAECONNREFUSED
|
||||
}
|
||||
+124
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+97
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+321
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user