From 1f7546cabd597f84c076529103f5deeec40521d6 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Wed, 26 Aug 2020 16:35:31 -0700 Subject: [PATCH] [Prometheus Remote Write Exporter for Cortex] Add TLS Support and Default HTTP Client (#255) * Start buildClient for creating a default HTTP client * Add TestBuildClient and first subtest * Update TestBuildClient with TLS server, TLS Config, and Proxy URL * Add methods for loading user-supplied certificates * Add buildTLSConfig for creating a new TLS Config struct * Add TLS Config and Proxy URL to buildClient, and update TestBuildClient * Add additional tests for TestBuildClient * Add helper function for generating certificate files * Add helper function for generating CA certificate files * Add helper function for generating serving certificate files * Add helper function for generating client certificate files * Add part of integration test with certificate creation and TLS server * Add helper function for creating the test server's TLS Config struct * Update TestMutualTLS by adding TLS Config to server and client * Run make precommit and fix lint errors * Adjust test for BuildClient * Change certificate loading functions into inline conditionals * Change ProxyURL to be a url.URL instead of a string * Add check for InsecureSkipVerify to avoid parse errors * Change client Transport to use http.DefaultTransport as base * Change require.Nil to require.NoError for error checks * Change require.Error to assert.Error in some areas * Write certificate and key files directly instead of to memory first * Update DialContext timeout and KeepAlive for retrying CI test * Revert increase to DialContext timeout and keepalive to retry CI test --- exporters/metric/cortex/auth.go | 91 +++++ exporters/metric/cortex/auth_test.go | 383 +++++++++++++++++- exporters/metric/cortex/config.go | 3 +- exporters/metric/cortex/cortex.go | 7 +- exporters/metric/cortex/cortex_test.go | 18 +- .../cortex/utils/config_utils_data_test.go | 2 +- .../metric/cortex/utils/config_utils_test.go | 8 +- 7 files changed, 490 insertions(+), 22 deletions(-) diff --git a/exporters/metric/cortex/auth.go b/exporters/metric/cortex/auth.go index b6de32c61f6..f351ae9817e 100644 --- a/exporters/metric/cortex/auth.go +++ b/exporters/metric/cortex/auth.go @@ -15,9 +15,14 @@ package cortex import ( + "crypto/tls" + "crypto/x509" "fmt" "io/ioutil" + "net" "net/http" + "strconv" + "time" ) // ErrFailedToReadFile occurs when a password / bearer token file exists, but could @@ -88,3 +93,89 @@ func (e *Exporter) addBearerTokenAuth(req *http.Request) error { return nil } + +// buildClient returns a http client that uses TLS and has the user-specified proxy and +// timeout. +func (e *Exporter) buildClient() (*http.Client, error) { + // Create a TLS Config struct for use in a custom HTTP Transport. + tlsConfig, err := e.buildTLSConfig() + if err != nil { + return nil, err + } + + // Create a custom HTTP Transport for the client. This is the same as + // http.DefaultTransport other than the TLSClientConfig. + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: tlsConfig, + } + + // Convert proxy url to proxy function for use in the created Transport. + if e.config.ProxyURL != nil { + proxy := http.ProxyURL(e.config.ProxyURL) + transport.Proxy = proxy + } + + client := http.Client{ + Transport: transport, + Timeout: e.config.RemoteTimeout, + } + return &client, nil +} + +// buildTLSConfig creates a new TLS Config struct with the properties from the exporter's +// Config struct. +func (e *Exporter) buildTLSConfig() (*tls.Config, error) { + tlsConfig := &tls.Config{} + if e.config.TLSConfig == nil { + return tlsConfig, nil + } + + // Set the server name if it exists. + if e.config.TLSConfig["server_name"] != "" { + tlsConfig.ServerName = e.config.TLSConfig["server_name"] + } + + // Set InsecureSkipVerify. Viper reads the bool as a string since it is in a map. + if isv, ok := e.config.TLSConfig["insecure_skip_verify"]; ok { + var err error + if tlsConfig.InsecureSkipVerify, err = strconv.ParseBool(isv); err != nil { + return nil, err + } + } + + // Load certificates from CA file if it exists. + caFile := e.config.TLSConfig["ca_file"] + if caFile != "" { + caFileData, err := ioutil.ReadFile(caFile) + if err != nil { + return nil, err + } + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM(caFileData) + tlsConfig.RootCAs = certPool + } + + // Load the client certificate if it exists. + certFile := e.config.TLSConfig["cert_file"] + keyFile := e.config.TLSConfig["key_file"] + if certFile != "" && keyFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + return tlsConfig, nil +} diff --git a/exporters/metric/cortex/auth_test.go b/exporters/metric/cortex/auth_test.go index 61aa57dfa68..a0283c83385 100644 --- a/exporters/metric/cortex/auth_test.go +++ b/exporters/metric/cortex/auth_test.go @@ -15,13 +15,26 @@ package cortex import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/base64" + "encoding/pem" + "fmt" "io/ioutil" + "math/big" + "net" "net/http" "net/http/httptest" + "net/url" "os" + "strings" "testing" + "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -98,7 +111,7 @@ func TestAuthentication(t *testing.T) { handler := func(rw http.ResponseWriter, req *http.Request) { authHeaderValue := req.Header.Get("Authorization") _, err := rw.Write([]byte(authHeaderValue)) - require.Nil(t, err) + require.NoError(t, err) } server := httptest.NewServer(http.HandlerFunc(handler)) defer server.Close() @@ -109,14 +122,14 @@ func TestAuthentication(t *testing.T) { if passwordFile != "" && test.basicAuthPasswordFileContents != nil { filepath := "./" + test.basicAuth["password_file"] err := createFile(test.basicAuthPasswordFileContents, filepath) - require.Nil(t, err) + require.NoError(t, err) defer os.Remove(filepath) } } if test.bearerTokenFile != "" && test.bearerTokenFileContents != nil { filepath := "./" + test.bearerTokenFile err := createFile(test.bearerTokenFileContents, filepath) - require.Nil(t, err) + require.NoError(t, err) defer os.Remove(filepath) } @@ -130,14 +143,14 @@ func TestAuthentication(t *testing.T) { }, } req, err := http.NewRequest(http.MethodPost, server.URL, nil) - require.Nil(t, err) + require.NoError(t, err) err = exporter.addHeaders(req) // Verify the error and if the Authorization header was correctly set. if err != nil { require.Equal(t, err.Error(), test.expectedError.Error()) } else { - require.Nil(t, test.expectedError) + require.NoError(t, test.expectedError) authHeaderValue := req.Header.Get("Authorization") require.Equal(t, authHeaderValue, test.expectedAuthHeaderValue) } @@ -153,3 +166,363 @@ func createFile(bytes []byte, filepath string) error { } return nil } + +// TestBuildClient checks whether the buildClient successfully creates a client that can +// connect over TLS and has the correct remote timeout and proxy url. +func TestBuildClient(t *testing.T) { + testProxyURL, err := url.Parse("123.4.5.6") + require.NoError(t, err) + + tests := []struct { + testName string + config Config + expectedRemoteTimeout time.Duration + expectedErrorSubstring string + }{ + { + testName: "Remote Timeout with Proxy URL", + config: Config{ + ProxyURL: testProxyURL, + RemoteTimeout: 123 * time.Second, + TLSConfig: map[string]string{ + "ca_file": "./ca_cert.pem", + "insecure_skip_verify": "0", + }, + }, + expectedRemoteTimeout: 123 * time.Second, + expectedErrorSubstring: "proxyconnect tcp", + }, + { + testName: "No Timeout or Proxy URL, InsecureSkipVerify is false", + config: Config{ + TLSConfig: map[string]string{ + "ca_file": "./ca_cert.pem", + "insecure_skip_verify": "0", + }, + }, + expectedErrorSubstring: "", + }, + { + testName: "No Timeout or Proxy URL, InsecureSkipVerify is true", + config: Config{ + TLSConfig: map[string]string{ + "ca_file": "./ca_cert.pem", + "insecure_skip_verify": "1", + }, + }, + expectedErrorSubstring: "", + }, + } + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + // Create and start the TLS server. + handler := func(rw http.ResponseWriter, req *http.Request) { + fmt.Fprint(rw, "Successfully received HTTP request!") + } + server := httptest.NewTLSServer(http.HandlerFunc(handler)) + defer server.Close() + + // Create a certicate for the CA from the TLS server. This will be used to + // verify the test server by the client. + encodedCACert := server.TLS.Certificates[0].Certificate[0] + caCertPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: encodedCACert, + }) + err := createFile(caCertPEM, "./ca_cert.pem") + require.NoError(t, err) + defer os.Remove("ca_cert.pem") + + // Create an Exporter client and check the timeout. + exporter := Exporter{ + config: test.config, + } + client, err := exporter.buildClient() + require.NoError(t, err) + assert.Equal(t, client.Timeout, test.expectedRemoteTimeout) + + // Attempt to send the request and verify that the correct error occurred. If + // an error is expected, the test checks if the error string contains the + // expected error substring since the error can contain the server URL, which + // changes every test. + _, err = client.Get(server.URL) + if test.expectedErrorSubstring != "" { + if assert.Error(t, err) { + hasErrorSubstring := strings.Contains(err.Error(), test.expectedErrorSubstring) + assert.True(t, hasErrorSubstring, "missing error message") + } + } else { + require.NoError(t, err) + } + }) + } +} + +// TestMutualTLS is an integration test that checks whether the Exporter's client can +// successfully verify a server and send a HTTP request and whether a server can +// successfully verify the Exporter client and receive the HTTP request. +func TestMutualTLS(t *testing.T) { + // Generate certificate authority certificate to sign other certificates. + caCert, caPrivateKey, err := generateCACertFiles("./ca_cert.pem", "./ca_key.pem") + require.NoError(t, err) + defer os.Remove("./ca_cert.pem") + defer os.Remove("./ca_key.pem") + + // Generate certificate for the server. The client will check this certificate against + // its certificate authority to verify the server. + _, _, err = generateServingCertFiles( + caCert, + caPrivateKey, + "./serving_cert.pem", + "./serving_key.pem", + ) + require.NoError(t, err) + defer os.Remove("./serving_cert.pem") + defer os.Remove("./serving_key.pem") + + // Generate certificate for the client. The server will check this certificate against + // its certificate authority to verify the client. + _, _, err = generateClientCertFiles( + caCert, + caPrivateKey, + "./client_cert.pem", + "./client_key.pem", + ) + require.NoError(t, err) + defer os.Remove("./client_cert.pem") + defer os.Remove("./client_key.pem") + + // Generate the tls Config to set up mutual TLS on the server. + serverTLSConfig, err := generateServerTLSConfig( + "ca_cert.pem", + "serving_cert.pem", + "serving_key.pem", + ) + require.NoError(t, err) + + // Create and start the TLS server. + handler := func(rw http.ResponseWriter, req *http.Request) { + fmt.Fprint(rw, "Successfully verified client and received request!") + } + server := httptest.NewUnstartedServer(http.HandlerFunc(handler)) + server.TLS = serverTLSConfig + server.StartTLS() + defer server.Close() + + // Create an Exporter client with the client and CA certificate files. + exporter := Exporter{ + Config{ + TLSConfig: map[string]string{ + "ca_file": "./ca_cert.pem", + "cert_file": "./client_cert.pem", + "key_file": "./client_key.pem", + "insecure_skip_verify": "0", + }, + }, + } + client, err := exporter.buildClient() + require.NoError(t, err) + + // Send the request and verify that the request was successfully received. + res, err := client.Get(server.URL) + require.NoError(t, err) + defer res.Body.Close() +} + +// generateCertFiles generates new certificate files from a template that is signed with +// the provided signer certificate and key. +func generateCertFiles( + template *x509.Certificate, + signer *x509.Certificate, + signerKey *rsa.PrivateKey, + certFilepath string, + keyFilepath string, +) (*x509.Certificate, *rsa.PrivateKey, error) { + // Generate a private key for the new certificate. This does not have to be rsa 4096. + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, nil, err + } + + // Check if a signer key was provided. If not, then use the newly created key. + if signerKey == nil { + signerKey = privateKey + } + + // Create a new certificate in DER encoding. + encodedCert, err := x509.CreateCertificate( + rand.Reader, template, signer, privateKey.Public(), signerKey, + ) + if err != nil { + return nil, nil, err + } + + // Write the certificate to the local directory. + certFile, err := os.Create(certFilepath) + if err != nil { + return nil, nil, err + } + err = pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: encodedCert, + }) + if err != nil { + return nil, nil, err + } + + // Write the private key to the local directory. + encodedPrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, nil, err + } + keyFile, err := os.Create(keyFilepath) + if err != nil { + return nil, nil, err + } + err = pem.Encode(keyFile, &pem.Block{ + Type: "PRIVATE KEY", + Bytes: encodedPrivateKey, + }) + if err != nil { + return nil, nil, err + } + + // Parse the newly created certificate so it can be returned. + cert, err := x509.ParseCertificate(encodedCert) + if err != nil { + return nil, nil, err + } + return cert, privateKey, nil +} + +// generateCACertFiles creates a CA certificate and key in the local directory. This +// certificate is used to sign other certificates. +func generateCACertFiles(certFilepath string, keyFilepath string) (*x509.Certificate, *rsa.PrivateKey, error) { + // Create a template for CA certificates. + certTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(123), + Subject: pkix.Name{ + Organization: []string{"CA Certificate"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(5 * time.Minute), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, + IsCA: true, + BasicConstraintsValid: true, + } + + // Create the certificate files. CA certificates are used to sign other certificates + // so it signs itself with its own template and private key during creation. + cert, privateKey, err := generateCertFiles( + certTemplate, + certTemplate, + nil, + certFilepath, + keyFilepath, + ) + if err != nil { + return nil, nil, err + } + + return cert, privateKey, nil +} + +// generateServingCertFiles creates a new certificate that a client will check against its +// certificate authority to verify the server. The certificate is signed by a certificate +// authority. +func generateServingCertFiles( + caCert *x509.Certificate, + caPrivateKey *rsa.PrivateKey, + certFilepath string, + keyFilepath string, +) (*x509.Certificate, *rsa.PrivateKey, error) { + certTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(456), + Subject: pkix.Name{ + Organization: []string{"Serving Certificate"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(5 * time.Minute), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + // Create the certificate files. The CA certificate is used to sign the new + // certificate. + cert, privateKey, err := generateCertFiles( + certTemplate, + caCert, + caPrivateKey, + "./serving_cert.pem", + "./serving_key.pem", + ) + if err != nil { + return nil, nil, err + } + + return cert, privateKey, nil +} + +// generateClientCertFiles creates a new certificate that a server will check against its +// certificate authority to verify the client. The certificate is signed by a certificate +// authority. +func generateClientCertFiles( + caCert *x509.Certificate, + caPrivateKey *rsa.PrivateKey, + certFilepath string, + keyFilepath string, +) (*x509.Certificate, *rsa.PrivateKey, error) { + certTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(789), + Subject: pkix.Name{ + Organization: []string{"Client Certificate"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(5 * time.Minute), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + // Create the certificate files. The CA certificate is used to sign the new + // certificate. + cert, privateKey, err := generateCertFiles( + certTemplate, + caCert, + caPrivateKey, + "./client_cert.pem", + "./client_key.pem", + ) + if err != nil { + return nil, nil, err + } + + return cert, privateKey, nil +} + +// generateServerTLSConfig creates a tls Config struct for a server that wants to both +// verify servers and have clients verify itself. +func generateServerTLSConfig(caCertFile string, servingCertFile string, servingKeyFile string) (*tls.Config, error) { + // Create the server's serving certificate. This allows clients to verify the server. + servingCert, err := tls.LoadX509KeyPair(servingCertFile, servingKeyFile) + if err != nil { + return nil, err + } + + // Create a certificate pool to store the CA certificate. This allows the server to + // verify client certificates signed by the stored certicate authority. + encodedCACert, err := ioutil.ReadFile(caCertFile) + if err != nil { + return nil, err + } + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM(encodedCACert) + + // Create the tls Config struct and set it to always verify the client with the CAs. + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{servingCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: certPool, + } + return tlsConfig, nil +} diff --git a/exporters/metric/cortex/config.go b/exporters/metric/cortex/config.go index 911be193b6d..51fa2851b41 100644 --- a/exporters/metric/cortex/config.go +++ b/exporters/metric/cortex/config.go @@ -17,6 +17,7 @@ package cortex import ( "fmt" "net/http" + "net/url" "time" ) @@ -51,7 +52,7 @@ type Config struct { BearerToken string `mapstructure:"bearer_token"` BearerTokenFile string `mapstructure:"bearer_token_file"` TLSConfig map[string]string `mapstructure:"tls_config"` - ProxyURL string `mapstructure:"proxy_url"` + ProxyURL *url.URL `mapstructure:"proxy_url"` PushInterval time.Duration `mapstructure:"push_interval"` Quantiles []float64 `mapstructure:"quantiles"` HistogramBoundaries []float64 `mapstructure:"histogram_boundaries"` diff --git a/exporters/metric/cortex/cortex.go b/exporters/metric/cortex/cortex.go index 9b7ff363314..2311b9f7b31 100644 --- a/exporters/metric/cortex/cortex.go +++ b/exporters/metric/cortex/cortex.go @@ -468,8 +468,11 @@ func (e *Exporter) buildRequest(message []byte) (*http.Request, error) { func (e *Exporter) sendRequest(req *http.Request) error { // Set a client if the user didn't provide one. if e.config.Client == nil { - e.config.Client = http.DefaultClient - e.config.Client.Timeout = e.config.RemoteTimeout + client, err := e.buildClient() + if err != nil { + return err + } + e.config.Client = client } // Attempt to send request. diff --git a/exporters/metric/cortex/cortex_test.go b/exporters/metric/cortex/cortex_test.go index bc5cc52d5d6..0d4ca1a9c1a 100644 --- a/exporters/metric/cortex/cortex_test.go +++ b/exporters/metric/cortex/cortex_test.go @@ -56,7 +56,7 @@ var validConfig = Config{ "server_name": "server", "insecure_skip_verify": "1", }, - ProxyURL: "", + ProxyURL: nil, PushInterval: 10 * time.Second, Headers: map[string]string{ "x-prometheus-remote-write-version": "0.1.0", @@ -190,9 +190,9 @@ func TestAddHeaders(t *testing.T) { // Create http request to add headers to. req, err := http.NewRequest("POST", "test.com", nil) - require.Nil(t, err) + require.NoError(t, err) err = exporter.addHeaders(req) - require.Nil(t, err) + require.NoError(t, err) // Check that all the headers are there. for name, field := range testConfig.Headers { @@ -213,7 +213,7 @@ func TestBuildMessage(t *testing.T) { // package has its own tests, buildMessage should work as expected as long as there // are no errors. _, err := exporter.buildMessage(timeseries) - require.Nil(t, err) + require.NoError(t, err) } // TestBuildRequest tests whether a http request is a POST request, has the correct body, @@ -225,14 +225,14 @@ func TestBuildRequest(t *testing.T) { // Create the http request. req, err := exporter.buildRequest(testMessage) - require.Nil(t, err) + require.NoError(t, err) // Verify the http method, url, and body. require.Equal(t, req.Method, http.MethodPost) require.Equal(t, req.URL.String(), validConfig.Endpoint) reqMessage, err := ioutil.ReadAll(req.Body) - require.Nil(t, err) + require.NoError(t, err) require.Equal(t, reqMessage, testMessage) // Verify headers. @@ -329,11 +329,11 @@ func TestSendRequest(t *testing.T) { // Create an empty Snappy-compressed message. msg, err := exporter.buildMessage([]*prompb.TimeSeries{}) - require.Nil(t, err) + require.NoError(t, err) // Create a http POST request with the compressed message. req, err := exporter.buildRequest(msg) - require.Nil(t, err) + require.NoError(t, err) // Send the request to the test server and verify the error. err = exporter.sendRequest(req) @@ -341,7 +341,7 @@ func TestSendRequest(t *testing.T) { errorString := err.Error() require.Equal(t, errorString, test.expectedError.Error()) } else { - require.Nil(t, test.expectedError) + require.NoError(t, test.expectedError) } }) } diff --git a/exporters/metric/cortex/utils/config_utils_data_test.go b/exporters/metric/cortex/utils/config_utils_data_test.go index 3a968278bb8..fa0d38cef69 100644 --- a/exporters/metric/cortex/utils/config_utils_data_test.go +++ b/exporters/metric/cortex/utils/config_utils_data_test.go @@ -126,7 +126,7 @@ var validConfig = cortex.Config{ "server_name": "server", "insecure_skip_verify": "1", }, - ProxyURL: "", + ProxyURL: nil, PushInterval: 5 * time.Second, Headers: map[string]string{ "test": "header", diff --git a/exporters/metric/cortex/utils/config_utils_test.go b/exporters/metric/cortex/utils/config_utils_test.go index ee8da05530f..72d99bfed21 100644 --- a/exporters/metric/cortex/utils/config_utils_test.go +++ b/exporters/metric/cortex/utils/config_utils_test.go @@ -105,7 +105,7 @@ func TestNewConfig(t *testing.T) { // Create YAML file. fullPath := test.directoryPath + "/" + test.fileName fs, err := initYAML(test.yamlByteString, fullPath) - require.Nil(t, err) + require.NoError(t, err) // Create new Config struct from the specified YAML file with an in-memory // filesystem. @@ -153,7 +153,7 @@ func TestWithFilepath(t *testing.T) { // Create YAML file. fullPath := test.directoryPath + "/" + test.fileName fs, err := initYAML(test.yamlByteString, fullPath) - require.Nil(t, err) + require.NoError(t, err) // Create new Config struct from the specified YAML file with an in-memory // filesystem. If a path is added, Viper should be able to find the file and @@ -165,7 +165,7 @@ func TestWithFilepath(t *testing.T) { utils.WithFilepath(test.directoryPath), utils.WithFilesystem(fs), ) - require.Nil(t, err) + require.NoError(t, err) } else { _, err := utils.NewConfig(test.fileName, utils.WithFilesystem(fs)) require.Error(t, err) @@ -179,7 +179,7 @@ func TestWithFilepath(t *testing.T) { func TestWithClient(t *testing.T) { // Create a YAML file. fs, err := initYAML(validYAML, "/test/config.yml") - require.Nil(t, err) + require.NoError(t, err) // Create a new Config struct with a custom HTTP client. customClient := &http.Client{