Skip to content

Commit

Permalink
add support for DOCKER_CUSTOM_HEADERS env-var
Browse files Browse the repository at this point in the history
This environment variable allows for setting additional headers
to be sent by the client. Headers set through this environment
variable are added to headers set through the config-file (through
the HttpHeaders field).

This environment variable can be used in situations where headers
must be set for a specific invocation of the CLI, but should not
be set by default, and therefore cannot be set in the config-file.

Signed-off-by: Sebastiaan van Stijn <[email protected]>
  • Loading branch information
thaJeztah committed Jun 4, 2024
1 parent ce85b24 commit 0d6b805
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 1 deletion.
2 changes: 1 addition & 1 deletion cli/command/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigF
if len(configFile.HTTPHeaders) > 0 {
opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders))
}
opts = append(opts, client.WithUserAgent(UserAgent()))
opts = append(opts, withCustomHeadersFromEnv(), client.WithUserAgent(UserAgent()))
return client.NewClientWithOpts(opts...)
}

Expand Down
95 changes: 95 additions & 0 deletions cli/command/cli_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ package command

import (
"context"
"encoding/csv"
"io"
"net/http"
"os"
"strconv"
"strings"

"github.com/docker/cli/cli/streams"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
"github.com/moby/term"
"github.com/pkg/errors"
)

// CLIOption is a functional argument to apply options to a [DockerCli]. These
Expand Down Expand Up @@ -107,3 +112,93 @@ func WithAPIClient(c client.APIClient) CLIOption {
return nil
}
}

// envOverrideHTTPHeaders is the name of the environment variable that can be
// used to set custom HTTP headers to be sent by the client. This environment
// variable is the equivalent to the HttpHeaders field in the configuration
// file; if both are set, the environment variable overrides the headers
// set in the configuration file.
//
// While this env-var allows for custom headers to be set, it does not allow
// for built-in headers (such as "User-Agent", if set) to be overridden.
// Also see [client.WithHTTPHeaders] and [client.WithUserAgent].
//
// This environment variable can be used in situations where headers must be
// set for a specific invocation of the CLI, but should not be set by default,
// and therefore cannot be set in the config-file.
//
// envOverrideHTTPHeaders accepts a comma-separated (CSV) list of key=value pairs,
// where key must be a non-empty, valid MIME header format. Whitespaces surrounding
// the key are trimmed, and the key is normalised. Whitespaces in values are
// preserved, but "key=value" pairs with an empty value (e.g. "key=") are ignored.
// Tuples without a "=" produce an error.
//
// It follows CSV rules for escaping, allowing "key=value" pairs to be quoted
// if they must contain commas. which allows for multiple values for a single
// header to be set. If a key is repeated in the list, later values override
// prior values.
//
// For example, the following value:
//
// one=one-value,"two=two,value","three= a value with whitespace ",four=,five=five=one,five=five-two
//
// Produces four headers (four is omitted as it has an empty value set):
//
// - one (value is "one-value")
// - two (value is "two,value")
// - three (value is " a value with whitespace ")
// - five (value is "five-two", the later value has overridden the prior value)
const envOverrideHTTPHeaders = "DOCKER_CUSTOM_HEADERS"

// withCustomHeadersFromEnv overriding custom HTTP headers to be sent by the
// client through the [envOverrideHTTPHeaders] environment variable. This
// environment variable is the equivalent to the HttpHeaders field in the
// configuration file; if both are set, the environment variable overrides
// the headers set in the configuration file.
//
// TODO(thaJeztah): this is a client Option, and should be moved to the client. It is non-exported for that reason.
func withCustomHeadersFromEnv() client.Opt {
return func(apiClient *client.Client) error {
value := os.Getenv(envOverrideHTTPHeaders)
if value == "" {
return nil
}
csvReader := csv.NewReader(strings.NewReader(value))
fields, err := csvReader.Read()
if err != nil {
return errdefs.InvalidParameter(errors.Wrapf(err, "failed to set custom headers from %s environment variable", envOverrideHTTPHeaders))
}
if len(fields) == 0 {
return nil
}

env := map[string]string{}
for _, kv := range fields {
k, v, hasValue := strings.Cut(kv, "=")

// Only strip whitespace in keys; preserve whitespace in values.
k = strings.TrimSpace(k)

if k == "" {
return errdefs.InvalidParameter(errors.Errorf("failed to set custom headers from %s environment variable: value contains a key=value pair with an empty key", envOverrideHTTPHeaders))
}

// We don't currently allow key=value pairs, and produce an error.
// This is something we could allow in future (e.g. to read value
// from an environment variable with the same name). In the meantime,
// produce an error to prevent users from depending on this.
if !hasValue {
return errdefs.InvalidParameter(errors.Errorf(`failed to set custom headers from %s environment variable: missing "=" in key=value pair %q`, envOverrideHTTPHeaders, kv))
}

if v == "" {
// Ignore empty values, and consider them to not be set
continue
}
env[http.CanonicalHeaderKey(k)] = v
}

// TODO(thaJeztah): should an empty result be ignored?
return client.WithHTTPHeaders(env)(apiClient)
}
}
34 changes: 34 additions & 0 deletions cli/command/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,40 @@ func TestNewAPIClientFromFlagsWithCustomHeaders(t *testing.T) {
assert.DeepEqual(t, received, expectedHeaders)
}

func TestNewAPIClientFromFlagsWithCustomHeadersFromEnv(t *testing.T) {
var received http.Header
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received = r.Header.Clone()
_, _ = w.Write([]byte("OK"))
}))
defer ts.Close()
host := strings.Replace(ts.URL, "http://", "tcp://", 1)
opts := &flags.ClientOptions{Hosts: []string{host}}
configFile := &configfile.ConfigFile{
HTTPHeaders: map[string]string{
"My-Header": "Custom-Value from config-file",
},
}

// envOverrideHTTPHeaders should override the HTTPHeaders from the config-file,
// so "My-Header" should not be present.
t.Setenv(envOverrideHTTPHeaders, `one=one-value,"two=two,value",three=,four=four-value,four=four-value-override`)
apiClient, err := NewAPIClientFromFlags(opts, configFile)
assert.NilError(t, err)
assert.Equal(t, apiClient.DaemonHost(), host)
assert.Equal(t, apiClient.ClientVersion(), api.DefaultVersion)

expectedHeaders := http.Header{
"One": []string{"one-value"},
"Two": []string{"two,value"},
"Four": []string{"four-value-override"},
"User-Agent": []string{UserAgent()},
}
_, err = apiClient.Ping(context.Background())
assert.NilError(t, err)
assert.DeepEqual(t, received, expectedHeaders)
}

func TestNewAPIClientFromFlagsWithAPIVersionFromEnv(t *testing.T) {
customVersion := "v3.3.3"
t.Setenv("DOCKER_API_VERSION", customVersion)
Expand Down

0 comments on commit 0d6b805

Please sign in to comment.