diff --git a/.github/config/wordlist.txt b/.github/config/wordlist.txt index 5ede31c8ff..dfdf6b8e11 100644 --- a/.github/config/wordlist.txt +++ b/.github/config/wordlist.txt @@ -274,6 +274,7 @@ templater templaters templating tgz +tcp tls todo toi diff --git a/api/oci/config/httpconfig.go b/api/oci/config/httpconfig.go new file mode 100644 index 0000000000..923c64738e --- /dev/null +++ b/api/oci/config/httpconfig.go @@ -0,0 +1,109 @@ +package config + +import ( + cfgcpi "ocm.software/ocm/api/config/cpi" + "ocm.software/ocm/api/oci/cpi" + "ocm.software/ocm/api/utils/runtime" +) + +const ( + HTTPConfigType = "http" + cfgcpi.OCM_CONFIG_TYPE_SUFFIX + HTTPConfigTypeV1Alpha1 = HTTPConfigType + runtime.VersionSeparator + "v1alpha1" +) + +func init() { + cfgcpi.RegisterConfigType(cfgcpi.NewConfigType[*HTTPConfig](HTTPConfigType, httpUsage)) + cfgcpi.RegisterConfigType(cfgcpi.NewConfigType[*HTTPConfig](HTTPConfigTypeV1Alpha1, httpUsage)) +} + +// HTTPConfig describes the configuration for HTTP client settings. +type HTTPConfig struct { + runtime.ObjectVersionedType `json:",inline"` + cpi.HTTPSettings `json:",inline"` +} + +func (a *HTTPConfig) GetType() string { + return HTTPConfigType +} + +func (a *HTTPConfig) ApplyTo(_ cfgcpi.Context, target interface{}) error { + t, ok := target.(cpi.Context) + if !ok { + return cfgcpi.ErrNoContext(HTTPConfigType) + } + if err := a.HTTPSettings.Validate(); err != nil { + return err + } + s, err := t.GetHTTPSettings() + if err != nil { + return err + } + 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 + } + t.SetHTTPSettings(&s) + return nil +} + +const httpUsage = ` +The config type ` + HTTPConfigType + ` can be used to configure +HTTP client settings: + +
+    type: generic.config.ocm.software/v1
+    configurations:
+      - type: ` + HTTPConfigType + `
+        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. + +The fields have the following meaning: + +- timeout — specifies a time limit for requests made by the HTTP + client. The timeout includes connection time, any redirects, and reading + the response body. A timeout of zero means no timeout. + +- tcpDialTimeout — the maximum amount of time a dial will wait + for a TCP connect to complete. When dialing a host name with multiple IP + addresses, the timeout may be divided between them. The operating system + may impose its own earlier timeout. + +- tcpKeepAlive — specifies the interval between keep-alive + probes for an active network connection. If negative, keep-alive probes + are disabled. + +- tlsHandshakeTimeout — specifies the maximum amount of time + to wait for a TLS handshake. Zero means no timeout. + +- responseHeaderTimeout — specifies the amount of time to wait + for a server's response headers after fully writing the request (including + its body, if any). This time does not include the time to read the response + body. + +- idleConnTimeout — the maximum amount of time an idle + (keep-alive) connection will remain idle before closing itself. Zero means + no limit. +` diff --git a/api/oci/config/httpconfig_test.go b/api/oci/config/httpconfig_test.go new file mode 100644 index 0000000000..b65b52f35e --- /dev/null +++ b/api/oci/config/httpconfig_test.go @@ -0,0 +1,193 @@ +package config_test + +import ( + "encoding/json" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "ocm.software/ocm/api/oci/config" + "ocm.software/ocm/api/oci/cpi" +) + +func dur(s string) *cpi.Duration { + td, err := time.ParseDuration(s) + if err != nil { + panic(err) + } + d := cpi.Duration(td) + return &d +} + +func MustGetHTTPSettings(ctx cpi.Context) cpi.HTTPSettings { + g, err := ctx.GetHTTPSettings() + ExpectWithOffset(1, err).To(Succeed()) + return g +} + +var _ = Describe("http config", func() { + Context("apply", func() { + It("applies timeout via ApplyTo", func() { + ctx := cpi.New() + cfg := &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{Timeout: dur("5m")}} + + Expect(cfg.ApplyTo(ctx.ConfigContext(), ctx)).To(Succeed()) + g := MustGetHTTPSettings(ctx) + Expect(time.Duration(*g.Timeout)).To(Equal(5 * time.Minute)) + }) + + It("applies via config context", func() { + ctx := cpi.New() + cfg := &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{Timeout: dur("30s")}} + + Expect(ctx.ConfigContext().ApplyConfig(cfg, "programmatic")).To(Succeed()) + g := MustGetHTTPSettings(ctx) + Expect(time.Duration(*g.Timeout)).To(Equal(30 * time.Second)) + }) + + It("parses all fields from JSON config", func() { + ctx := cpi.New() + raw := []byte(`{"type":"http.config.ocm.software/v1alpha1","timeout":"10s","tcpDialTimeout":"15s","tcpKeepAlive":"20s","tlsHandshakeTimeout":"5s","responseHeaderTimeout":"8s","idleConnTimeout":"45s"}`) + cfg, err := ctx.ConfigContext().GetConfigForData(raw, nil) + Expect(err).To(Succeed()) + Expect(ctx.ConfigContext().ApplyConfig(cfg, "config file")).To(Succeed()) + + g := MustGetHTTPSettings(ctx) + Expect(time.Duration(*g.Timeout)).To(Equal(10 * time.Second)) + Expect(time.Duration(*g.TCPDialTimeout)).To(Equal(15 * time.Second)) + Expect(time.Duration(*g.TCPKeepAlive)).To(Equal(20 * time.Second)) + Expect(time.Duration(*g.TLSHandshakeTimeout)).To(Equal(5 * time.Second)) + Expect(time.Duration(*g.ResponseHeaderTimeout)).To(Equal(8 * time.Second)) + Expect(time.Duration(*g.IdleConnTimeout)).To(Equal(45 * time.Second)) + }) + + It("successive ApplyConfig overrides only non-nil fields", func() { + ctx := cpi.New() + + first := &config.HTTPConfig{ + HTTPSettings: cpi.HTTPSettings{ + TCPDialTimeout: dur("15s"), + }, + } + Expect(first.ApplyTo(ctx.ConfigContext(), ctx)).To(Succeed()) + + second := &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{Timeout: dur("1m")}} + Expect(second.ApplyTo(ctx.ConfigContext(), ctx)).To(Succeed()) + + g := MustGetHTTPSettings(ctx) + Expect(time.Duration(*g.Timeout)).To(Equal(1 * time.Minute)) + Expect(time.Duration(*g.TCPDialTimeout)).To(Equal(15 * time.Second)) + }) + + It("applies via generic config wrapper", func() { + ctx := cpi.New() + raw := []byte(` +type: generic.config.ocm.software/v1 +configurations: + - type: http.config.ocm.software + timeout: "10s" + tcpDialTimeout: "15s" + tcpKeepAlive: "20s" + tlsHandshakeTimeout: "5s" + responseHeaderTimeout: "8s" + idleConnTimeout: "45s" +`) + cfg, err := ctx.ConfigContext().GetConfigForData(raw, nil) + Expect(err).To(Succeed()) + Expect(ctx.ConfigContext().ApplyConfig(cfg, "config file")).To(Succeed()) + + g := MustGetHTTPSettings(ctx) + Expect(time.Duration(*g.Timeout)).To(Equal(10 * time.Second)) + Expect(time.Duration(*g.TCPDialTimeout)).To(Equal(15 * time.Second)) + Expect(time.Duration(*g.TCPKeepAlive)).To(Equal(20 * time.Second)) + Expect(time.Duration(*g.TLSHandshakeTimeout)).To(Equal(5 * time.Second)) + Expect(time.Duration(*g.ResponseHeaderTimeout)).To(Equal(8 * time.Second)) + Expect(time.Duration(*g.IdleConnTimeout)).To(Equal(45 * time.Second)) + }) + + DescribeTable("rejects invalid duration values on unmarshal", + func(jsonValue string, expectedErr string) { + ctx := cpi.New() + raw := []byte(`{"type":"http.config.ocm.software/v1alpha1","timeout":` + jsonValue + `}`) + _, err := ctx.ConfigContext().GetConfigForData(raw, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(expectedErr)) + }, + Entry("garbage string", `"notaduration"`, "expected a Go duration string"), + Entry("number instead of string", `42`, "expected a Go duration string"), + Entry("boolean instead of string", `true`, "expected a Go duration string"), + Entry("empty string", `""`, "expected a Go duration string"), + ) + + DescribeTable("rejects negative duration for timeout fields", + func(field string, cfg *config.HTTPConfig) { + ctx := cpi.New() + Expect(cfg.ApplyTo(ctx.ConfigContext(), ctx)).To(MatchError( + ContainSubstring("invalid value for " + field), + )) + }, + Entry("timeout -5m", "timeout", + &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{Timeout: dur("-5m")}}), + Entry("tcpDialTimeout -10s", "tcpDialTimeout", + &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{TCPDialTimeout: dur("-10s")}}), + Entry("tlsHandshakeTimeout -10h5m", "tlsHandshakeTimeout", + &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{TLSHandshakeTimeout: dur("-10h5m")}}), + Entry("responseHeaderTimeout -1s", "responseHeaderTimeout", + &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{ResponseHeaderTimeout: dur("-1s")}}), + Entry("idleConnTimeout -30s", "idleConnTimeout", + &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{IdleConnTimeout: dur("-30s")}}), + ) + + It("allows negative tcpKeepAlive to disable keep-alive probes", func() { + ctx := cpi.New() + cfg := &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{TCPKeepAlive: dur("-1s")}} + Expect(cfg.ApplyTo(ctx.ConfigContext(), ctx)).To(Succeed()) + g := MustGetHTTPSettings(ctx) + Expect(time.Duration(*g.TCPKeepAlive)).To(Equal(-1 * time.Second)) + }) + + DescribeTable("accepts compound duration like 1h5s", + func(cfg *config.HTTPConfig) { + ctx := cpi.New() + Expect(cfg.ApplyTo(ctx.ConfigContext(), ctx)).To(Succeed()) + }, + Entry("timeout", &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{Timeout: dur("1h5s")}}), + Entry("tcpDialTimeout", &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{TCPDialTimeout: dur("1h5s")}}), + Entry("tcpKeepAlive", &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{TCPKeepAlive: dur("1h5s")}}), + Entry("tlsHandshakeTimeout", &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{TLSHandshakeTimeout: dur("1h5s")}}), + Entry("responseHeaderTimeout", &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{ResponseHeaderTimeout: dur("1h5s")}}), + Entry("idleConnTimeout", &config.HTTPConfig{HTTPSettings: cpi.HTTPSettings{IdleConnTimeout: dur("1h5s")}}), + ) + + It("default settings are nil", func() { + g := MustGetHTTPSettings(cpi.New()) + 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("nil timeout returns nil not zero", func() { + g := MustGetHTTPSettings(cpi.New()) + Expect(g.Timeout).To(BeNil()) + }) + + It("round-trips Duration through MarshalJSON and UnmarshalJSON", func() { + original := cpi.HTTPSettings{ + Timeout: dur("5m30s"), + TCPDialTimeout: dur("15s"), + } + data, err := json.Marshal(original) + Expect(err).To(Succeed()) + + var restored cpi.HTTPSettings + Expect(json.Unmarshal(data, &restored)).To(Succeed()) + Expect(time.Duration(*restored.Timeout)).To(Equal(5*time.Minute + 30*time.Second)) + Expect(time.Duration(*restored.TCPDialTimeout)).To(Equal(15 * time.Second)) + Expect(restored.TCPKeepAlive).To(BeNil()) + }) + }) +}) diff --git a/api/oci/cpi/interface.go b/api/oci/cpi/interface.go index f749217928..67e060a85e 100644 --- a/api/oci/cpi/interface.go +++ b/api/oci/cpi/interface.go @@ -42,6 +42,8 @@ type ( DataAccess = internal.DataAccess RepositorySource = internal.RepositorySource ConsumerIdentityProvider = internal.ConsumerIdentityProvider + Duration = internal.Duration + HTTPSettings = internal.HTTPSettings ) type Descriptor = ociv1.Descriptor diff --git a/api/oci/extensions/repositories/docker/client.go b/api/oci/extensions/repositories/docker/client.go index 4fcf46e767..3886704fc0 100644 --- a/api/oci/extensions/repositories/docker/client.go +++ b/api/oci/extensions/repositories/docker/client.go @@ -5,6 +5,7 @@ package docker import ( "net/http" "os" + "time" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config" @@ -13,11 +14,18 @@ import ( dockerclient "github.com/moby/moby/client" "github.com/spf13/pflag" + "ocm.software/ocm/api/oci/cpi" + "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 *cpi.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 +43,15 @@ 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{ + Transport: logging.NewRoundTripper(transport, logger), + } + if httpCfg.Timeout != nil { + clnt.Timeout = time.Duration(*httpCfg.Timeout) + } 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 31aefba1b3..d301517a2e 100644 --- a/api/oci/extensions/repositories/docker/repository.go +++ b/api/oci/extensions/repositories/docker/repository.go @@ -23,7 +23,11 @@ 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) + httpSettings, err := ctx.GetHTTPSettings() + if err != nil { + return nil, err + } + client, err := newDockerClient(spec.DockerHost, logger, &httpSettings) 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..6f2fd5d071 100644 --- a/api/oci/extensions/repositories/docker/type.go +++ b/api/oci/extensions/repositories/docker/type.go @@ -56,7 +56,11 @@ 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) + httpSettings, err := ctx.GetHTTPSettings() + if err != nil { + return err + } + client, err := newDockerClient(a.DockerHost, logger, &httpSettings) 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..51d03cd690 100644 --- a/api/oci/extensions/repositories/ocireg/repository.go +++ b/api/oci/extensions/repositories/ocireg/repository.go @@ -7,6 +7,7 @@ import ( "net/http" "path" "strings" + "time" "github.com/containerd/errdefs" "github.com/mandelsoft/goutils/errors" @@ -22,6 +23,7 @@ import ( "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,28 +155,18 @@ func (r *RepositoryImpl) getResolver(comp string) (oras.Resolver, error) { } } - client := retry.DefaultClient - client.Transport = ocmlog.NewRoundTripper(retry.DefaultClient.Transport, logger) - 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{ - // MinVersion: tls.VersionTLS13, - RootCAs: func() *x509.CertPool { - rootCAs := rootcertsattr.Get(r.GetContext()).GetRootCertPool(true) - if creds != nil { - c := creds.GetProperty(credentials.ATTR_CERTIFICATE_AUTHORITY) - if c != "" { - rootCAs.AppendCertsFromPEM([]byte(c)) - } - } + baseTransport, timeout, err := configureTransport(r.GetContext(), r.info.Scheme, creds) + if err != nil { + return nil, err + } - 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), + } + if timeout != nil { + client.Timeout = *timeout } authClient := &auth.Client{ @@ -197,6 +189,37 @@ func (r *RepositoryImpl) getResolver(comp string) (oras.Resolver, error) { }), nil } +func configureTransport(ctx cpi.Context, scheme string, creds credentials.Credentials) (*http.Transport, *time.Duration, error) { + httpSettings, err := ctx.GetHTTPSettings() + if err != nil { + return nil, nil, err + } + baseTransport := httpclient.NewTransport(&httpSettings) + var timeout *time.Duration + if httpSettings.Timeout != nil { + timeout = new(time.Duration(*httpSettings.Timeout)) + } + + if scheme == "https" { + //nolint:gosec // used like the default, there are OCI servers (quay.io) not working with min version. + baseTransport.TLSClientConfig = &tls.Config{ + // MinVersion: tls.VersionTLS13, + RootCAs: func() *x509.CertPool { + rootCAs := rootcertsattr.Get(ctx).GetRootCertPool(true) + if creds != nil { + c := creds.GetProperty(credentials.ATTR_CERTIFICATE_AUTHORITY) + if c != "" { + rootCAs.AppendCertsFromPEM([]byte(c)) + } + } + return rootCAs + }(), + } + } + + return baseTransport, timeout, nil +} + func (r *RepositoryImpl) GetRef(comp, vers string) string { base := path.Join(r.info.Locator, comp) if vers == "" { diff --git a/api/oci/extensions/repositories/ocireg/repository_test.go b/api/oci/extensions/repositories/ocireg/repository_test.go new file mode 100644 index 0000000000..6e8646ec94 --- /dev/null +++ b/api/oci/extensions/repositories/ocireg/repository_test.go @@ -0,0 +1,52 @@ +package ocireg + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "ocm.software/ocm/api/credentials" + "ocm.software/ocm/api/oci/cpi" + common "ocm.software/ocm/api/utils/misc" +) + +func TestConfigureTransport(t *testing.T) { + t.Run("HTTPS sets TLSClientConfig with RootCAs", func(t *testing.T) { + ctx := cpi.New() + transport, _, err := configureTransport(ctx, "https", nil) + + require.NoError(t, err) + require.NotNil(t, transport.TLSClientConfig) + assert.NotNil(t, transport.TLSClientConfig.RootCAs) + }) + + t.Run("HTTP does not set RootCAs", func(t *testing.T) { + ctx := cpi.New() + transport, _, err := configureTransport(ctx, "http", nil) + + require.NoError(t, err) + if transport.TLSClientConfig != nil { + assert.Nil(t, transport.TLSClientConfig.RootCAs) + } + }) + + t.Run("HTTPS appends CA cert from credentials", func(t *testing.T) { + ctx := cpi.New() + + // Self-signed test CA certificate (PEM format). + caCert := `-----BEGIN CERTIFICATE----- +ABC= +-----END CERTIFICATE-----` + + creds := credentials.NewCredentials(common.Properties{ + credentials.ATTR_CERTIFICATE_AUTHORITY: caCert, + }) + + transport, _, err := configureTransport(ctx, "https", creds) + + require.NoError(t, err) + require.NotNil(t, transport.TLSClientConfig) + assert.NotNil(t, transport.TLSClientConfig.RootCAs) + }) +} 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..3b71dd25de --- /dev/null +++ b/api/oci/extensions/repositories/ocireg/timeout_integration_test.go @@ -0,0 +1,227 @@ +//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).ToNot(HaveOccurred(), "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).ToNot(HaveOccurred(), "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).ToNot(HaveOccurred(), "failed to start toxiproxy container") + + host, port, err := toxiContainer.ProxiedEndpoint(8666) + Expect(err).ToNot(HaveOccurred()) + proxyHost = fmt.Sprintf("%s:%s", host, port) + + uri, err := toxiContainer.URI(ctx) + Expect(err).ToNot(HaveOccurred()) + toxiClient := toxiproxy.NewClient(uri) + proxy, err = toxiClient.Proxy(proxyName) + Expect(err).ToNot(HaveOccurred(), "failed to get toxiproxy proxy") + + env = NewTestEnv(envhelper.FileSystem(osfs.New())) + + // Create temp dir and CTF for all tests. + tempDir = GinkgoT().TempDir() + + 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()) + } + }) + + // writeHTTPConfig writes a config file with the given http settings and returns its path. + writeHTTPConfig := func(settings string) string { + cfg := fmt.Sprintf(`{"type":"generic.config.ocm.software/v1","configurations":[{"type":"http.config.ocm.software",%s}]}`, settings) + cfgFile := filepath.Join(tempDir, "httpconfig.yaml") + Expect(os.WriteFile(cfgFile, []byte(cfg), 0o644)).To(Succeed()) + return cfgFile + } + + // Each test sets ONLY the timeout being tested to avoid races between + // different timeout mechanisms (e.g. Client.Timeout vs transport-level). + + It("fails when overall timeout is shorter than proxy latency", func() { + addLatency(proxy, 30_000, "downstream") + defer removeToxic(proxy, "latency") + + // Only set the overall Client.Timeout — no transport-level timeouts. + // The error message varies by Go runtime ("Client.Timeout" vs + // "context deadline exceeded") depending on which layer catches + // the cancelled context first, but the cause is unambiguous. + 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"), + )) + }) + + It("succeeds when overall timeout exceeds proxy latency", func() { + addLatency(proxy, 1_000, "downstream") + 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 header timeout expires", func() { + addLatency(proxy, 3_000, "downstream") + defer removeToxic(proxy, "latency") + + // Only set responseHeaderTimeout — no overall Client.Timeout so + // the transport-level timeout is the only one that can fire. + cfgFile := writeHTTPConfig(`"responseHeaderTimeout":"100ms"`) + registryURL := "http://" + proxyHost + err := env.Execute( + "--config", cfgFile, + "transfer", "componentversions", + ctfDir+"//"+componentName+":"+componentVersion, + registryURL, + ) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timeout awaiting response headers")) + }) + + 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", func() { + // Only set tcpDialTimeout — no other timeouts. + 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(ContainSubstring("i/o timeout")) + }) + +}) + +func addLatency(proxy *toxiproxy.Proxy, latencyMs int, stream string) { + _, err := proxy.AddToxic("latency", "latency", stream, 1.0, toxiproxy.Attributes{ + "latency": latencyMs, + }) + Expect(err).ToNot(HaveOccurred()) +} + +func removeToxic(proxy *toxiproxy.Proxy, name string) { + err := proxy.RemoveToxic(name) + Expect(err).ToNot(HaveOccurred()) +} diff --git a/api/oci/internal/context.go b/api/oci/internal/context.go index 883d8caac1..3289824306 100644 --- a/api/oci/internal/context.go +++ b/api/oci/internal/context.go @@ -38,6 +38,9 @@ type Context interface { GetAlias(name string) RepositorySpec SetAlias(name string, spec RepositorySpec) + + GetHTTPSettings() (HTTPSettings, error) + SetHTTPSettings(s *HTTPSettings) } var key = reflect.TypeOf(_context{}) @@ -80,6 +83,7 @@ type _context struct { knownRepositoryTypes RepositoryTypeScheme specHandlers RepositorySpecHandlers aliases map[string]RepositorySpec + httpSettings HTTPSettings } var ( @@ -109,6 +113,7 @@ func (w *gcWrapper) SetContext(c *_context) { func newContext(credctx credentials.Context, reposcheme RepositoryTypeScheme, specHandlers RepositorySpecHandlers, delegates datacontext.Delegates) Context { c := &_context{ credentials: datacontext.PersistentContextRef(credctx), + httpSettings: HTTPSettings{}, knownRepositoryTypes: reposcheme, specHandlers: specHandlers, aliases: map[string]RepositorySpec{}, @@ -193,3 +198,19 @@ func (c *_context) SetAlias(name string, spec RepositorySpec) { defer c.updater.Unlock() c.aliases[name] = spec } + +func (c *_context) GetHTTPSettings() (HTTPSettings, error) { + err := c.updater.Update() + if err != nil { + return HTTPSettings{}, err + } + c.updater.RLock() + defer c.updater.RUnlock() + return c.httpSettings, nil +} + +func (c *_context) SetHTTPSettings(s *HTTPSettings) { + c.updater.Lock() + defer c.updater.Unlock() + c.httpSettings = *s +} diff --git a/api/oci/internal/httpsettings.go b/api/oci/internal/httpsettings.go new file mode 100644 index 0000000000..f64c7005bf --- /dev/null +++ b/api/oci/internal/httpsettings.go @@ -0,0 +1,86 @@ +package internal + +import ( + "encoding/json" + "fmt" + "time" +) + +// Duration is a time.Duration that marshals to/from a human-readable +// Go duration string (e.g. "30s", "5m") in JSON/YAML. +type Duration time.Duration + +// UnmarshalJSON implements the json.Unmarshaller interface. +// It parses a quoted Go duration string (e.g. "30s", "1h5m") into a Duration. +func (d *Duration) UnmarshalJSON(b []byte) error { + var str string + if err := json.Unmarshal(b, &str); err != nil { + return fmt.Errorf("invalid duration %s: expected a Go duration string (e.g. \"30s\", \"5m\", \"1h30m\")", string(b)) + } + pd, err := time.ParseDuration(str) + if err != nil { + return fmt.Errorf("invalid duration %q: expected a Go duration string (e.g. \"30s\", \"5m\", \"1h30m\")", str) + } + *d = Duration(pd) + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +// It encodes the Duration as a quoted Go duration string (e.g. "30s", "1h5m"). +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +// 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. + // If zero, probes are sent with a default value (currently 15 seconds). + // If negative, keep-alive probes are disabled. + 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"` +} + +// Validate checks that timeout values are non-negative. +// TCPKeepAlive is not validated because any negative value +// disables keep-alive probes (consistent with Go's net.Dialer.KeepAlive). +func (s *HTTPSettings) Validate() error { + for _, check := range []struct { + name string + val *Duration + }{ + {"timeout", s.Timeout}, + {"tcpDialTimeout", s.TCPDialTimeout}, + {"tlsHandshakeTimeout", s.TLSHandshakeTimeout}, + {"responseHeaderTimeout", s.ResponseHeaderTimeout}, + {"idleConnTimeout", s.IdleConnTimeout}, + } { + if check.val != nil && time.Duration(*check.val) < 0 { + return fmt.Errorf("invalid value for %s: %s, must be zero or positive", check.name, time.Duration(*check.val)) + } + } + return nil +} diff --git a/api/utils/httpclient/transport.go b/api/utils/httpclient/transport.go new file mode 100644 index 0000000000..1b96550abd --- /dev/null +++ b/api/utils/httpclient/transport.go @@ -0,0 +1,60 @@ +package httpclient + +import ( + "net" + "net/http" + "time" + + "ocm.software/ocm/api/oci/cpi" +) + +// Default dialer timeouts matching http.DefaultTransport. +const ( + defaultDialTimeout = 30 * time.Second + defaultKeepAlive = 30 * time.Second +) + +// NewTransport creates an *http.Transport that starts as a clone of +// http.DefaultTransport and selectively overrides timeouts from cfg. +func NewTransport(cfg *cpi.HTTPSettings) *http.Transport { + dt, ok := http.DefaultTransport.(*http.Transport) + if !ok { + dt = &http.Transport{} + } + transport := dt.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: defaultDialTimeout, + KeepAlive: defaultKeepAlive, + } + if cfg.TCPDialTimeout != nil { + dialer.Timeout = time.Duration(*cfg.TCPDialTimeout) + } + if cfg.TCPKeepAlive != nil { + dialer.KeepAlive = time.Duration(*cfg.TCPKeepAlive) + } + transport.DialContext = dialer.DialContext + } + + if cfg.TLSHandshakeTimeout != nil { + transport.TLSHandshakeTimeout = time.Duration(*cfg.TLSHandshakeTimeout) + } + + if cfg.ResponseHeaderTimeout != nil { + transport.ResponseHeaderTimeout = time.Duration(*cfg.ResponseHeaderTimeout) + } + + if cfg.IdleConnTimeout != nil { + transport.IdleConnTimeout = time.Duration(*cfg.IdleConnTimeout) + } + + return transport +} diff --git a/api/utils/httpclient/transport_test.go b/api/utils/httpclient/transport_test.go new file mode 100644 index 0000000000..344dbed7fb --- /dev/null +++ b/api/utils/httpclient/transport_test.go @@ -0,0 +1,114 @@ +package httpclient_test + +import ( + "net/http" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + ocicpi "ocm.software/ocm/api/oci/cpi" + "ocm.software/ocm/api/utils/httpclient" +) + +func dur(s string) *ocicpi.Duration { + td, err := time.ParseDuration(s) + if err != nil { + panic(err) + } + d := ocicpi.Duration(td) + return &d +} + +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(&ocicpi.HTTPSettings{}) + 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 individual fields are set", func() { + It("overrides TLSHandshakeTimeout only", func() { + tr := httpclient.NewTransport(&ocicpi.HTTPSettings{ + TLSHandshakeTimeout: dur("5s"), + }) + Expect(tr.TLSHandshakeTimeout).To(Equal(5 * time.Second)) + Expect(tr.IdleConnTimeout).To(Equal(defaultTransport.IdleConnTimeout)) + }) + + It("overrides IdleConnTimeout only", func() { + tr := httpclient.NewTransport(&ocicpi.HTTPSettings{ + IdleConnTimeout: dur("120s"), + }) + Expect(tr.IdleConnTimeout).To(Equal(120 * time.Second)) + Expect(tr.TLSHandshakeTimeout).To(Equal(defaultTransport.TLSHandshakeTimeout)) + }) + + It("overrides ResponseHeaderTimeout only", func() { + tr := httpclient.NewTransport(&ocicpi.HTTPSettings{ + ResponseHeaderTimeout: dur("20s"), + }) + 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(&ocicpi.HTTPSettings{ + TCPDialTimeout: dur("15s"), + }) + Expect(tr.DialContext).NotTo(BeNil()) + Expect(tr.TLSHandshakeTimeout).To(Equal(defaultTransport.TLSHandshakeTimeout)) + }) + + It("replaces DialContext when negative TCPKeepAlive disables probes", func() { + tr := httpclient.NewTransport(&ocicpi.HTTPSettings{ + TCPKeepAlive: dur("-1s"), + }) + Expect(tr.DialContext).NotTo(BeNil()) + }) + }) + + Context("when all fields are set", func() { + It("applies all values and preserves non-timeout defaults", func() { + tr := httpclient.NewTransport(&ocicpi.HTTPSettings{ + TCPDialTimeout: dur("1s"), + TCPKeepAlive: dur("2s"), + TLSHandshakeTimeout: dur("3s"), + ResponseHeaderTimeout: dur("4s"), + IdleConnTimeout: dur("5s"), + }) + 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_configfile.md b/docs/reference/ocm_configfile.md index a16112ea04..88af2289ce 100644 --- a/docs/reference/ocm_configfile.md +++ b/docs/reference/ocm_configfile.md @@ -129,6 +129,52 @@ 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: generic.config.ocm.software/v1
+      configurations:
+        - 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. + + The fields have the following meaning: + + - timeout — specifies a time limit for requests made by the HTTP + client. The timeout includes connection time, any redirects, and reading + the response body. A timeout of zero means no timeout. + + - tcpDialTimeout — the maximum amount of time a dial will wait + for a TCP connect to complete. When dialing a host name with multiple IP + addresses, the timeout may be divided between them. The operating system + may impose its own earlier timeout. + + - tcpKeepAlive — specifies the interval between keep-alive + probes for an active network connection. If negative, keep-alive probes + are disabled. + + - tlsHandshakeTimeout — specifies the maximum amount of time + to wait for a TLS handshake. Zero means no timeout. + + - responseHeaderTimeout — specifies the amount of time to wait + for a server's response headers after fully writing the request (including + its body, if any). This time does not include the time to read the response + body. + + - idleConnTimeout — the maximum amount of time an idle + (keep-alive) connection will remain idle before closing itself. Zero means + no limit. - 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 8588c867fc..761252ead1 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.5 github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/credentials v1.19.14 @@ -68,6 +69,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/registry v0.42.0 + github.com/testcontainers/testcontainers-go/modules/toxiproxy v0.42.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 b83d39fc73..e4ee433d4b 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= @@ -561,6 +563,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= @@ -911,6 +915,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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/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= @@ -1206,8 +1212,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.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY= github.com/testcontainers/testcontainers-go/modules/registry v0.42.0 h1:3tvpqgK6nVwEH2B0SChZIs1Ajla6FDL/NazMUV0Rj2E= github.com/testcontainers/testcontainers-go/modules/registry v0.42.0/go.mod h1:ygb3Jb/mfRvFtsaR4SBtHitJOvVMJNjNloowhswwT34= +github.com/testcontainers/testcontainers-go/modules/toxiproxy v0.42.0 h1:BW/+geTayECivCdk5HvO5otFEG0gWtuy2eaxBbPcHxk= +github.com/testcontainers/testcontainers-go/modules/toxiproxy v0.42.0/go.mod h1:ki/eZfOboEaeeLg15TV07Mk/QY8Nwf582IujDSp3KSk= 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=