From 14537950939815d4f4560899e535154737d1b4c4 Mon Sep 17 00:00:00 2001 From: Piotr Janik Date: Wed, 25 Mar 2026 23:50:01 +0100 Subject: [PATCH] feat: add configurable HTTP client timeouts via config file Introduce the `http.config.ocm.software/v1alpha1` config type for controlling HTTP client timeouts (dial, TLS handshake, response header, idle connection, keep-alive, and overall timeout). Timeouts are configured exclusively through the OCM config file using Go duration strings. When not set, `http.DefaultTransport` values are preserved unchanged. - Add `httpcfgattr` package with Duration type - Add `api/utils/httpclient` transport factory (clones DefaultTransport, selectively overrides from config) - Wire HTTP settings into docker daemon and OCI registry clients - Add integration tests using toxiproxy for timeout verification - Update docs with new config type documentation Fixes: https://github.com/open-component-model/ocm/issues/1731 Signed-off-by: Piotr Janik --- api/datacontext/attrs/httpcfgattr/attr.go | 102 ++++++++ .../attrs/httpcfgattr/attr_test.go | 72 ++++++ api/datacontext/attrs/httpcfgattr/config.go | 175 +++++++++++++ .../attrs/httpcfgattr/config_test.go | 148 +++++++++++ .../attrs/httpcfgattr/suite_test.go | 13 + api/datacontext/attrs/init.go | 1 + .../extensions/repositories/docker/client.go | 18 +- .../repositories/docker/repository.go | 3 +- .../extensions/repositories/docker/type.go | 3 +- .../repositories/ocireg/repository.go | 21 +- .../repositories/ocireg/suite_test.go | 13 + .../ocireg/timeout_integration_test.go | 230 ++++++++++++++++++ api/utils/httpclient/transport.go | 47 ++++ api/utils/httpclient/transport_test.go | 106 ++++++++ docs/reference/ocm.md | 15 ++ docs/reference/ocm_attributes.md | 15 ++ docs/reference/ocm_configfile.md | 21 ++ go.mod | 2 + go.sum | 10 + 19 files changed, 1002 insertions(+), 13 deletions(-) create mode 100644 api/datacontext/attrs/httpcfgattr/attr.go create mode 100644 api/datacontext/attrs/httpcfgattr/attr_test.go create mode 100644 api/datacontext/attrs/httpcfgattr/config.go create mode 100644 api/datacontext/attrs/httpcfgattr/config_test.go create mode 100644 api/datacontext/attrs/httpcfgattr/suite_test.go create mode 100644 api/oci/extensions/repositories/ocireg/suite_test.go create mode 100644 api/oci/extensions/repositories/ocireg/timeout_integration_test.go create mode 100644 api/utils/httpclient/transport.go create mode 100644 api/utils/httpclient/transport_test.go 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=