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=