Initial QSfera import
This commit is contained in:
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user