diff --git a/api/datacontext/attrs/httpcfgattr/attr.go b/api/datacontext/attrs/httpcfgattr/attr.go new file mode 100644 index 0000000000..0335fd8aae --- /dev/null +++ b/api/datacontext/attrs/httpcfgattr/attr.go @@ -0,0 +1,102 @@ +package httpcfgattr + +import ( + "github.com/mandelsoft/goutils/errors" + + "ocm.software/ocm/api/datacontext" + "ocm.software/ocm/api/utils/runtime" +) + +type ( + Context = datacontext.AttributesContext + ContextProvider = datacontext.ContextProvider +) + +const ( + ATTR_KEY = "ocm.software/ocm/api/datacontext/attrs/httptimeout" + ATTR_SHORT = "httpcfg" +) + +func init() { + datacontext.RegisterAttributeType(ATTR_KEY, AttributeType{}, ATTR_SHORT) +} + +type AttributeType struct{} + +func (a AttributeType) Name() string { + return ATTR_KEY +} + +func (a AttributeType) Description() string { + return ` +*JSON* +Configures HTTP client timeout settings for OCI registry and remote endpoint access. +Settings are provided as a JSON document matching the ` + ConfigType + ` config type. + +For full control use the config file: +
+ type: ` + ConfigType + ` + timeout: 0s + tcpDialTimeout: 30s + tcpKeepAlive: 30s + tlsHandshakeTimeout: 10s + idleConnTimeout: 90s ++` +} + +func (a AttributeType) Encode(v interface{}, marshaller runtime.Marshaler) ([]byte, error) { + attr, ok := v.(*Attribute) + if !ok { + return nil, errors.ErrInvalid("httpcfg attribute") + } + cfg := New() + cfg.HTTPSettings = attr.settings + + return marshaller.Marshal(cfg) +} + +func (a AttributeType) Decode(data []byte, unmarshaller runtime.Unmarshaler) (interface{}, error) { + var value Config + err := unmarshaller.Unmarshal(data, &value) + if err != nil { + return nil, err + } + + attr := &Attribute{} + err = value.ApplyToAttribute(attr) + if err != nil { + return nil, err + } + return attr, nil +} + +//////////////////////////////////////////////////////////////////////////////// + +// Attribute holds the effective HTTP client settings for a context. +type Attribute struct { + settings HTTPSettings +} + +// GetHTTPSettings returns a pointer to the effective HTTP settings. +func (a *Attribute) GetHTTPSettings() *HTTPSettings { + if a == nil { + return &HTTPSettings{} + } + return &a.settings +} + +//////////////////////////////////////////////////////////////////////////////// + +// Get returns the HTTP client attribute from the context. +// If not set, a default empty Attribute is created and stored. +func Get(ctx ContextProvider) *Attribute { + return ctx.AttributesContext().GetAttributes().GetOrCreateAttribute(ATTR_KEY, func(datacontext.Context) interface{} { + return &Attribute{} + }).(*Attribute) +} + +// Set stores the HTTP client attribute in the context. +func Set(ctx ContextProvider, attr *Attribute) error { + return ctx.AttributesContext().GetAttributes().SetAttribute(ATTR_KEY, attr) +} diff --git a/api/datacontext/attrs/httpcfgattr/attr_test.go b/api/datacontext/attrs/httpcfgattr/attr_test.go new file mode 100644 index 0000000000..d85fc5bdc5 --- /dev/null +++ b/api/datacontext/attrs/httpcfgattr/attr_test.go @@ -0,0 +1,72 @@ +package httpcfgattr_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "ocm.software/ocm/api/datacontext" + "ocm.software/ocm/api/datacontext/attrs/httpcfgattr" + "ocm.software/ocm/api/utils/runtime" +) + +var _ = Describe("httpcfg attribute", func() { + var ctx datacontext.Context + attr := httpcfgattr.AttributeType{} + enc := runtime.DefaultJSONEncoding + + BeforeEach(func() { + ctx = datacontext.New(nil) + }) + + Context("get and set", func() { + It("defaults to empty settings with no timeout", func() { + a := httpcfgattr.Get(ctx) + Expect(a).NotTo(BeNil()) + Expect(a.GetHTTPSettings().GetTimeout()).To(Equal(time.Duration(0))) + }) + + It("round-trips through encode/decode", func() { + a := httpcfgattr.Get(ctx) + cfg := httpcfgattr.NewConfig(30 * time.Second) + cfg.ApplyToAttribute(a) + + Expect(httpcfgattr.Get(ctx).GetHTTPSettings().GetTimeout()).To(Equal(30 * time.Second)) + }) + }) + + Context("encoding", func() { + It("encodes *Attribute to JSON", func() { + a := httpcfgattr.Get(ctx) + cfg := httpcfgattr.NewConfig(30 * time.Second) + cfg.ApplyToAttribute(a) + + data, err := attr.Encode(a, enc) + Expect(err).To(Succeed()) + Expect(string(data)).To(ContainSubstring(`"timeout":"30s"`)) + }) + + It("rejects non-*Attribute input", func() { + _, err := attr.Encode("invalid", enc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("is invalid")) + }) + }) + + Context("decoding", func() { + It("decodes JSON to *Attribute", func() { + raw := []byte(`{"type":"http.config.ocm.software/v1alpha1","timeout":"10s"}`) + val, err := attr.Decode(raw, enc) + Expect(err).To(Succeed()) + a, ok := val.(*httpcfgattr.Attribute) + Expect(ok).To(BeTrue()) + Expect(a.GetHTTPSettings().GetTimeout()).To(Equal(10 * time.Second)) + }) + + It("rejects invalid JSON", func() { + _, err := attr.Decode([]byte(`{invalid`), enc) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/api/datacontext/attrs/httpcfgattr/config.go b/api/datacontext/attrs/httpcfgattr/config.go new file mode 100644 index 0000000000..4cf93d6b9d --- /dev/null +++ b/api/datacontext/attrs/httpcfgattr/config.go @@ -0,0 +1,175 @@ +package httpcfgattr + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/mandelsoft/goutils/errors" + + cfgcpi "ocm.software/ocm/api/config/cpi" + "ocm.software/ocm/api/utils/runtime" +) + +const ( + ConfigType = "http" + cfgcpi.OCM_CONFIG_TYPE_SUFFIX + ConfigTypeV1Alpha1 = ConfigType + runtime.VersionSeparator + "v1alpha1" +) + +func init() { + cfgcpi.RegisterConfigType(cfgcpi.NewConfigType[*Config](ConfigType, usage)) + cfgcpi.RegisterConfigType(cfgcpi.NewConfigType[*Config](ConfigTypeV1Alpha1, usage)) +} + +// Duration is a string type representing a Go duration (e.g. "30s", "5m"). +// It is validated on JSON unmarshaling. +type Duration string + +// UnmarshalJSON implements the json.Unmarshaller interface. +func (d *Duration) UnmarshalJSON(b []byte) error { + var str string + if err := json.Unmarshal(b, &str); err != nil { + return err + } + if _, err := time.ParseDuration(str); err != nil { + return fmt.Errorf("invalid duration: %s", str) + } + *d = Duration(str) + return nil +} + +// TimeDuration parses the Duration string and returns a time.Duration. +// Returns 0 if the string is empty or invalid. +func (d *Duration) TimeDuration() time.Duration { + pd, _ := time.ParseDuration(string(*d)) + return pd +} + +// NewDuration creates a pointer to a Duration. +func NewDuration(d time.Duration) *Duration { + v := Duration(d.String()) + return &v +} + +// HTTPSettings contains the timeout settings for HTTP clients. +// All timeout values use Duration (Go duration strings in config). +// If not set (nil), the http.DefaultTransport value from the Go +// standard library is used. +// +// Note: Timeout controls the overall http.Client deadline and is +// independent of the transport-level timeouts below. Setting Timeout +// alone does NOT override TCPDialTimeout, TLSHandshakeTimeout, etc. +type HTTPSettings struct { + // Timeout is the overall http.Client timeout — the maximum duration + // for the entire request including connection, TLS, headers, and body. + // It does NOT serve as a fallback for transport-level timeouts. + // If not set, http.Client uses no timeout (0). + Timeout *Duration `json:"timeout,omitempty"` + + // TCPDialTimeout is the time limit for establishing a TCP connection. + TCPDialTimeout *Duration `json:"tcpDialTimeout,omitempty"` + + // TCPKeepAlive is the interval between TCP keep-alive probes. + TCPKeepAlive *Duration `json:"tcpKeepAlive,omitempty"` + + // TLSHandshakeTimeout is the maximum time to wait for a TLS handshake. + TLSHandshakeTimeout *Duration `json:"tlsHandshakeTimeout,omitempty"` + + // ResponseHeaderTimeout is the time limit to wait for response headers. + ResponseHeaderTimeout *Duration `json:"responseHeaderTimeout,omitempty"` + + // IdleConnTimeout is the maximum time an idle connection remains open. + IdleConnTimeout *Duration `json:"idleConnTimeout,omitempty"` +} + +// GetTimeout returns the overall HTTP client timeout. +// Returns 0 (disabled) if not set. +func (s *HTTPSettings) GetTimeout() time.Duration { + if s == nil || s.Timeout == nil { + return 0 + } + return s.Timeout.TimeDuration() +} + +// Config describes the configuration for HTTP client settings. +type Config struct { + runtime.ObjectVersionedType `json:",inline"` + HTTPSettings `json:",inline"` +} + +// New creates a new empty HTTP Config. +func New() *Config { + return &Config{ + ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType), + } +} + +func (a *Config) GetType() string { + return ConfigType +} + +// NewConfig creates a new HTTP config with the given overall timeout. +func NewConfig(timeout time.Duration) *Config { + return &Config{ + ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType), + HTTPSettings: HTTPSettings{ + Timeout: NewDuration(timeout), + }, + } +} + +func (a *Config) ApplyTo(ctx cfgcpi.Context, target interface{}) error { + if t, ok := target.(Context); ok { + if t.AttributesContext().IsAttributesContext() { // apply only to root context + return errors.Wrapf(a.ApplyToAttribute(Get(t)), "applying config failed") + } + } + return cfgcpi.ErrNoContext(ConfigType) +} + +// ApplyToAttribute merges this config's settings into an existing attribute. +func (a *Config) ApplyToAttribute(attr *Attribute) error { + s := &attr.settings + if a.Timeout != nil { + s.Timeout = a.Timeout + } + if a.TCPDialTimeout != nil { + s.TCPDialTimeout = a.TCPDialTimeout + } + if a.TCPKeepAlive != nil { + s.TCPKeepAlive = a.TCPKeepAlive + } + if a.TLSHandshakeTimeout != nil { + s.TLSHandshakeTimeout = a.TLSHandshakeTimeout + } + if a.ResponseHeaderTimeout != nil { + s.ResponseHeaderTimeout = a.ResponseHeaderTimeout + } + if a.IdleConnTimeout != nil { + s.IdleConnTimeout = a.IdleConnTimeout + } + return nil +} + +const usage = ` +The config type
` + ConfigType + ` can be used to configure
+HTTP client settings:
+
++ type: ` + ConfigType + ` + timeout: 0s + tcpDialTimeout: 30s + tcpKeepAlive: 30s + tlsHandshakeTimeout: 10s + responseHeaderTimeout: 0s + idleConnTimeout: 90s ++ +All timeout values are Go duration strings (e.g. "30s", "5m", "1h"). +Use "0s" to disable a specific timeout. If not set, the
http.DefaultTransport
+values from the Go standard library are used.
+
+Note: timeout controls the overall http.Client request deadline and is
+independent of the transport-level settings. Setting only timeout does not
+affect tcpDialTimeout, tlsHandshakeTimeout, or other transport timeouts.
+`
diff --git a/api/datacontext/attrs/httpcfgattr/config_test.go b/api/datacontext/attrs/httpcfgattr/config_test.go
new file mode 100644
index 0000000000..056760cb4f
--- /dev/null
+++ b/api/datacontext/attrs/httpcfgattr/config_test.go
@@ -0,0 +1,148 @@
+package httpcfgattr_test
+
+import (
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "ocm.software/ocm/api/config"
+ "ocm.software/ocm/api/credentials"
+ "ocm.software/ocm/api/datacontext"
+ "ocm.software/ocm/api/datacontext/attrs/httpcfgattr"
+)
+
+var _ = Describe("http config type", func() {
+ var ctx config.Context
+
+ BeforeEach(func() {
+ ctx = config.WithSharedAttributes(datacontext.New(nil)).New()
+ })
+
+ It("applies timeout via ApplyConfig", func() {
+ cfg := httpcfgattr.NewConfig(5 * time.Minute)
+ Expect(ctx.ApplyConfig(cfg, "test")).To(Succeed())
+
+ ocmCtx := credentials.WithConfigs(ctx).New()
+ Expect(httpcfgattr.Get(ocmCtx).GetHTTPSettings().GetTimeout()).To(Equal(5 * time.Minute))
+ })
+
+ It("parses all fields from JSON config", func() {
+ raw := []byte(`{"type":"http.config.ocm.software/v1alpha1","timeout":"10s","tcpDialTimeout":"15s","tcpKeepAlive":"20s","tlsHandshakeTimeout":"5s","responseHeaderTimeout":"8s","idleConnTimeout":"45s"}`)
+ cfg, err := ctx.GetConfigForData(raw, nil)
+ Expect(err).To(Succeed())
+ Expect(ctx.ApplyConfig(cfg, "config file")).To(Succeed())
+
+ ocmCtx := credentials.WithConfigs(ctx).New()
+ g := httpcfgattr.Get(ocmCtx).GetHTTPSettings()
+ Expect(g.GetTimeout()).To(Equal(10 * time.Second))
+ Expect(g.TCPDialTimeout.TimeDuration()).To(Equal(15 * time.Second))
+ Expect(g.TCPKeepAlive.TimeDuration()).To(Equal(20 * time.Second))
+ Expect(g.TLSHandshakeTimeout.TimeDuration()).To(Equal(5 * time.Second))
+ Expect(g.ResponseHeaderTimeout.TimeDuration()).To(Equal(8 * time.Second))
+ Expect(g.IdleConnTimeout.TimeDuration()).To(Equal(45 * time.Second))
+ })
+
+ It("parses all fields from YAML config", func() {
+ raw := []byte(`
+type: http.config.ocm.software/v1alpha1
+timeout: "0s"
+tcpDialTimeout: "30s"
+tcpKeepAlive: "30s"
+tlsHandshakeTimeout: "10s"
+responseHeaderTimeout: "10s"
+idleConnTimeout: "90s"
+`)
+ cfg, err := ctx.GetConfigForData(raw, nil)
+ Expect(err).To(Succeed())
+ Expect(ctx.ApplyConfig(cfg, "yaml config file")).To(Succeed())
+
+ ocmCtx := credentials.WithConfigs(ctx).New()
+ g := httpcfgattr.Get(ocmCtx).GetHTTPSettings()
+ Expect(g.GetTimeout()).To(Equal(time.Duration(0)))
+ Expect(g.TCPDialTimeout.TimeDuration()).To(Equal(30 * time.Second))
+ Expect(g.TCPKeepAlive.TimeDuration()).To(Equal(30 * time.Second))
+ Expect(g.TLSHandshakeTimeout.TimeDuration()).To(Equal(10 * time.Second))
+ Expect(g.ResponseHeaderTimeout.TimeDuration()).To(Equal(10 * time.Second))
+ Expect(g.IdleConnTimeout.TimeDuration()).To(Equal(90 * time.Second))
+ })
+
+ It("successive ApplyConfig overrides earlier values", func() {
+ raw := []byte(`{"type":"http.config.ocm.software/v1alpha1","timeout":"10s"}`)
+ cfg, err := ctx.GetConfigForData(raw, nil)
+ Expect(err).To(Succeed())
+ Expect(ctx.ApplyConfig(cfg, "config file")).To(Succeed())
+
+ override := httpcfgattr.NewConfig(2 * time.Minute)
+ Expect(ctx.ApplyConfig(override, "cli")).To(Succeed())
+
+ ocmCtx := credentials.WithConfigs(ctx).New()
+ Expect(httpcfgattr.Get(ocmCtx).GetHTTPSettings().GetTimeout()).To(Equal(2 * time.Minute))
+ })
+
+ It("rejects invalid duration string", func() {
+ raw := []byte(`{"type":"http.config.ocm.software/v1alpha1","timeout":"notaduration"}`)
+ _, err := ctx.GetConfigForData(raw, nil)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid duration: notaduration"))
+ })
+
+ It("returns nil fields when config omits timeout fields", func() {
+ raw := []byte(`{"type":"http.config.ocm.software/v1alpha1"}`)
+ cfg, err := ctx.GetConfigForData(raw, nil)
+ Expect(err).To(Succeed())
+ Expect(ctx.ApplyConfig(cfg, "config file")).To(Succeed())
+
+ ocmCtx := credentials.WithConfigs(ctx).New()
+ g := httpcfgattr.Get(ocmCtx).GetHTTPSettings()
+ Expect(g.Timeout).To(BeNil())
+ Expect(g.TCPDialTimeout).To(BeNil())
+ })
+
+ It("default attribute returns empty settings", func() {
+ ocmCtx := credentials.WithConfigs(ctx).New()
+ g := httpcfgattr.Get(ocmCtx).GetHTTPSettings()
+ Expect(g.Timeout).To(BeNil())
+ Expect(g.TCPDialTimeout).To(BeNil())
+ Expect(g.TCPKeepAlive).To(BeNil())
+ Expect(g.TLSHandshakeTimeout).To(BeNil())
+ Expect(g.ResponseHeaderTimeout).To(BeNil())
+ Expect(g.IdleConnTimeout).To(BeNil())
+ })
+
+ It("ApplyToAttribute overrides only non-nil fields", func() {
+ attr := &httpcfgattr.Attribute{}
+ first := &httpcfgattr.Config{
+ HTTPSettings: httpcfgattr.HTTPSettings{
+ TCPDialTimeout: httpcfgattr.NewDuration(15 * time.Second),
+ },
+ }
+ second := &httpcfgattr.Config{
+ HTTPSettings: httpcfgattr.HTTPSettings{
+ Timeout: httpcfgattr.NewDuration(1 * time.Minute),
+ },
+ }
+ first.ApplyToAttribute(attr)
+ second.ApplyToAttribute(attr)
+ g := attr.GetHTTPSettings()
+ Expect(g.GetTimeout()).To(Equal(1 * time.Minute))
+ Expect(g.TCPDialTimeout.TimeDuration()).To(Equal(15 * time.Second))
+ })
+
+ It("ApplyToAttribute with empty config leaves attribute unchanged", func() {
+ attr := &httpcfgattr.Attribute{}
+ first := &httpcfgattr.Config{
+ HTTPSettings: httpcfgattr.HTTPSettings{
+ TCPDialTimeout: httpcfgattr.NewDuration(5 * time.Second),
+ },
+ }
+ first.ApplyToAttribute(attr)
+
+ empty := &httpcfgattr.Config{}
+ empty.ApplyToAttribute(attr)
+
+ g := attr.GetHTTPSettings()
+ Expect(g.TCPDialTimeout.TimeDuration()).To(Equal(5 * time.Second))
+ Expect(g.Timeout).To(BeNil())
+ })
+})
diff --git a/api/datacontext/attrs/httpcfgattr/suite_test.go b/api/datacontext/attrs/httpcfgattr/suite_test.go
new file mode 100644
index 0000000000..1322ddc904
--- /dev/null
+++ b/api/datacontext/attrs/httpcfgattr/suite_test.go
@@ -0,0 +1,13 @@
+package httpcfgattr_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestHTTPTimeout(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "HTTP Timeout Attribute")
+}
diff --git a/api/datacontext/attrs/init.go b/api/datacontext/attrs/init.go
index 9b87f58cb3..6e2b29153c 100644
--- a/api/datacontext/attrs/init.go
+++ b/api/datacontext/attrs/init.go
@@ -1,6 +1,7 @@
package attrs
import (
+ _ "ocm.software/ocm/api/datacontext/attrs/httpcfgattr"
_ "ocm.software/ocm/api/datacontext/attrs/logforward"
_ "ocm.software/ocm/api/datacontext/attrs/rootcertsattr"
_ "ocm.software/ocm/api/datacontext/attrs/tmpcache"
diff --git a/api/oci/extensions/repositories/docker/client.go b/api/oci/extensions/repositories/docker/client.go
index c948adf739..3b25149df7 100644
--- a/api/oci/extensions/repositories/docker/client.go
+++ b/api/oci/extensions/repositories/docker/client.go
@@ -13,11 +13,18 @@ import (
dockerclient "github.com/moby/moby/client"
"github.com/spf13/pflag"
+ "ocm.software/ocm/api/datacontext/attrs/httpcfgattr"
+ "ocm.software/ocm/api/utils/httpclient"
"ocm.software/ocm/api/utils/logging"
)
-func newDockerClient(dockerhost string, logger mlog.UnboundLogger) (*dockerclient.Client, error) {
+func newDockerClient(dockerhost string, logger mlog.UnboundLogger, httpCfg *httpcfgattr.HTTPSettings) (*dockerclient.Client, error) {
if dockerhost == "" {
+ // Use Docker CLI context resolution (DOCKER_CONTEXT env,
+ // currentContext in ~/.docker/config.json, DOCKER_HOST env,
+ // default socket). NewAPIClientFromFlags builds its own HTTP
+ // transport internally, so OCM HTTP timeout settings do not
+ // apply to this path.
opts := cliflags.NewClientOptions()
// set defaults
opts.SetDefaultOptions(pflag.NewFlagSet("", pflag.ContinueOnError))
@@ -35,8 +42,13 @@ func newDockerClient(dockerhost string, logger mlog.UnboundLogger) (*dockerclien
if err == nil && url.Scheme == "unix" {
opts = append(opts, dockerclient.WithScheme(url.Scheme))
}
- clnt := http.Client{}
- clnt.Transport = logging.NewRoundTripper(clnt.Transport, logger)
+
+ transport := httpclient.NewTransport(httpCfg)
+
+ clnt := http.Client{
+ Timeout: httpCfg.GetTimeout(),
+ Transport: logging.NewRoundTripper(transport, logger),
+ }
opts = append(opts, dockerclient.WithHTTPClient(&clnt))
c, err := dockerclient.New(opts...)
if err != nil {
diff --git a/api/oci/extensions/repositories/docker/repository.go b/api/oci/extensions/repositories/docker/repository.go
index 4e9ad75306..8d16e86bd7 100644
--- a/api/oci/extensions/repositories/docker/repository.go
+++ b/api/oci/extensions/repositories/docker/repository.go
@@ -7,6 +7,7 @@ import (
"github.com/mandelsoft/logging"
"github.com/moby/moby/client"
+ "ocm.software/ocm/api/datacontext/attrs/httpcfgattr"
"ocm.software/ocm/api/oci/cpi"
ocmlog "ocm.software/ocm/api/utils/logging"
)
@@ -23,7 +24,7 @@ var _ cpi.RepositoryImpl = (*RepositoryImpl)(nil)
func NewRepository(ctx cpi.Context, spec *RepositorySpec) (cpi.Repository, error) {
urs := spec.UniformRepositorySpec()
logger := logging.DynamicLogger(ctx, REALM, logging.NewAttribute(ocmlog.ATTR_HOST, urs.Host))
- client, err := newDockerClient(spec.DockerHost, logger)
+ client, err := newDockerClient(spec.DockerHost, logger, httpcfgattr.Get(ctx).GetHTTPSettings())
if err != nil {
return nil, err
}
diff --git a/api/oci/extensions/repositories/docker/type.go b/api/oci/extensions/repositories/docker/type.go
index 8f4f4464fe..ce84b37593 100644
--- a/api/oci/extensions/repositories/docker/type.go
+++ b/api/oci/extensions/repositories/docker/type.go
@@ -7,6 +7,7 @@ import (
dockerclient "github.com/moby/moby/client"
"ocm.software/ocm/api/credentials"
+ "ocm.software/ocm/api/datacontext/attrs/httpcfgattr"
"ocm.software/ocm/api/oci/cpi"
"ocm.software/ocm/api/utils"
ocmlog "ocm.software/ocm/api/utils/logging"
@@ -56,7 +57,7 @@ func (a *RepositorySpec) Repository(ctx cpi.Context, creds credentials.Credentia
func (a *RepositorySpec) Validate(ctx cpi.Context, creds credentials.Credentials, usageContext ...credentials.UsageContext) error {
urs := a.UniformRepositorySpec()
logger := logging.DynamicLogger(ctx, REALM, logging.NewAttribute(ocmlog.ATTR_HOST, urs.Host))
- client, err := newDockerClient(a.DockerHost, logger)
+ client, err := newDockerClient(a.DockerHost, logger, httpcfgattr.Get(ctx).GetHTTPSettings())
if err != nil {
return err
}
diff --git a/api/oci/extensions/repositories/ocireg/repository.go b/api/oci/extensions/repositories/ocireg/repository.go
index 37fd892730..445d7258dc 100644
--- a/api/oci/extensions/repositories/ocireg/repository.go
+++ b/api/oci/extensions/repositories/ocireg/repository.go
@@ -16,12 +16,14 @@ import (
"oras.land/oras-go/v2/registry/remote/retry"
"ocm.software/ocm/api/credentials"
+ "ocm.software/ocm/api/datacontext/attrs/httpcfgattr"
"ocm.software/ocm/api/datacontext/attrs/rootcertsattr"
"ocm.software/ocm/api/oci/artdesc"
"ocm.software/ocm/api/oci/cpi"
"ocm.software/ocm/api/tech/oci/identity"
"ocm.software/ocm/api/tech/oras"
"ocm.software/ocm/api/utils"
+ "ocm.software/ocm/api/utils/httpclient"
ocmlog "ocm.software/ocm/api/utils/logging"
"ocm.software/ocm/api/utils/refmgmt"
)
@@ -153,12 +155,12 @@ func (r *RepositoryImpl) getResolver(comp string) (oras.Resolver, error) {
}
}
- client := retry.DefaultClient
- client.Transport = ocmlog.NewRoundTripper(retry.DefaultClient.Transport, logger)
+ httpCfg := httpcfgattr.Get(r.GetContext()).GetHTTPSettings()
+ baseTransport := httpclient.NewTransport(httpCfg)
+
if r.info.Scheme == "https" {
- // set up TLS
//nolint:gosec // used like the default, there are OCI servers (quay.io) not working with min version.
- conf := &tls.Config{
+ baseTransport.TLSClientConfig = &tls.Config{
// MinVersion: tls.VersionTLS13,
RootCAs: func() *x509.CertPool {
rootCAs := rootcertsattr.Get(r.GetContext()).GetRootCertPool(true)
@@ -168,13 +170,16 @@ func (r *RepositoryImpl) getResolver(comp string) (oras.Resolver, error) {
rootCAs.AppendCertsFromPEM([]byte(c))
}
}
-
return rootCAs
}(),
}
- client.Transport = ocmlog.NewRoundTripper(retry.NewTransport(&http.Transport{
- TLSClientConfig: conf,
- }), logger)
+ }
+
+ retryTransport := retry.NewTransport(baseTransport)
+
+ client := &http.Client{
+ Transport: ocmlog.NewRoundTripper(retryTransport, logger),
+ Timeout: httpCfg.GetTimeout(),
}
authClient := &auth.Client{
diff --git a/api/oci/extensions/repositories/ocireg/suite_test.go b/api/oci/extensions/repositories/ocireg/suite_test.go
new file mode 100644
index 0000000000..5a7587cc82
--- /dev/null
+++ b/api/oci/extensions/repositories/ocireg/suite_test.go
@@ -0,0 +1,13 @@
+package ocireg_test
+
+import (
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestOCIReg(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "OCIReg")
+}
diff --git a/api/oci/extensions/repositories/ocireg/timeout_integration_test.go b/api/oci/extensions/repositories/ocireg/timeout_integration_test.go
new file mode 100644
index 0000000000..8843dca7ee
--- /dev/null
+++ b/api/oci/extensions/repositories/ocireg/timeout_integration_test.go
@@ -0,0 +1,230 @@
+//go:build integration
+
+package ocireg_test
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ toxiproxy "github.com/Shopify/toxiproxy/v2/client"
+ "github.com/mandelsoft/vfs/pkg/osfs"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/testcontainers/testcontainers-go"
+ "github.com/testcontainers/testcontainers-go/modules/registry"
+ tctoxiproxy "github.com/testcontainers/testcontainers-go/modules/toxiproxy"
+ "github.com/testcontainers/testcontainers-go/network"
+ "github.com/testcontainers/testcontainers-go/wait"
+
+ envhelper "ocm.software/ocm/api/helper/env"
+ . "ocm.software/ocm/cmds/ocm/testhelper"
+)
+
+var _ = Describe("Registry timeout:", Ordered, func() {
+ const (
+ registryImage = "registry:2.8.3"
+ toxiproxyImage = "ghcr.io/shopify/toxiproxy:2.12.0"
+ componentName = "example.com/timeout-test"
+ componentVersion = "1.0.0"
+ proxyName = "registry"
+ registryPort = 5000
+ )
+
+ var (
+ ctx context.Context
+ nw *testcontainers.DockerNetwork
+ registryContainer testcontainers.Container
+ toxiContainer *tctoxiproxy.Container
+ proxy *toxiproxy.Proxy
+ proxyHost string
+ tempDir string
+ ctfDir string
+ env *TestEnv
+ )
+
+ BeforeAll(func() {
+ ctx = context.Background()
+ log := GinkgoLogr
+
+ var err error
+ nw, err = network.New(ctx)
+ Expect(err).To(Succeed(), "failed to create Docker network")
+
+ registryContainer, err = registry.Run(ctx, registryImage,
+ network.WithNetwork([]string{"registry"}, nw),
+ testcontainers.WithWaitStrategy(wait.ForHTTP("/v2/").WithPort("5000/tcp")),
+ )
+ Expect(err).To(Succeed(), "failed to start registry container")
+
+ toxiContainer, err = tctoxiproxy.Run(ctx, toxiproxyImage,
+ network.WithNetwork([]string{"toxiproxy"}, nw),
+ tctoxiproxy.WithProxy(proxyName, fmt.Sprintf("registry:%d", registryPort)),
+ )
+ Expect(err).To(Succeed(), "failed to start toxiproxy container")
+
+ host, port, err := toxiContainer.ProxiedEndpoint(8666)
+ Expect(err).To(Succeed())
+ proxyHost = fmt.Sprintf("%s:%s", host, port)
+
+ uri, err := toxiContainer.URI(ctx)
+ Expect(err).To(Succeed())
+ toxiClient := toxiproxy.NewClient(uri)
+ proxy, err = toxiClient.Proxy(proxyName)
+ Expect(err).To(Succeed(), "failed to get toxiproxy proxy")
+
+ env = NewTestEnv(envhelper.FileSystem(osfs.New()))
+
+ // Create temp dir and CTF for all tests.
+ tempDir, err = os.MkdirTemp("", "ocm-timeout-*")
+ Expect(err).To(Succeed())
+
+ ctfDir = filepath.Join(tempDir, "ctf")
+ constructorFile := filepath.Join(tempDir, "constructor.yaml")
+ constructor := `components:
+ - name: ` + componentName + `
+ version: ` + componentVersion + `
+ provider:
+ name: test
+`
+ Expect(os.WriteFile(constructorFile, []byte(constructor), 0o644)).To(Succeed())
+
+ buf := bytes.NewBuffer(nil)
+ Expect(env.CatchOutput(buf).Execute(
+ "add", "componentversions",
+ "--create",
+ "--file", ctfDir,
+ constructorFile,
+ )).To(Succeed())
+
+ log.Info("Toxic Registry ready", "proxy", proxyHost, "ctf", ctfDir)
+ })
+
+ AfterAll(func() {
+ if toxiContainer != nil {
+ Expect(testcontainers.TerminateContainer(toxiContainer)).To(Succeed())
+ }
+ if registryContainer != nil {
+ Expect(testcontainers.TerminateContainer(registryContainer)).To(Succeed())
+ }
+ if nw != nil {
+ Expect(nw.Remove(ctx)).To(Succeed())
+ }
+ if env != nil {
+ Expect(env.Cleanup()).To(Succeed())
+ }
+ if tempDir != "" {
+ Expect(os.RemoveAll(tempDir)).To(Succeed())
+ }
+ })
+
+ // writeHTTPConfig writes a config file with the given http settings and returns its path.
+ writeHTTPConfig := func(settings string) string {
+ cfg := fmt.Sprintf(`{"type":"http.config.ocm.software/v1alpha1",%s}`, settings)
+ cfgFile := filepath.Join(tempDir, "httpconfig.yaml")
+ Expect(os.WriteFile(cfgFile, []byte(cfg), 0o644)).To(Succeed())
+ return cfgFile
+ }
+
+ It("fails when timeout is shorter than proxy latency", func() {
+ addLatency(proxy, 30_000, "upstream")
+ defer removeToxic(proxy, "latency")
+
+ cfgFile := writeHTTPConfig(`"timeout":"2s"`)
+ registryURL := "http://" + proxyHost
+ err := env.Execute(
+ "--config", cfgFile,
+ "transfer", "componentversions",
+ ctfDir+"//"+componentName+":"+componentVersion,
+ registryURL,
+ )
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(SatisfyAny(
+ ContainSubstring("Client.Timeout"),
+ ContainSubstring("context deadline exceeded"),
+ ContainSubstring("i/o timeout"),
+ ))
+ })
+
+ It("succeeds when timeout exceeds proxy latency", func() {
+ addLatency(proxy, 1_000, "upstream")
+ defer removeToxic(proxy, "latency")
+
+ cfgFile := writeHTTPConfig(`"timeout":"30s"`)
+ registryURL := "http://" + proxyHost
+ buf := bytes.NewBuffer(nil)
+ Expect(env.CatchOutput(buf).Execute(
+ "--config", cfgFile,
+ "transfer", "componentversions",
+ ctfDir+"//"+componentName+":"+componentVersion,
+ registryURL,
+ )).To(Succeed())
+ })
+
+ It("fails when response headers can't arrive within configured time", func() {
+ addLatency(proxy, 5_000, "downstream")
+ defer removeToxic(proxy, "latency")
+
+ // Cap overall timeout to prevent retries from dragging out.
+ cfgFile := writeHTTPConfig(`"responseHeaderTimeout":"1s","timeout":"8s"`)
+ registryURL := "http://" + proxyHost
+ err := env.Execute(
+ "--config", cfgFile,
+ "transfer", "componentversions",
+ ctfDir+"//"+componentName+":"+componentVersion,
+ registryURL,
+ )
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(SatisfyAny(
+ ContainSubstring("timeout awaiting response headers"),
+ ContainSubstring("context deadline exceeded"),
+ ContainSubstring("i/o timeout"),
+ ))
+ })
+
+ It("succeeds when response header timeout is generous enough", func() {
+ addLatency(proxy, 2_000, "downstream")
+ defer removeToxic(proxy, "latency")
+
+ cfgFile := writeHTTPConfig(`"responseHeaderTimeout":"30s"`)
+ registryURL := "http://" + proxyHost
+ buf := bytes.NewBuffer(nil)
+ Expect(env.CatchOutput(buf).Execute(
+ "--config", cfgFile,
+ "transfer", "componentversions",
+ ctfDir+"//"+componentName+":"+componentVersion,
+ registryURL,
+ )).To(Succeed())
+ })
+
+ It("fails when tcp-dial-timeout is too short to establish connection", func() {
+ cfgFile := writeHTTPConfig(`"tcpDialTimeout":"1ns"`)
+ registryURL := "http://" + proxyHost
+ err := env.Execute(
+ "--config", cfgFile,
+ "transfer", "componentversions",
+ ctfDir+"//"+componentName+":"+componentVersion,
+ registryURL,
+ )
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(SatisfyAny(
+ ContainSubstring("i/o timeout"),
+ ContainSubstring("dial tcp"),
+ ContainSubstring("context deadline exceeded"),
+ ))
+ })
+
+})
+
+func addLatency(proxy *toxiproxy.Proxy, latencyMs int, stream string) {
+ _, err := proxy.AddToxic("latency", "latency", stream, 1.0, toxiproxy.Attributes{
+ "latency": latencyMs,
+ })
+ Expect(err).To(Succeed())
+}
+
+func removeToxic(proxy *toxiproxy.Proxy, name string) {
+ Expect(proxy.RemoveToxic(name)).To(Succeed())
+}
diff --git a/api/utils/httpclient/transport.go b/api/utils/httpclient/transport.go
new file mode 100644
index 0000000000..d32178d49a
--- /dev/null
+++ b/api/utils/httpclient/transport.go
@@ -0,0 +1,47 @@
+package httpclient
+
+import (
+ "net"
+ "net/http"
+ "time"
+
+ "ocm.software/ocm/api/datacontext/attrs/httpcfgattr"
+)
+
+// NewTransport creates an *http.Transport that starts as a clone of
+// http.DefaultTransport and selectively overrides timeouts from cfg.
+func NewTransport(cfg *httpcfgattr.HTTPSettings) *http.Transport {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+
+ if cfg == nil {
+ return transport
+ }
+
+ // TCP Dialer settings
+ if cfg.TCPDialTimeout != nil || cfg.TCPKeepAlive != nil {
+ // Clone() doesn't expose the original dialer, so we create a new one
+ // with the same defaults as http.DefaultTransport.
+ dialer := &net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+ if cfg.TCPDialTimeout != nil {
+ dialer.Timeout = cfg.TCPDialTimeout.TimeDuration()
+ }
+ if cfg.TCPKeepAlive != nil {
+ dialer.KeepAlive = cfg.TCPKeepAlive.TimeDuration()
+ }
+ transport.DialContext = dialer.DialContext
+ }
+
+ if cfg.TLSHandshakeTimeout != nil {
+ transport.TLSHandshakeTimeout = cfg.TLSHandshakeTimeout.TimeDuration()
+ }
+ if cfg.ResponseHeaderTimeout != nil {
+ transport.ResponseHeaderTimeout = cfg.ResponseHeaderTimeout.TimeDuration()
+ }
+ if cfg.IdleConnTimeout != nil {
+ transport.IdleConnTimeout = cfg.IdleConnTimeout.TimeDuration()
+ }
+ return transport
+}
diff --git a/api/utils/httpclient/transport_test.go b/api/utils/httpclient/transport_test.go
new file mode 100644
index 0000000000..de75616715
--- /dev/null
+++ b/api/utils/httpclient/transport_test.go
@@ -0,0 +1,106 @@
+package httpclient_test
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "ocm.software/ocm/api/datacontext/attrs/httpcfgattr"
+ "ocm.software/ocm/api/utils/httpclient"
+)
+
+func TestTransport(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Transport Test Suite")
+}
+
+var defaultTransport = http.DefaultTransport.(*http.Transport)
+
+var _ = Describe("NewTransport", func() {
+ Context("when no config is provided", func() {
+ It("preserves http.DefaultTransport values", func() {
+ tr := httpclient.NewTransport(nil)
+ Expect(tr.TLSHandshakeTimeout).To(Equal(defaultTransport.TLSHandshakeTimeout))
+ Expect(tr.IdleConnTimeout).To(Equal(defaultTransport.IdleConnTimeout))
+ Expect(tr.ResponseHeaderTimeout).To(Equal(defaultTransport.ResponseHeaderTimeout))
+ Expect(tr.ExpectContinueTimeout).To(Equal(defaultTransport.ExpectContinueTimeout))
+ Expect(tr.MaxIdleConns).To(Equal(defaultTransport.MaxIdleConns))
+ Expect(tr.ForceAttemptHTTP2).To(Equal(defaultTransport.ForceAttemptHTTP2))
+ })
+ })
+
+ Context("when config has all nil fields", func() {
+ It("preserves http.DefaultTransport values", func() {
+ tr := httpclient.NewTransport(&httpcfgattr.HTTPSettings{})
+ Expect(tr.TLSHandshakeTimeout).To(Equal(defaultTransport.TLSHandshakeTimeout))
+ Expect(tr.IdleConnTimeout).To(Equal(defaultTransport.IdleConnTimeout))
+ Expect(tr.ResponseHeaderTimeout).To(Equal(defaultTransport.ResponseHeaderTimeout))
+ })
+ })
+
+ Context("when default attribute settings are used (no config)", func() {
+ It("preserves http.DefaultTransport values", func() {
+ attr := &httpcfgattr.Attribute{}
+ tr := httpclient.NewTransport(attr.GetHTTPSettings())
+ Expect(tr.TLSHandshakeTimeout).To(Equal(defaultTransport.TLSHandshakeTimeout))
+ Expect(tr.IdleConnTimeout).To(Equal(defaultTransport.IdleConnTimeout))
+ Expect(tr.ResponseHeaderTimeout).To(Equal(defaultTransport.ResponseHeaderTimeout))
+ Expect(tr.ExpectContinueTimeout).To(Equal(defaultTransport.ExpectContinueTimeout))
+ })
+ })
+
+ Context("when individual fields are set", func() {
+ It("overrides TLSHandshakeTimeout only", func() {
+ tr := httpclient.NewTransport(&httpcfgattr.HTTPSettings{
+ TLSHandshakeTimeout: httpcfgattr.NewDuration(5 * time.Second),
+ })
+ Expect(tr.TLSHandshakeTimeout).To(Equal(5 * time.Second))
+ Expect(tr.IdleConnTimeout).To(Equal(defaultTransport.IdleConnTimeout))
+ })
+
+ It("overrides IdleConnTimeout only", func() {
+ tr := httpclient.NewTransport(&httpcfgattr.HTTPSettings{
+ IdleConnTimeout: httpcfgattr.NewDuration(120 * time.Second),
+ })
+ Expect(tr.IdleConnTimeout).To(Equal(120 * time.Second))
+ Expect(tr.TLSHandshakeTimeout).To(Equal(defaultTransport.TLSHandshakeTimeout))
+ })
+
+ It("overrides ResponseHeaderTimeout only", func() {
+ tr := httpclient.NewTransport(&httpcfgattr.HTTPSettings{
+ ResponseHeaderTimeout: httpcfgattr.NewDuration(20 * time.Second),
+ })
+ Expect(tr.ResponseHeaderTimeout).To(Equal(20 * time.Second))
+ Expect(tr.TLSHandshakeTimeout).To(Equal(defaultTransport.TLSHandshakeTimeout))
+ })
+
+ It("replaces DialContext when TCPDialTimeout is set", func() {
+ tr := httpclient.NewTransport(&httpcfgattr.HTTPSettings{
+ TCPDialTimeout: httpcfgattr.NewDuration(15 * time.Second),
+ })
+ Expect(tr.DialContext).NotTo(BeNil())
+ Expect(tr.TLSHandshakeTimeout).To(Equal(defaultTransport.TLSHandshakeTimeout))
+ })
+ })
+
+ Context("when all fields are set", func() {
+ It("applies all values and preserves non-timeout defaults", func() {
+ tr := httpclient.NewTransport(&httpcfgattr.HTTPSettings{
+ TCPDialTimeout: httpcfgattr.NewDuration(1 * time.Second),
+ TCPKeepAlive: httpcfgattr.NewDuration(2 * time.Second),
+ TLSHandshakeTimeout: httpcfgattr.NewDuration(3 * time.Second),
+ ResponseHeaderTimeout: httpcfgattr.NewDuration(4 * time.Second),
+ IdleConnTimeout: httpcfgattr.NewDuration(5 * time.Second),
+ })
+ Expect(tr.TLSHandshakeTimeout).To(Equal(3 * time.Second))
+ Expect(tr.ResponseHeaderTimeout).To(Equal(4 * time.Second))
+ Expect(tr.IdleConnTimeout).To(Equal(5 * time.Second))
+ Expect(tr.ExpectContinueTimeout).To(Equal(defaultTransport.ExpectContinueTimeout))
+ Expect(tr.MaxIdleConns).To(Equal(defaultTransport.MaxIdleConns))
+ Expect(tr.ForceAttemptHTTP2).To(Equal(defaultTransport.ForceAttemptHTTP2))
+ })
+ })
+})
diff --git a/docs/reference/ocm.md b/docs/reference/ocm.md
index d7a28caed3..4a51edc812 100644
--- a/docs/reference/ocm.md
+++ b/docs/reference/ocm.md
@@ -306,6 +306,21 @@ The value can be a simple type or a JSON/YAML string for complex values
the backend and descriptor updated will be persisted on AddVersion
or closing a provided existing component version.
+- ocm.software/ocm/api/datacontext/attrs/httptimeout [httpcfg]: *JSON*
+
+ Configures HTTP client timeout settings for OCI registry and remote endpoint access.
+ Settings are provided as a JSON document matching the http.config.ocm.software config type.
+
+ For full control use the config file:
+ + type: http.config.ocm.software + timeout: 0s + tcpDialTimeout: 30s + tcpKeepAlive: 30s + tlsHandshakeTimeout: 10s + idleConnTimeout: 90s ++ -
ocm.software/ocm/api/ocm/extensions/attrs/maxworkers [maxworkers]: *integer* or *"auto"*
Specifies the maximum number of concurrent workers to use for resource and source,
diff --git a/docs/reference/ocm_attributes.md b/docs/reference/ocm_attributes.md
index 72c1607a3b..a13632cea8 100644
--- a/docs/reference/ocm_attributes.md
+++ b/docs/reference/ocm_attributes.md
@@ -198,6 +198,21 @@ OCM library:
the backend and descriptor updated will be persisted on AddVersion
or closing a provided existing component version.
+- ocm.software/ocm/api/datacontext/attrs/httptimeout [httpcfg]: *JSON*
+
+ Configures HTTP client timeout settings for OCI registry and remote endpoint access.
+ Settings are provided as a JSON document matching the http.config.ocm.software config type.
+
+ For full control use the config file:
+ + type: http.config.ocm.software + timeout: 0s + tcpDialTimeout: 30s + tcpKeepAlive: 30s + tlsHandshakeTimeout: 10s + idleConnTimeout: 90s ++ -
ocm.software/ocm/api/ocm/extensions/attrs/maxworkers [maxworkers]: *integer* or *"auto"*
Specifies the maximum number of concurrent workers to use for resource and source,
diff --git a/docs/reference/ocm_configfile.md b/docs/reference/ocm_configfile.md
index a16112ea04..f9418828b4 100644
--- a/docs/reference/ocm_configfile.md
+++ b/docs/reference/ocm_configfile.md
@@ -129,6 +129,27 @@ The following configuration types are supported:
- NO-DIGEST
- SHA-256 (default)
- SHA-512
+- http.config.ocm.software
+ The config type http.config.ocm.software can be used to configure
+ HTTP client settings:
+
+ + type: http.config.ocm.software + timeout: 0s + tcpDialTimeout: 30s + tcpKeepAlive: 30s + tlsHandshakeTimeout: 10s + responseHeaderTimeout: 0s + idleConnTimeout: 90s ++ + All timeout values are Go duration strings (e.g. "30s", "5m", "1h"). + Use "0s" to disable a specific timeout. If not set, the
http.DefaultTransport
+ values from the Go standard library are used.
+
+ Note: timeout controls the overall http.Client request deadline and is
+ independent of the transport-level settings. Setting only timeout does not
+ affect tcpDialTimeout, tlsHandshakeTimeout, or other transport timeouts.
- keys.config.ocm.software
The config type keys.config.ocm.software can be used to define
public and private keys. A key value might be given by one of the fields:
diff --git a/go.mod b/go.mod
index 1de2da80a2..916c3445a2 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
github.com/InfiniteLoopSpace/go_S-MIME v0.0.0-20181221134359-3f58f9a4b2b6
github.com/Masterminds/semver/v3 v3.4.0
github.com/Masterminds/sprig/v3 v3.3.0
+ github.com/Shopify/toxiproxy/v2 v2.12.0
github.com/aws/aws-sdk-go-v2 v1.41.4
github.com/aws/aws-sdk-go-v2/config v1.32.12
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
@@ -69,6 +70,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.41.0
github.com/testcontainers/testcontainers-go/modules/registry v0.41.0
+ github.com/testcontainers/testcontainers-go/modules/toxiproxy v0.41.0
github.com/texttheater/golang-levenshtein v1.0.1
github.com/tonglil/buflogr v1.1.1
github.com/ulikunitz/xz v0.5.15
diff --git a/go.sum b/go.sum
index 8878bc9f9d..31699228bf 100644
--- a/go.sum
+++ b/go.sum
@@ -116,6 +116,8 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
+github.com/Shopify/toxiproxy/v2 v2.12.0 h1:d1x++lYZg/zijXPPcv7PH0MvHMzEI5aX/YuUi/Sw+yg=
+github.com/Shopify/toxiproxy/v2 v2.12.0/go.mod h1:R9Z38Pw6k2cGZWXHe7tbxjGW9azmY1KbDQJ1kd+h7Tk=
github.com/ThalesGroup/crypto11 v1.6.0 h1:Og9EMn44fBS4GNnGnH1aqHnF2wL6F7IU/RhpJajWX/4=
github.com/ThalesGroup/crypto11 v1.6.0/go.mod h1:H6LRjN5R5SHxTrLqGNteisLDI0/IC6+SGx1pHtbwizE=
github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=
@@ -565,6 +567,8 @@ github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7x
github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
+github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@@ -909,6 +913,8 @@ github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhg
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
+github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/mgechev/revive v1.11.0 h1:b/gLLpBE427o+Xmd8G58gSA+KtBwxWinH/A565Awh0w=
@@ -1203,8 +1209,12 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpR
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
+github.com/testcontainers/testcontainers-go/modules/redis v0.41.0 h1:QlTSe4JGOnjr/37MXx0GqNLGa+8sKQst7lsn7uLjg8E=
+github.com/testcontainers/testcontainers-go/modules/redis v0.41.0/go.mod h1:5mDOIWrS/a+z8gBesXBQAAQtrqJrW2tUi9Tf46+/Luo=
github.com/testcontainers/testcontainers-go/modules/registry v0.41.0 h1:Wa/SEmjJFspcg4ukBsU9sSsJoWNNW10Hbj6FT3Z4QL4=
github.com/testcontainers/testcontainers-go/modules/registry v0.41.0/go.mod h1:xOQCIOIbOSgxQJ6gGDlVDEJL6+3e/8o1lSwIFgtant0=
+github.com/testcontainers/testcontainers-go/modules/toxiproxy v0.41.0 h1:FcPygoroBsZHT5tvfUXxCftFixQXamB8imLygqf+9yw=
+github.com/testcontainers/testcontainers-go/modules/toxiproxy v0.41.0/go.mod h1:veWhBHxGtFfL7BhCGuZTCRIHYZ8wuJ5YipASibgK0Jw=
github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4=
github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=