Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/config/wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ templater
templaters
templating
tgz
tcp
tls
todo
toi
Expand Down
109 changes: 109 additions & 0 deletions api/oci/config/httpconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package config
Comment thread
piotrjanik marked this conversation as resolved.

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 {
Comment thread
jakobmoellerdev marked this conversation as resolved.
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 <code>` + HTTPConfigType + `</code> can be used to configure
HTTP client settings:

<pre>
type: generic.config.ocm.software/v1
configurations:
- type: ` + HTTPConfigType + `
timeout: "0s"
tcpDialTimeout: "30s"
tcpKeepAlive: "30s"
tlsHandshakeTimeout: "10s"
responseHeaderTimeout: "0s"
idleConnTimeout: "90s"
</pre>

All timeout values are Go duration strings (e.g. "30s", "5m", "1h").
Use "0s" to disable a specific timeout. If not set, the <code>http.DefaultTransport</code>
values from the Go standard library are used.

The fields have the following meaning:

- <code>timeout</code> &mdash; 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.

- <code>tcpDialTimeout</code> &mdash; 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.

- <code>tcpKeepAlive</code> &mdash; specifies the interval between keep-alive
probes for an active network connection. If negative, keep-alive probes
are disabled.

- <code>tlsHandshakeTimeout</code> &mdash; specifies the maximum amount of time
to wait for a TLS handshake. Zero means no timeout.

- <code>responseHeaderTimeout</code> &mdash; 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.

- <code>idleConnTimeout</code> &mdash; the maximum amount of time an idle
(keep-alive) connection will remain idle before closing itself. Zero means
no limit.
`
193 changes: 193 additions & 0 deletions api/oci/config/httpconfig_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
})
})
2 changes: 2 additions & 0 deletions api/oci/cpi/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type (
DataAccess = internal.DataAccess
RepositorySource = internal.RepositorySource
ConsumerIdentityProvider = internal.ConsumerIdentityProvider
Duration = internal.Duration
HTTPSettings = internal.HTTPSettings
)

type Descriptor = ociv1.Descriptor
Expand Down
21 changes: 18 additions & 3 deletions api/oci/extensions/repositories/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package docker
import (
"net/http"
"os"
"time"

"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config"
Expand All @@ -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))
Expand All @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion api/oci/extensions/repositories/docker/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion api/oci/extensions/repositories/docker/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading