Files
aac1a8f39c [confmap] Stabilize confmap validation facilities (#15142)
<!--Ex. Fixing a bug - Describe the bug and how this fixes the issue.
Ex. Adding a feature - Explain what this achieves.-->
#### Description

The `xconfmap.Validator` interface has existed in some form for a very
long time now and is ready to be stabilized. Similarly, the
`xconfmap.Validate` method has existed in it's current state for over a
year and doesn't have any open issues I'm aware of. It should also be
ready for stabilization.

Many modules depend on `xconfmap` merely for the validation facilities
(as can be seen by the go.mod diffs), so this will allow them to depend
on only stable modules.

---------

Co-authored-by: Alex Boten <[email protected]>
2026-05-08 13:33:15 +00:00

736 lines
22 KiB
Go

// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package confighttp
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.opentelemetry.io/collector/client"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/component/componenttest"
"go.opentelemetry.io/collector/config/configauth"
"go.opentelemetry.io/collector/config/configcompression"
"go.opentelemetry.io/collector/config/configmiddleware"
"go.opentelemetry.io/collector/config/configopaque"
"go.opentelemetry.io/collector/config/configoptional"
"go.opentelemetry.io/collector/config/configtls"
"go.opentelemetry.io/collector/confmap"
"go.opentelemetry.io/collector/confmap/confmaptest"
"go.opentelemetry.io/collector/extension"
"go.opentelemetry.io/collector/extension/extensionauth"
"go.opentelemetry.io/collector/extension/extensionauth/extensionauthtest"
)
var (
testAuthID = component.MustNewID("testauth")
mockID = component.MustNewID("mock")
dummyID = component.MustNewID("dummy")
nonExistingID = component.MustNewID("nonexisting")
// Omit TracerProvider and MeterProvider in TelemetrySettings as otelhttp.Transport cannot be introspected
nilProvidersSettings = component.TelemetrySettings{Logger: zap.NewNop()}
)
func TestAllHTTPClientSettings(t *testing.T) {
extensions := map[component.ID]component.Component{
testAuthID: extensionauthtest.NewNopClient(),
}
maxIdleConns := 50
maxIdleConnsPerHost := 40
maxConnsPerHost := 45
idleConnTimeout := 30 * time.Second
http2PingTimeout := 5 * time.Second
tests := []struct {
name string
settings ClientConfig
shouldError bool
}{
{
name: "all_valid_settings",
settings: ClientConfig{
Endpoint: "localhost:1234",
TLS: configtls.ClientConfig{
Insecure: false,
},
ReadBufferSize: 1024,
WriteBufferSize: 512,
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxConnsPerHost: maxConnsPerHost,
IdleConnTimeout: idleConnTimeout,
Compression: "",
DisableKeepAlives: true,
Cookies: configoptional.Some(CookiesConfig{}),
HTTP2ReadIdleTimeout: idleConnTimeout,
HTTP2PingTimeout: http2PingTimeout,
},
shouldError: false,
},
{
name: "all_valid_settings_http2_enabled",
settings: ClientConfig{
Endpoint: "localhost:1234",
TLS: configtls.ClientConfig{
Insecure: false,
},
ReadBufferSize: 1024,
WriteBufferSize: 512,
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxConnsPerHost: maxConnsPerHost,
ForceAttemptHTTP2: true,
IdleConnTimeout: idleConnTimeout,
Compression: "",
DisableKeepAlives: true,
Cookies: configoptional.Some(CookiesConfig{}),
HTTP2ReadIdleTimeout: idleConnTimeout,
HTTP2PingTimeout: http2PingTimeout,
},
shouldError: false,
},
{
name: "all_valid_settings_with_none_compression",
settings: ClientConfig{
Endpoint: "localhost:1234",
TLS: configtls.ClientConfig{
Insecure: false,
},
ReadBufferSize: 1024,
WriteBufferSize: 512,
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxConnsPerHost: maxConnsPerHost,
IdleConnTimeout: idleConnTimeout,
Compression: "none",
DisableKeepAlives: true,
HTTP2ReadIdleTimeout: idleConnTimeout,
HTTP2PingTimeout: http2PingTimeout,
},
shouldError: false,
},
{
name: "all_valid_settings_with_gzip_compression",
settings: ClientConfig{
Endpoint: "localhost:1234",
TLS: configtls.ClientConfig{
Insecure: false,
},
ReadBufferSize: 1024,
WriteBufferSize: 512,
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxConnsPerHost: maxConnsPerHost,
IdleConnTimeout: idleConnTimeout,
Compression: "gzip",
DisableKeepAlives: true,
HTTP2ReadIdleTimeout: idleConnTimeout,
HTTP2PingTimeout: http2PingTimeout,
},
shouldError: false,
},
{
name: "all_valid_settings_http2_health_check",
settings: ClientConfig{
Endpoint: "localhost:1234",
TLS: configtls.ClientConfig{
Insecure: false,
},
ReadBufferSize: 1024,
WriteBufferSize: 512,
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxConnsPerHost: maxConnsPerHost,
IdleConnTimeout: idleConnTimeout,
Compression: "gzip",
DisableKeepAlives: true,
HTTP2ReadIdleTimeout: idleConnTimeout,
HTTP2PingTimeout: http2PingTimeout,
},
shouldError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tel := componenttest.NewNopTelemetrySettings()
tel.TracerProvider = nil
client, err := tt.settings.ToClient(context.Background(), extensions, tel)
if tt.shouldError {
assert.Error(t, err)
return
}
require.NoError(t, err)
switch transport := client.Transport.(type) {
case *http.Transport:
assert.Equal(t, 1024, transport.ReadBufferSize)
assert.Equal(t, 512, transport.WriteBufferSize)
assert.Equal(t, 50, transport.MaxIdleConns)
assert.Equal(t, 40, transport.MaxIdleConnsPerHost)
assert.Equal(t, 45, transport.MaxConnsPerHost)
assert.Equal(t, 30*time.Second, transport.IdleConnTimeout)
assert.True(t, transport.DisableKeepAlives)
case *compressRoundTripper:
assert.EqualValues(t, "gzip", transport.compressionType)
}
})
}
}
func TestPartialHTTPClientSettings(t *testing.T) {
extensions := map[component.ID]component.Component{
testAuthID: extensionauthtest.NewNopClient(),
}
tests := []struct {
name string
settings ClientConfig
shouldError bool
}{
{
name: "valid_partial_settings",
settings: ClientConfig{
Endpoint: "localhost:1234",
TLS: configtls.ClientConfig{
Insecure: false,
},
ReadBufferSize: 1024,
WriteBufferSize: 512,
},
shouldError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tel := componenttest.NewNopTelemetrySettings()
tel.TracerProvider = nil
client, err := tt.settings.ToClient(context.Background(), extensions, tel)
require.NoError(t, err)
transport := client.Transport.(*http.Transport)
assert.Equal(t, 1024, transport.ReadBufferSize)
assert.Equal(t, 512, transport.WriteBufferSize)
assert.Equal(t, 0, transport.MaxIdleConns)
assert.Equal(t, 0, transport.MaxIdleConnsPerHost)
assert.Equal(t, 0, transport.MaxConnsPerHost)
assert.EqualValues(t, 0, transport.IdleConnTimeout)
assert.False(t, transport.DisableKeepAlives)
})
}
}
func TestDefaultHTTPClientSettings(t *testing.T) {
httpClientSettings := NewDefaultClientConfig()
assert.Equal(t, 100, httpClientSettings.MaxIdleConns)
assert.Equal(t, 90*time.Second, httpClientSettings.IdleConnTimeout)
}
func TestProxyURL(t *testing.T) {
testCases := []struct {
name string
proxyURL string
expectedURL *url.URL
err bool
}{
{
name: "default config",
expectedURL: nil,
},
{
name: "proxy is set",
proxyURL: "http://proxy.example.com:8080",
expectedURL: &url.URL{Scheme: "http", Host: "proxy.example.com:8080"},
},
{
name: "proxy is invalid",
proxyURL: "://example.com",
err: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
s := NewDefaultClientConfig()
s.ProxyURL = tt.proxyURL
tel := componenttest.NewNopTelemetrySettings()
tel.TracerProvider = nil
client, err := s.ToClient(context.Background(), nil, tel)
if tt.err {
require.Error(t, err)
} else {
require.NoError(t, err)
}
if err == nil {
transport := client.Transport.(*http.Transport)
require.NotNil(t, transport.Proxy)
url, err := transport.Proxy(&http.Request{URL: &url.URL{Scheme: "http", Host: "example.com"}})
require.NoError(t, err)
if tt.expectedURL == nil {
assert.Nil(t, url)
} else {
require.NotNil(t, url)
assert.Equal(t, tt.expectedURL, url)
}
}
})
}
}
func TestHTTPClientSettingsError(t *testing.T) {
extensions := map[component.ID]component.Component{}
tests := []struct {
settings ClientConfig
err string
}{
{
err: "^failed to load TLS config: failed to load CA CertPool File: failed to load cert /doesnt/exist:",
settings: ClientConfig{
Endpoint: "",
TLS: configtls.ClientConfig{
Config: configtls.Config{
CAFile: "/doesnt/exist",
},
Insecure: false,
ServerName: "",
},
},
},
{
err: "^failed to load TLS config: failed to load TLS cert and key: for auth via TLS, provide both certificate and key, or neither",
settings: ClientConfig{
Endpoint: "",
TLS: configtls.ClientConfig{
Config: configtls.Config{
CertFile: "/doesnt/exist",
},
Insecure: false,
ServerName: "",
},
},
},
{
err: "failed to resolve authenticator \"dummy\": authenticator not found",
settings: ClientConfig{
Endpoint: "https://localhost:1234/v1/traces",
Auth: configoptional.Some(configauth.Config{AuthenticatorID: dummyID}),
},
},
}
for _, tt := range tests {
t.Run(tt.err, func(t *testing.T) {
_, err := tt.settings.ToClient(context.Background(), extensions, componenttest.NewNopTelemetrySettings())
assert.Regexp(t, tt.err, err)
})
}
}
var _ http.RoundTripper = &customRoundTripper{}
type customRoundTripper struct{}
func (c *customRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
return nil, nil
}
var (
_ extensionauth.HTTPClient = (*mockClient)(nil)
_ extension.Extension = (*mockClient)(nil)
)
type mockClient struct {
component.StartFunc
component.ShutdownFunc
}
// RoundTripper implements extensionauth.HTTPClient.
func (m *mockClient) RoundTripper(http.RoundTripper) (http.RoundTripper, error) {
return &customRoundTripper{}, nil
}
func TestHTTPClientSettingWithAuthConfig(t *testing.T) {
tests := []struct {
name string
shouldErr bool
settings ClientConfig
extensions map[component.ID]component.Component
}{
{
name: "no_auth_extension_enabled",
settings: ClientConfig{
Endpoint: "localhost:1234",
Auth: configoptional.None[configauth.Config](),
},
shouldErr: false,
extensions: map[component.ID]component.Component{
mockID: extensionauthtest.NewNopClient(),
},
},
{
name: "with_auth_configuration_and_no_extension",
settings: ClientConfig{
Endpoint: "localhost:1234",
Auth: configoptional.Some(configauth.Config{AuthenticatorID: dummyID}),
},
shouldErr: true,
extensions: map[component.ID]component.Component{
mockID: extensionauthtest.NewNopClient(),
},
},
{
name: "with_auth_configuration_and_no_extension_map",
settings: ClientConfig{
Endpoint: "localhost:1234",
Auth: configoptional.Some(configauth.Config{AuthenticatorID: dummyID}),
},
shouldErr: true,
},
{
name: "with_auth_configuration_has_extension",
settings: ClientConfig{
Endpoint: "localhost:1234",
Auth: configoptional.Some(configauth.Config{AuthenticatorID: mockID}),
},
shouldErr: false,
extensions: map[component.ID]component.Component{
mockID: &mockClient{},
},
},
{
name: "with_auth_configuration_has_extension_and_headers",
settings: ClientConfig{
Endpoint: "localhost:1234",
Auth: configoptional.Some(configauth.Config{AuthenticatorID: mockID}),
Headers: configopaque.MapList{
{Name: "foo", Value: "bar"},
},
},
shouldErr: false,
extensions: map[component.ID]component.Component{
mockID: &mockClient{},
},
},
{
name: "with_auth_configuration_has_extension_and_compression",
settings: ClientConfig{
Endpoint: "localhost:1234",
Auth: configoptional.Some(configauth.Config{AuthenticatorID: mockID}),
Compression: configcompression.TypeGzip,
},
shouldErr: false,
extensions: map[component.ID]component.Component{
mockID: &mockClient{},
},
},
{
name: "with_auth_configuration_has_err_extension",
settings: ClientConfig{
Endpoint: "localhost:1234",
Auth: configoptional.Some(configauth.Config{AuthenticatorID: mockID}),
},
shouldErr: true,
extensions: map[component.ID]component.Component{
mockID: extensionauthtest.NewErr(errors.New("error")),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Omit TracerProvider and MeterProvider in TelemetrySettings as otelhttp.Transport cannot be introspected
client, err := tt.settings.ToClient(context.Background(), tt.extensions, nilProvidersSettings)
if tt.shouldErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.NotNil(t, client)
transport := client.Transport
// Compression should wrap Auth, unwrap it
if tt.settings.Compression.IsCompressed() {
ct, ok := transport.(*compressRoundTripper)
assert.True(t, ok)
assert.Equal(t, tt.settings.Compression, ct.compressionType)
transport = ct.rt
}
// Headers should wrap Auth, unwrap it
if tt.settings.Headers != nil {
ht, ok := transport.(*headerRoundTripper)
assert.True(t, ok)
assert.Equal(t, tt.settings.Headers, ht.headers)
transport = ht.transport
}
if tt.settings.Auth.HasValue() {
_, ok := transport.(*customRoundTripper)
assert.True(t, ok)
}
})
}
}
func TestHTTPClientHeaders(t *testing.T) {
tests := []struct {
name string
headers configopaque.MapList
}{
{
name: "with_headers",
headers: configopaque.MapList{
{Name: "header1", Value: "value1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range tt.headers.Iter {
assert.Equal(t, r.Header.Get(k), string(v))
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
serverURL, _ := url.Parse(server.URL)
setting := ClientConfig{
Endpoint: serverURL.String(),
TLS: configtls.ClientConfig{},
ReadBufferSize: 0,
WriteBufferSize: 0,
Timeout: 0,
Headers: tt.headers,
}
client, _ := setting.ToClient(context.Background(), nil, componenttest.NewNopTelemetrySettings())
req, err := http.NewRequest(http.MethodGet, setting.Endpoint, http.NoBody)
require.NoError(t, err)
_, err = client.Do(req)
assert.NoError(t, err)
})
}
}
func TestHTTPClientHostHeader(t *testing.T) {
hostHeader := "th"
tt := struct {
name string
headers configopaque.MapList
}{
name: "with_host_header",
headers: configopaque.MapList{
{Name: "Host", Value: configopaque.String(hostHeader)},
},
}
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, hostHeader, r.Host)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
serverURL, _ := url.Parse(server.URL)
setting := ClientConfig{
Endpoint: serverURL.String(),
TLS: configtls.ClientConfig{},
ReadBufferSize: 0,
WriteBufferSize: 0,
Timeout: 0,
Headers: tt.headers,
}
client, _ := setting.ToClient(context.Background(), nil, componenttest.NewNopTelemetrySettings())
req, err := http.NewRequest(http.MethodGet, setting.Endpoint, http.NoBody)
require.NoError(t, err)
_, err = client.Do(req)
assert.NoError(t, err)
})
}
func TestHTTPTransportOptions(t *testing.T) {
settings := componenttest.NewNopTelemetrySettings()
// Disable OTel instrumentation so the *http.Transport object is directly accessible
settings.MeterProvider = nil
settings.TracerProvider = nil
clientConfig := NewDefaultClientConfig()
clientConfig.MaxIdleConns = 100
clientConfig.IdleConnTimeout = time.Duration(100)
clientConfig.MaxConnsPerHost = 100
clientConfig.MaxIdleConnsPerHost = 100
client, err := clientConfig.ToClient(context.Background(), nil, settings)
require.NoError(t, err)
transport, ok := client.Transport.(*http.Transport)
require.True(t, ok, "client.Transport is not an *http.Transport")
require.Equal(t, 100, transport.MaxIdleConns)
require.Equal(t, time.Duration(100), transport.IdleConnTimeout)
require.Equal(t, 100, transport.MaxConnsPerHost)
require.Equal(t, 100, transport.MaxIdleConnsPerHost)
clientConfig = NewDefaultClientConfig()
clientConfig.MaxIdleConns = 0
clientConfig.IdleConnTimeout = 0
clientConfig.MaxConnsPerHost = 0
clientConfig.IdleConnTimeout = time.Duration(0)
client, err = clientConfig.ToClient(context.Background(), nil, settings)
require.NoError(t, err)
transport, ok = client.Transport.(*http.Transport)
require.True(t, ok, "client.Transport is not an *http.Transport")
require.Equal(t, 0, transport.MaxIdleConns)
require.Equal(t, time.Duration(0), transport.IdleConnTimeout)
require.Equal(t, 0, transport.MaxConnsPerHost)
require.Equal(t, 0, transport.MaxIdleConnsPerHost)
}
func TestContextWithClient(t *testing.T) {
testCases := []struct {
name string
input *http.Request
doMetadata bool
expected client.Info
}{
{
name: "request without client IP or headers",
input: &http.Request{},
expected: client.Info{},
},
{
name: "request with client IP",
input: &http.Request{
RemoteAddr: "1.2.3.4:55443",
},
expected: client.Info{
Addr: &net.IPAddr{
IP: net.IPv4(1, 2, 3, 4),
},
},
},
{
name: "request with client headers, no metadata processing",
input: &http.Request{
Header: map[string][]string{"x-tt-header": {"tt-value"}},
},
doMetadata: false,
expected: client.Info{},
},
{
name: "request with client headers",
input: &http.Request{
Header: map[string][]string{"x-tt-header": {"tt-value"}},
},
doMetadata: true,
expected: client.Info{
Metadata: client.NewMetadata(map[string][]string{"x-tt-header": {"tt-value"}}),
},
},
{
name: "request with Host and client headers",
input: &http.Request{
Header: map[string][]string{"x-tt-header": {"tt-value"}},
Host: "localhost:55443",
},
doMetadata: true,
expected: client.Info{
Metadata: client.NewMetadata(map[string][]string{"x-tt-header": {"tt-value"}, "Host": {"localhost:55443"}}),
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := contextWithClient(tt.input, tt.doMetadata)
assert.Equal(t, tt.expected, client.FromContext(ctx))
})
}
}
// TestUnmarshalYAMLWithMiddlewares tests that the "middlewares" field is correctly
// parsed from YAML configurations (fixing the bug where "middleware" was used instead)
func TestClientUnmarshalYAMLWithMiddlewares(t *testing.T) {
cm, err := confmaptest.LoadConf(filepath.Join("testdata", "middlewares.yaml"))
require.NoError(t, err)
// Test client configuration
var clientConfig ClientConfig
clientSub, err := cm.Sub("client")
require.NoError(t, err)
require.NoError(t, clientSub.Unmarshal(&clientConfig))
// Validate the client configuration using reflection-based validation
require.NoError(t, confmap.Validate(&clientConfig), "Client configuration should be valid")
assert.Equal(t, "http://localhost:4318/v1/traces", clientConfig.Endpoint)
require.Len(t, clientConfig.Middlewares, 2)
assert.Equal(t, component.MustNewID("fancy_middleware"), clientConfig.Middlewares[0].ID)
assert.Equal(t, component.MustNewID("careful_middleware"), clientConfig.Middlewares[1].ID)
}
// TestUnmarshalYAMLComprehensiveConfig tests the complete configuration example
// to ensure all fields including middlewares are parsed correctly
func TestClientUnmarshalYAMLComprehensiveConfig(t *testing.T) {
cm, err := confmaptest.LoadConf(filepath.Join("testdata", "config.yaml"))
require.NoError(t, err)
// Test client configuration
var clientConfig ClientConfig
clientSub, err := cm.Sub("client")
require.NoError(t, err)
require.NoError(t, clientSub.Unmarshal(&clientConfig))
// Validate the client configuration using reflection-based validation
require.NoError(t, confmap.Validate(&clientConfig), "Client configuration should be valid")
// Verify basic fields
assert.Equal(t, "http://example.com:4318/v1/traces", clientConfig.Endpoint)
assert.Equal(t, "http://proxy.example.com:8080", clientConfig.ProxyURL)
assert.Equal(t, 30*time.Second, clientConfig.Timeout)
assert.Equal(t, 4096, clientConfig.ReadBufferSize)
assert.Equal(t, 4096, clientConfig.WriteBufferSize)
assert.Equal(t, configcompression.TypeGzip, clientConfig.Compression)
// Verify TLS configuration
assert.False(t, clientConfig.TLS.Insecure)
assert.Equal(t, "/path/to/client.crt", clientConfig.TLS.CertFile)
assert.Equal(t, "/path/to/client.key", clientConfig.TLS.KeyFile)
assert.Equal(t, "/path/to/ca.crt", clientConfig.TLS.CAFile)
assert.Equal(t, "example.com", clientConfig.TLS.ServerName)
// Verify headers
expectedHeaders := configopaque.MapList{
{Name: "User-Agent", Value: "OpenTelemetry-Collector/1.0"},
{Name: "X-Custom-Header", Value: "custom-value"},
}
assert.Equal(t, expectedHeaders, clientConfig.Headers)
// Verify middlewares
require.Len(t, clientConfig.Middlewares, 2)
assert.Equal(t, component.MustNewID("middleware1"), clientConfig.Middlewares[0].ID)
assert.Equal(t, component.MustNewID("middleware2"), clientConfig.Middlewares[1].ID)
}
// TestMiddlewaresFieldCompatibility tests that the new "middlewares" field name
// is used instead of the old "middleware" name, ensuring the bug is fixed
func TestClientMiddlewaresFieldCompatibility(t *testing.T) {
// Test that we can create a config with middlewares using the new field name
clientConfig := ClientConfig{
Endpoint: "http://localhost:4318",
Middlewares: []configmiddleware.Config{
{ID: component.MustNewID("test_middleware")},
},
}
assert.Equal(t, "http://localhost:4318", clientConfig.Endpoint)
assert.Len(t, clientConfig.Middlewares, 1)
assert.Equal(t, component.MustNewID("test_middleware"), clientConfig.Middlewares[0].ID)
}