Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement support for docker-credential-helpers #2651

Merged
merged 2 commits into from
May 31, 2017
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
183 changes: 145 additions & 38 deletions client/driver/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
Expand Down Expand Up @@ -104,6 +105,9 @@ const (

// dockerImageResKey is the CreatedResources key for docker images
dockerImageResKey = "image"

// Authentication-helper is a binary in $PATH named ${prefix-}${helper-name}
dockerAuthHelperPrefix = "docker-credential-"
)

type DockerDriver struct {
Expand Down Expand Up @@ -1004,33 +1008,33 @@ func (d *DockerDriver) createImage(driverConfig *DockerDriverConfig, client *doc

// pullImage creates an image by pulling it from a docker registry
func (d *DockerDriver) pullImage(driverConfig *DockerDriverConfig, client *docker.Client, repo, tag string) (id string, err error) {
var authOptions *docker.AuthConfiguration
if len(driverConfig.Auth) != 0 {
authOptions = &docker.AuthConfiguration{
Username: driverConfig.Auth[0].Username,
Password: driverConfig.Auth[0].Password,
Email: driverConfig.Auth[0].Email,
ServerAddress: driverConfig.Auth[0].ServerAddress,
}
} else if authConfigFile := d.config.Read("docker.auth.config"); authConfigFile != "" {
var err error
authOptions, err = authOptionFrom(authConfigFile, repo)
if err != nil {
d.logger.Printf("[INFO] driver.docker: failed to find docker auth for repo %q: %v", repo, err)
return "", fmt.Errorf("Failed to find docker auth for repo %q: %v", repo, err)
}
authOptions, err := d.resolveRegistryAuthentication(driverConfig, repo)
if err != nil {
return "", fmt.Errorf("Failed to find docker auth for repo %q: %v", repo, err)
}

if authOptions.Email == "" && authOptions.Password == "" &&
authOptions.ServerAddress == "" && authOptions.Username == "" {
d.logger.Printf("[DEBUG] driver.docker: did not find docker auth for repo %q", repo)
}
if authIsEmpty(authOptions) {
d.logger.Printf("[DEBUG] driver.docker: did not find docker auth for repo %q", repo)
}

d.emitEvent("Downloading image %s:%s", repo, tag)
coordinator, callerID := d.getDockerCoordinator(client)
return coordinator.PullImage(driverConfig.ImageName, authOptions, callerID)
}

// Definition of a function that resolves credentials when needed. These are invoked in a priority-chain.
// First non-nil AuthConfiguration is used. Any error before that propagates as an error
type authBackend func(string) (*docker.AuthConfiguration, error)

// Tries all authentication-backends in order
func (d *DockerDriver) resolveRegistryAuthentication(driverConfig *DockerDriverConfig, repo string) (*docker.AuthConfiguration, error) {
return firstValidAuth(repo, []authBackend{
authFromTaskConfig(driverConfig),
authFromDockerConfig(d.config.Read("docker.auth.config")),
authFromHelper(d.config.Read("docker.auth.helper")),
})
}

// loadImage creates an image by loading it from the file system
func (d *DockerDriver) loadImage(driverConfig *DockerDriverConfig, client *docker.Client,
taskDir *allocdir.TaskDir) (id string, err error) {
Expand Down Expand Up @@ -1462,10 +1466,21 @@ func calculatePercent(newSample, oldSample, newTotal, oldTotal uint64, cores int
return (float64(numerator) / float64(denom)) * float64(cores) * 100.0
}

// authOptionFrom takes the Docker auth config file and the repo being pulled
// and returns an AuthConfiguration or an error if the file/repo could not be
// parsed or looked up.
func authOptionFrom(file, repo string) (*docker.AuthConfiguration, error) {
func loadDockerConfig(file string) (*configfile.ConfigFile, error) {
f, err := os.Open(file)
if err != nil {
return nil, fmt.Errorf("Failed to open auth config file: %v, error: %v", file, err)
}
defer f.Close()

cfile := new(configfile.ConfigFile)
if err = cfile.LoadFromReader(f); err != nil {
return nil, fmt.Errorf("Failed to parse auth config file: %v", err)
}
return cfile, nil
}

func parseRepositoryInfo(repo string) (*registry.RepositoryInfo, error) {
name, err := reference.ParseNamed(repo)
if err != nil {
return nil, fmt.Errorf("Failed to parse named repo %q: %v", repo, err)
Expand All @@ -1476,26 +1491,118 @@ func authOptionFrom(file, repo string) (*docker.AuthConfiguration, error) {
return nil, fmt.Errorf("Failed to parse repository: %v", err)
}

f, err := os.Open(file)
if err != nil {
return nil, fmt.Errorf("Failed to open auth config file: %v, error: %v", file, err)
return repoInfo, nil
}

// Tries a list of auth backends, returning first error or AuthConfiguration
func firstValidAuth(repo string, backends []authBackend) (*docker.AuthConfiguration, error) {
for _, backend := range backends {
auth, err := backend(repo)
if auth != nil || err != nil {
return auth, err
}
}
defer f.Close()
return nil, nil
}

cfile := new(configfile.ConfigFile)
if err := cfile.LoadFromReader(f); err != nil {
return nil, fmt.Errorf("Failed to parse auth config file: %v", err)
// Generate an authBackend for any auth given in the task-configuration
func authFromTaskConfig(driverConfig *DockerDriverConfig) authBackend {
return func(string) (*docker.AuthConfiguration, error) {
if len(driverConfig.Auth) == 0 {
return nil, nil
}
auth := driverConfig.Auth[0]
return &docker.AuthConfiguration{
Username: auth.Username,
Password: auth.Password,
Email: auth.Email,
ServerAddress: auth.ServerAddress,
}, nil
}
}

// Generate an authBackend for a dockercfg-compatible file.
// Either from explicit auths, or through given helpers
func authFromDockerConfig(file string) authBackend {
return func(repo string) (*docker.AuthConfiguration, error) {
if file == "" {
return nil, nil
}
repoInfo, err := parseRepositoryInfo(repo)
if err != nil {
return nil, err
}

cfile, err := loadDockerConfig(file)
if err != nil {
return nil, err
}

return firstValidAuth(repo, []authBackend{
func(string) (*docker.AuthConfiguration, error) {
dockerAuthConfig := registry.ResolveAuthConfig(cfile.AuthConfigs, repoInfo.Index)
auth := &docker.AuthConfiguration{
Username: dockerAuthConfig.Username,
Password: dockerAuthConfig.Password,
Email: dockerAuthConfig.Email,
ServerAddress: dockerAuthConfig.ServerAddress,
}
if authIsEmpty(auth) {
return nil, nil
}
return auth, nil
},
authFromHelper(cfile.CredentialHelpers[registry.GetAuthConfigKey(repoInfo.Index)]),
authFromHelper(cfile.CredentialsStore),
})
}
}

// Generate an authBackend for a docker-credentials-helper;
// A script taking the requested domain on input, outputting JSON with ["Username"]
func authFromHelper(helperName string) authBackend {
return func(repo string) (*docker.AuthConfiguration, error) {
if helperName == "" {
return nil, nil
}
helper := dockerAuthHelperPrefix + helperName
cmd := exec.Command(helper, "get")
cmd.Stdin = strings.NewReader(repo)

dockerAuthConfig := registry.ResolveAuthConfig(cfile.AuthConfigs, repoInfo.Index)
output, err := cmd.Output()
if err != nil {
switch e := err.(type) {
default:
return nil, err
case *exec.ExitError:
return nil, fmt.Errorf("%s failed with stderr: %s", helper, string(e.Stderr))
}
}

// Convert to Api version
apiAuthConfig := &docker.AuthConfiguration{
Username: dockerAuthConfig.Username,
Password: dockerAuthConfig.Password,
Email: dockerAuthConfig.Email,
ServerAddress: dockerAuthConfig.ServerAddress,
var response map[string]string
if err := json.Unmarshal(output, &response); err != nil {
return nil, err
}

auth := &docker.AuthConfiguration{
Username: response["Username"],
Password: response["Secret"],
}

if authIsEmpty(auth) {
return nil, nil
}
return auth, nil
}
}

return apiAuthConfig, nil
// Check if auth is nil or an empty structure
func authIsEmpty(auth *docker.AuthConfiguration) bool {
if auth == nil {
return false
}
return auth.Username == "" &&
auth.Password == "" &&
auth.Email == "" &&
auth.ServerAddress == ""
}
4 changes: 2 additions & 2 deletions client/driver/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1305,7 +1305,7 @@ func TestDockerDriver_AuthConfiguration(t *testing.T) {
}{
{
Repo: "lolwhat.com/what:1337",
AuthConfig: &docker.AuthConfiguration{},
AuthConfig: nil,
},
{
Repo: "redis:3.2",
Expand Down Expand Up @@ -1337,7 +1337,7 @@ func TestDockerDriver_AuthConfiguration(t *testing.T) {
}

for i, c := range cases {
act, err := authOptionFrom(path, c.Repo)
act, err := authFromDockerConfig(path)(c.Repo)
if err != nil {
t.Fatalf("Test %d failed: %v", i+1, err)
}
Expand Down
37 changes: 32 additions & 5 deletions website/source/docs/drivers/docker.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,14 @@ This is not configurable.
### Authentication

If you want to pull from a private repo (for example on dockerhub or quay.io),
you will need to specify credentials in your job via the `auth` option or by
storing the credentials in a file and setting the
[docker.auth.config](#auth_file) value on the client.
you will need to specify credentials in your job via:

* the `auth` option in the task config.

* by storing credentials or `credHelpers` in a file and setting the
[docker.auth.config](#auth_file) value on the client.

* by specifying a [docker.auth.helper](#auth_helper) on the client

The `auth` object supports the following keys:

Expand All @@ -274,7 +279,7 @@ The `auth` object supports the following keys:
* `server_address` - (Optional) The server domain/IP without the protocol.
Docker Hub is used by default.

Example:
Example task-config:

```hcl
task "example" {
Expand All @@ -291,6 +296,22 @@ task "example" {
}
```

Example docker-config, using two helper scripts in $PATH,
"docker-credential-ecr" and "docker-credential-vault":

```json
{
"auths": {
"internal.repo": { "auth": "`echo -n '<username>:<password>' | base64 -w0`" }
},
"credHelpers": {
"<XYZ>.dkr.ecr.<region>.amazonaws.com": "ecr-login"
},
"credsStore": "secretservice"
}
```


!> **Be Careful!** At this time these credentials are stored in Nomad in plain
text. Secrets management will be added in a later release.

Expand Down Expand Up @@ -420,7 +441,13 @@ options](/docs/agent/configuration/client.html#options):

* `docker.auth.config` <a id="auth_file"></a>- Allows an operator to specify a
JSON file which is in the dockercfg format containing authentication
information for a private registry.
information for a private registry, from either (in order) `auths`,
`credHelpers` or `credsStore`.

* `docker.auth.helper` <a id="auth_helper"></a>- Allows an operator to specify
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer if we remove this. Sometimes optionality actually just leads to confusion! I think it is better to have one well documented, canonical way to do this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tend to agree. The rationale I see for it here, is that for simple scenarios (like in my organization) it's very likely to only have a single helper required. For those cases, a docker-config really isn't needed. Another possible such scenario is to have all secrets stored in Vault. Hashicorp might even want to have an official docker-helper for that.

With this option, moving nomad docker-auth from clear-text docker-config, to protected in vault, is simply a matter of

  • one config-line in the nomad agent-config
  • a VAULT_TOKEN in env
  • a script installed to $PATH

Without this option, an entire extra config-file is required. (Likely needlessly, since the nomad agent host should preferably not be shared for other purposes, I guess?)

a [credsStore](https://docs.docker.com/engine/reference/commandline/login/#credential-helper-protocol)
-like script on $PATH to lookup authentication information from external
sources.

* `docker.tls.cert` - Path to the server's certificate file (`.pem`). Specify
this along with `docker.tls.key` and `docker.tls.ca` to use a TLS client to
Expand Down