From 0a4a995e55b84f6a8050d13dde09128f479998bc Mon Sep 17 00:00:00 2001 From: Seth Hoenig Date: Wed, 28 Jun 2023 16:45:05 +0000 Subject: [PATCH] driver: add support for credentials helper and static file auth config This PR adds support for specifying an external credentials helper and/ or an external "auth.json" credentials file. The plugin configuration now has an "auth" block with fields "helper" and "config" (similar to the docker driver). We also now have an "auth_soft_fail" option in Task config for cases where someone has configured the auth block in plugin config, but has a task that is using a public image with no credentials. In that case setting auth_soft_fail is used to ignore the fact that no credentials will be found for the given public image. (This is also how the docker driver works, I didn't come up with this). Unlike the docker driver, the podman driver still does not support specifying a credentials helper _in_ the external "auth.json" credentials file. If there is demand for that use case we can add it, but in the short term it seems like just the plugin's support for specifying a credentials helper could be sufficient. And it's a lot of code to do the other thing. --- api/image_pull.go | 30 ++-- api/image_pull_test.go | 5 +- config.go | 27 ++-- driver.go | 25 +++- driver_test.go | 6 +- registry/authentication.go | 74 ++++++---- registry/authentication_test.go | 55 ++++++++ registry/credshelper.go | 79 +++++++++++ registry/credshelper_test.go | 46 +++++++ registry/fileconfig.go | 87 ++++++++++++ registry/fileconfig_test.go | 51 +++++++ registry/tests/auth.json | 14 ++ registry/tests/docker-credential-fake.sh | 21 +++ registry/util.go | 89 ++++++++++++ registry/util_test.go | 167 +++++++++++++++++++++++ 15 files changed, 721 insertions(+), 55 deletions(-) create mode 100644 registry/authentication_test.go create mode 100644 registry/credshelper.go create mode 100644 registry/credshelper_test.go create mode 100644 registry/fileconfig.go create mode 100644 registry/fileconfig_test.go create mode 100644 registry/tests/auth.json create mode 100755 registry/tests/docker-credential-fake.sh create mode 100644 registry/util.go create mode 100644 registry/util_test.go diff --git a/api/image_pull.go b/api/image_pull.go index eb7af29..6776d8a 100644 --- a/api/image_pull.go +++ b/api/image_pull.go @@ -20,19 +20,22 @@ func (c *API) ImagePull(ctx context.Context, pullConfig *registry.PullConfig) (s var ( headers = make(map[string]string) - repository = pullConfig.Repository + repository = pullConfig.Image tlsVerify = pullConfig.TLSVerify ) - auth, err := registry.ResolveRegistryAuthentication(repository, pullConfig) - if err != nil { - return "", fmt.Errorf("failed to determine authentication for %q", repository) + // if the task or driver are configured with an auth block, attempt to find + // credentials that are compatible with the given image, and set the appropriate + // header if found + if pullConfig.AuthAvailable() { + auth, err := registry.ResolveRegistryAuthentication(repository, pullConfig) + if err != nil { + return "", fmt.Errorf("failed to determine authentication for %q: %w", repository, err) + } + auth.SetHeader(headers) } - auth.SetHeader(headers) - c.logger.Info("HEADERS", "headers", headers) urlPath := fmt.Sprintf("/v1.0.0/libpod/images/pull?reference=%s&tlsVerify=%t", repository, tlsVerify) - c.logger.Info("URL PATH", "urlPath", urlPath) res, err := c.PostWithHeaders(ctx, urlPath, nil, headers) if err != nil { @@ -50,17 +53,16 @@ func (c *API) ImagePull(ctx context.Context, pullConfig *registry.PullConfig) (s report ImagePullReport id string ) + for { decodeErr := dec.Decode(&report) - if errors.Is(decodeErr, io.EOF) { - break - } else if decodeErr != nil { + switch { + case errors.Is(decodeErr, io.EOF): + return id, nil + case decodeErr != nil: return "", fmt.Errorf("failed to read image pull response: %w", decodeErr) - } else if report.Error != "" { - return "", fmt.Errorf("image pull report indicates error: %s", report.Error) - } else if report.ID != "" { + case report.ID != "": id = report.ID } } - return id, nil } diff --git a/api/image_pull_test.go b/api/image_pull_test.go index ad96ae6..817c50d 100644 --- a/api/image_pull_test.go +++ b/api/image_pull_test.go @@ -7,6 +7,7 @@ import ( "context" "testing" + "github.com/hashicorp/nomad-driver-podman/registry" "github.com/shoenig/test/must" ) @@ -25,7 +26,9 @@ func TestApi_Image_Pull(t *testing.T) { } for _, testCase := range testCases { - id, err := api.ImagePull(ctx, testCase.Image, ImageAuthConfig{}) + id, err := api.ImagePull(ctx, ®istry.PullConfig{ + Image: testCase.Image, + }) if testCase.Exists { must.NoError(t, err) must.NotEq(t, "", id) diff --git a/config.go b/config.go index 1d5e64f..127d441 100644 --- a/config.go +++ b/config.go @@ -15,6 +15,7 @@ var ( // image registry authentication options "auth": hclspec.NewBlock("auth", false, hclspec.NewObject(map[string]*hclspec.Spec{ "config": hclspec.NewAttr("config", "string", false), + "helper": hclspec.NewAttr("helper", "string", false), })), // volume options @@ -107,9 +108,9 @@ var ( }) ) -// AuthConfig is the tasks authentication configuration +// TaskAuthConfig is the tasks authentication configuration // (there is also auth_soft_fail on the top level) -type AuthConfig struct { +type TaskAuthConfig struct { Username string `codec:"username"` Password string `codec:"password"` TLSVerify bool `codec:"tls_verify"` @@ -132,15 +133,21 @@ type VolumeConfig struct { SelinuxLabel string `codec:"selinuxlabel"` } +type PluginAuthConfig struct { + FileConfig string `codec:"config"` + Helper string `codec:"helper"` +} + // PluginConfig is the driver configuration set by the SetConfig RPC call type PluginConfig struct { - Volumes VolumeConfig `codec:"volumes"` - GC GCConfig `codec:"gc"` - RecoverStopped bool `codec:"recover_stopped"` - DisableLogCollection bool `codec:"disable_log_collection"` - SocketPath string `codec:"socket_path"` - ClientHttpTimeout string `codec:"client_http_timeout"` - ExtraLabels []string `codec:"extra_labels"` + Auth PluginAuthConfig `codec:"auth"` + Volumes VolumeConfig `codec:"volumes"` + GC GCConfig `codec:"gc"` + RecoverStopped bool `codec:"recover_stopped"` + DisableLogCollection bool `codec:"disable_log_collection"` + SocketPath string `codec:"socket_path"` + ClientHttpTimeout string `codec:"client_http_timeout"` + ExtraLabels []string `codec:"extra_labels"` } // LogWarnings will emit logs about known problematic configurations @@ -154,7 +161,7 @@ func (c *PluginConfig) LogWarnings(logger hclog.Logger) { type TaskConfig struct { ApparmorProfile string `codec:"apparmor_profile"` Args []string `codec:"args"` - Auth AuthConfig `codec:"auth"` + Auth TaskAuthConfig `codec:"auth"` AuthSoftFail bool `codec:"auth_soft_fail"` Ports []string `codec:"ports"` Tmpfs []string `codec:"tmpfs"` diff --git a/driver.go b/driver.go index b5d7954..e7b36b7 100644 --- a/driver.go +++ b/driver.go @@ -694,7 +694,14 @@ func (d *Driver) StartTask(cfg *drivers.TaskConfig) (*drivers.TaskHandle, *drive return nil, nil, fmt.Errorf("failed to parse image_pull_timeout: %w", parseErr) } - imageID, createErr := d.createImage(createOpts.Image, &driverConfig.Auth, driverConfig.ForcePull, imagePullTimeout, cfg) + imageID, createErr := d.createImage( + createOpts.Image, + &driverConfig.Auth, + driverConfig.AuthSoftFail, + driverConfig.ForcePull, + imagePullTimeout, + cfg, + ) if createErr != nil { return nil, nil, fmt.Errorf("failed to create image: %s: %w", createOpts.Image, createErr) } @@ -907,7 +914,14 @@ func sliceMergeUlimit(ulimitsRaw map[string]string) ([]spec.POSIXRlimit, error) // Creates the requested image if missing from storage // returns the 64-byte image ID as an unique image identifier -func (d *Driver) createImage(image string, auth *AuthConfig, forcePull bool, imagePullTimeout time.Duration, cfg *drivers.TaskConfig) (string, error) { +func (d *Driver) createImage( + image string, + auth *TaskAuthConfig, + authSoftFail bool, + forcePull bool, + imagePullTimeout time.Duration, + cfg *drivers.TaskConfig, +) (string, error) { var imageID string imageName := image // If it is a shortname, we should not have to worry @@ -963,12 +977,15 @@ func (d *Driver) createImage(image string, auth *AuthConfig, forcePull bool, ima }) pc := ®istry.PullConfig{ - Repository: imageName, - TLSVerify: auth.TLSVerify, + Image: imageName, + TLSVerify: auth.TLSVerify, RegistryConfig: ®istry.RegistryAuthConfig{ Username: auth.Username, Password: auth.Password, }, + CredentialsFile: d.config.Auth.FileConfig, + CredentialsHelper: d.config.Auth.Helper, + AuthSoftFail: authSoftFail, } result, err, _ := d.pullGroup.Do(imageName, func() (interface{}, error) { diff --git a/driver_test.go b/driver_test.go index 0f230ca..1904088 100644 --- a/driver_test.go +++ b/driver_test.go @@ -1983,7 +1983,7 @@ func startDestroyInspectImage(t *testing.T, image string, taskName string) { AllocID: uuid.Generate(), Resources: createBasicResources(), } - imageID, err := getPodmanDriver(t, d).createImage(image, &AuthConfig{}, false, 5*time.Minute, task) + imageID, err := getPodmanDriver(t, d).createImage(image, &TaskAuthConfig{}, false, false, 5*time.Minute, task) must.NoError(t, err) must.Eq(t, imageID, inspectData.Image) } @@ -2063,7 +2063,7 @@ insecure = true` go func() { // Pull image using our proxy. image := "localhost:5000/quay/busybox:latest" - _, err = getPodmanDriver(t, d).createImage(image, &AuthConfig{}, true, 3*time.Second, task) + _, err = getPodmanDriver(t, d).createImage(image, &TaskAuthConfig{}, false, true, 3*time.Second, task) resultCh <- err }() @@ -2141,7 +2141,7 @@ func createInspectImage(t *testing.T, image, reference string) { AllocID: uuid.Generate(), Resources: createBasicResources(), } - idTest, err := getPodmanDriver(t, d).createImage(image, &AuthConfig{}, false, 5*time.Minute, task) + idTest, err := getPodmanDriver(t, d).createImage(image, &TaskAuthConfig{}, false, false, 5*time.Minute, task) must.NoError(t, err) idRef, err := getPodmanDriver(t, d).podman.ImageInspectID(context.Background(), reference) diff --git a/registry/authentication.go b/registry/authentication.go index 8bd7d79..dc487e1 100644 --- a/registry/authentication.go +++ b/registry/authentication.go @@ -1,21 +1,23 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package registry import ( "encoding/base64" "encoding/json" - "fmt" "github.com/hashicorp/go-hclog" ) // PullConfig represents the necessary information needed for an image pull query. type PullConfig struct { - Repository string - RegistryConfig *RegistryAuthConfig - TLSVerify bool - - // TODO: config file - // TODO: creds helper + Image string + RegistryConfig *RegistryAuthConfig + TLSVerify bool + CredentialsFile string + CredentialsHelper string + AuthSoftFail bool } // BasicConfig returns the Basic-Auth level of configuration. @@ -33,16 +35,37 @@ func (pc *PullConfig) BasicConfig() *TaskAuthConfig { } } +// AuthAvailable returns true if any of the supported authentication methods are +// set, indicating we should try to find credentials for the given image by +// checking each of the options for looking up credentials. +// - RegistryConfig (from task configuration) +// - CredentialsFile (from plugin configuration) +// - CredentialsHelper (from plugin configuration) +func (pc *PullConfig) AuthAvailable() bool { + switch { + case !pc.RegistryConfig.Empty(): + return true + case pc.CredentialsFile != "": + return true + case pc.CredentialsHelper != "": + return true + default: + return false + } +} + // Log the components of PullConfig in a safe way. func (pc *PullConfig) Log(logger hclog.Logger) { var ( - repository = pc.Repository + repository = pc.Image tls bool - username = "" - email = "" - server = "" + username string + email string + server string password bool token bool + helper = pc.CredentialsHelper + file = pc.CredentialsFile ) if r := pc.RegistryConfig; r != nil { @@ -63,6 +86,8 @@ func (pc *PullConfig) Log(logger hclog.Logger) { "server", server, "tls", tls, "token", token, + "helper", helper, + "file", file, ) } @@ -114,8 +139,10 @@ func (c *TaskAuthConfig) IsEmpty() bool { // - auth from auth.json file specified by plugin config // - auth from a credentials helper specified by plugin config func ResolveRegistryAuthentication(repository string, pullConfig *PullConfig) (*RegistryAuthConfig, error) { - return firstValidAuth(repository, []AuthBackend{ + return firstValidAuth(repository, pullConfig.AuthSoftFail, []AuthBackend{ authFromTaskConfig(pullConfig.BasicConfig()), + authFromFileConfig(pullConfig.CredentialsFile), + authFromCredsHelper(pullConfig.CredentialsHelper), }) } @@ -124,11 +151,20 @@ func ResolveRegistryAuthentication(repository string, pullConfig *PullConfig) (* // returned and should be skipped. type AuthBackend func(string) (*RegistryAuthConfig, error) -func firstValidAuth(repository string, authBackends []AuthBackend) (*RegistryAuthConfig, error) { +// firstValidAuth returns the first RegistryAuthConfig associated with repository. +// +// If softFail is set, ignore error return values from an authBackend and pretend +// like they simply do not recognize repository, and continue searching. +func firstValidAuth(repository string, softFail bool, authBackends []AuthBackend) (*RegistryAuthConfig, error) { for _, backend := range authBackends { auth, err := backend(repository) - if auth != nil || err != nil { - return auth, err + switch { + case auth != nil: + return auth, nil + case err != nil && softFail: + continue + case err != nil: + return nil, err } } return nil, nil @@ -147,11 +183,3 @@ func authFromTaskConfig(taskAuthConfig *TaskAuthConfig) AuthBackend { }, nil } } - -func authFromFileConfig(filename string) (*RegistryAuthConfig, error) { - return nil, fmt.Errorf("not implemented yet") -} - -func authFromCredsHelper(helper string) (*RegistryAuthConfig, error) { - return nil, fmt.Errorf("not implemented yet") -} diff --git a/registry/authentication_test.go b/registry/authentication_test.go new file mode 100644 index 0000000..d5c8d65 --- /dev/null +++ b/registry/authentication_test.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package registry + +import ( + "testing" + + "github.com/shoenig/test/must" +) + +func TestPullConfig_NoAuth(t *testing.T) { + cases := []struct { + name string + pc *PullConfig + exp bool + }{ + { + name: "task config", + pc: &PullConfig{ + RegistryConfig: &RegistryAuthConfig{ + Username: "user", + Password: "pass", + }, + }, + exp: true, + }, + { + name: "creds helper", + pc: &PullConfig{ + CredentialsHelper: "helper.sh", + }, + exp: true, + }, + { + name: "creds file", + pc: &PullConfig{ + CredentialsFile: "auth.json", + }, + exp: true, + }, + { + name: "none", + pc: &PullConfig{}, + exp: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := tc.pc.AuthAvailable() + must.Eq(t, tc.exp, result) + }) + } +} diff --git a/registry/credshelper.go b/registry/credshelper.go new file mode 100644 index 0000000..33ba1f7 --- /dev/null +++ b/registry/credshelper.go @@ -0,0 +1,79 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package registry + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +const ( + // helperTimeout is the maximum amount of time to allow a credential helper + // to execute before force killing it + helperTimeout = 10 * time.Second +) + +func helpCmd(ctx context.Context, name, index string) (*exec.Cmd, error) { + executable := fmt.Sprintf("docker-credential-%s", name) + + path, err := exec.LookPath(executable) + if err != nil { + return nil, err + } + + cmd := exec.CommandContext(ctx, path) + cmd.Args = []string{"get"} + cmd.Stdin = strings.NewReader(index) + return cmd, nil +} + +type Credential struct { + Username string `json:"Username"` + Secret string `json:"Secret"` +} + +func (c *Credential) Empty() bool { + return c == nil || (c.Username == "" && c.Secret == "") +} + +func authFromCredsHelper(name string) AuthBackend { + if name == "" { + return noBackend + } + return func(repository string) (*RegistryAuthConfig, error) { + repo := parse(repository) + index := repo.Index() + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(helperTimeout)) + defer cancel() + + cmd, err := helpCmd(ctx, name, index) + if err != nil { + return nil, err + } + + b, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("failed to run credential-helper %q: %w", name, err) + } + + var credential Credential + if err := json.Unmarshal(b, &credential); err != nil { + return nil, fmt.Errorf("failed to read credential from credential-helper %q: %w", name, err) + } + + if credential.Empty() { + return nil, nil + } + + return &RegistryAuthConfig{ + Username: credential.Username, + Password: credential.Secret, + }, nil + } +} diff --git a/registry/credshelper_test.go b/registry/credshelper_test.go new file mode 100644 index 0000000..a8b74f4 --- /dev/null +++ b/registry/credshelper_test.go @@ -0,0 +1,46 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package registry + +import ( + "os" + "testing" + + "github.com/shoenig/test/must" +) + +func Test_authFromCredsHelper(t *testing.T) { + cases := []struct { + name string + repository string + exp *RegistryAuthConfig + }{ + { + name: "basic", + repository: "docker.io/library/bash:5", + exp: &RegistryAuthConfig{ + Username: "user1", + Password: "pass1", + }, + }, + { + name: "example.com", + repository: "example.com/some/silly/thing:v1", + exp: &RegistryAuthConfig{ + Username: "user2", + Password: "pass2", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("PATH", os.ExpandEnv("${PWD}/tests:${PATH}")) + be := authFromCredsHelper("fake.sh") + rac, err := be(tc.repository) + must.NoError(t, err) + must.Eq(t, tc.exp, rac) + }) + } +} diff --git a/registry/fileconfig.go b/registry/fileconfig.go new file mode 100644 index 0000000..0760e87 --- /dev/null +++ b/registry/fileconfig.go @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package registry + +import ( + "encoding/json" + "os" +) + +// [Glossery] +// registry: The service that serves container images. +// domain: The DNS domain name of a registry. +// repository: The domain + complete path of an image (no tag). +// index: The domain[+[sub]path] of an image (no tag) (i.e. repository prefix). +// image: A complete domain + path + tag +// credential: A base64 encoded username + password associated with an index. + +// CredentialsFile is the struct that represents the contents of the "auth.json" file +// that stores or references credentials that enable authentication into specific +// registries or even repositories. +// +// reference: https://www.mankier.com/5/containers-auth.json +// alternate: https://man.archlinux.org/man/containers-auth.json.5 +type CredentialsFile struct { + Auths map[string]EncAuth `json:"auths"` + + // CredHelpers is currently not supported by the podman task driver + // CredHelpers map[string]string `json:"credHelpers"` +} + +// EncAuth represents a single registry (or specific repository) and the associated +// base64 encoded auth token. +type EncAuth struct { + Auth string `json:"auth"` +} + +func authFromFileConfig(filename string) AuthBackend { + return func(repository string) (*RegistryAuthConfig, error) { + repo := parse(repository) + + cFile, err := loadCredentialsFile(filename) + if err != nil { + return nil, err + } + + rac := cFile.lookup(repo) + return rac, nil + } +} + +func loadCredentialsFile(path string) (*CredentialsFile, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + + var cFile CredentialsFile + + dec := json.NewDecoder(f) + if err := dec.Decode(&cFile); err != nil { + return nil, err + } + return &cFile, nil +} + +func (cf *CredentialsFile) lookup(repository *ImageSpecs) *RegistryAuthConfig { + if cf == nil { + return nil + } + + // first look for any static auth that applies + for index, credential := range cf.Auths { + if repository.Match(index) { + user, pass := decode(credential.Auth) + return &RegistryAuthConfig{ + Username: user, + Password: pass, + } + } + } + + // TODO: add support for specifying credentials helpers that can be used + // via credsHelpers in this credentials file. + + return nil +} diff --git a/registry/fileconfig_test.go b/registry/fileconfig_test.go new file mode 100644 index 0000000..82c3260 --- /dev/null +++ b/registry/fileconfig_test.go @@ -0,0 +1,51 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package registry + +import ( + "testing" + + "github.com/shoenig/test/must" +) + +func Test_authFromConfigFile(t *testing.T) { + ab := authFromFileConfig("tests/auth.json") + must.NotNil(t, ab) + + cases := []struct { + name string + image string + expUser string + expPass string + }{ + { + name: "complete", + image: "one.example.com/library/bash:5", + expUser: "user1", + expPass: "pass1", + }, + { + name: "partial path", + image: "two.example.com/library/bash:5", + expUser: "user2", + expPass: "pass2", + }, + { + name: "domain only", + image: "three.example.com/library/bash:5", + expUser: "user3", + expPass: "pass3", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rac, err := ab(tc.image) + must.NoError(t, err) + must.NotNil(t, rac, must.Sprintf("RAC should not be nil")) + must.Eq(t, tc.expUser, rac.Username) + must.Eq(t, tc.expPass, rac.Password) + }) + } +} diff --git a/registry/tests/auth.json b/registry/tests/auth.json new file mode 100644 index 0000000..4bb9055 --- /dev/null +++ b/registry/tests/auth.json @@ -0,0 +1,14 @@ +{ + "auths": { + "one.example.com/library/bash": { + "auth": "dXNlcjE6cGFzczE=" + }, + "two.example.com/library": { + "auth": "dXNlcjI6cGFzczI=" + }, + "three.example.com": { + "auth": "dXNlcjM6cGFzczM=" + } + } +} + diff --git a/registry/tests/docker-credential-fake.sh b/registry/tests/docker-credential-fake.sh new file mode 100755 index 0000000..6f277c0 --- /dev/null +++ b/registry/tests/docker-credential-fake.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +value=$(cat /dev/stdin) + +case "${value}" in + docker.io/*) + username="user1" + password="pass1" + ;; + example.com/*) + username="user2" + password="pass2" + ;; + *) + echo "unknown" + exit 1 + ;; +esac + +echo "{\"Username\": \"$username\", \"Secret\": \"$password\"}" + diff --git a/registry/util.go b/registry/util.go new file mode 100644 index 0000000..f0bd879 --- /dev/null +++ b/registry/util.go @@ -0,0 +1,89 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package registry + +import ( + "encoding/base64" + "strings" +) + +// noBackend may be used when it is known no AuthBackend exists +// that can be used. +func noBackend(string) (*RegistryAuthConfig, error) { + return nil, nil +} + +// ImageSpecs holds each of the components of a full container image identifier, +// such that we can ask questions about it. There are three parts, +// - domain +// - path +// - tag +type ImageSpecs struct { + Domain string + Path []string + Tag string +} + +func (is *ImageSpecs) Index() string { + s := []string{is.Domain} + s = append(s, is.Path...) + return strings.Join(s, "/") +} + +// Match returns true if the repository belongs to the given registry index. +// +// Given repository example.com/foo/bar, each of these index values would +// match, e.g. +// - example.com +// - examle.com/foo +// - example.com/foo/bar +// +// Whereas other index values would not match, e.g. +// - other.com +// - example.com/baz +// - example.com/foo/bar/baz +func (is *ImageSpecs) Match(index string) bool { + repository := is.Index() + if len(index) > len(repository) { + return false + } + if strings.HasPrefix(repository, index) { + return true + } + return false +} + +// parse the repository string into a useful object we can interact with to +// ask questions about that repository +func parse(repository string) *ImageSpecs { + repository = strings.TrimPrefix(repository, "https://") + repository = strings.TrimPrefix(repository, "http://") + tagIdx := strings.LastIndex(repository, ":") + var tag string + if tagIdx != -1 { + tag = repository[tagIdx+1:] + repository = repository[0:tagIdx] + } + if tag == "" { + tag = "latest" + } + parts := strings.Split(repository, "/") + return &ImageSpecs{ + Domain: parts[0], + Path: parts[1:], + Tag: tag, + } +} + +func decode(b64 string) (string, string) { + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", "" + } + parts := strings.SplitN(string(data), ":", 2) + if len(parts) != 2 { + return "", "" + } + return parts[0], parts[1] +} diff --git a/registry/util_test.go b/registry/util_test.go new file mode 100644 index 0000000..12cd956 --- /dev/null +++ b/registry/util_test.go @@ -0,0 +1,167 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package registry + +import ( + "testing" + + "github.com/shoenig/test/must" +) + +func Test_parse(t *testing.T) { + cases := []struct { + name string + repository string + exp *ImageSpecs + }{ + { + name: "normal format", + repository: "docker.io/library/bash:5", + exp: &ImageSpecs{ + Domain: "docker.io", + Path: []string{"library", "bash"}, + Tag: "5", + }, + }, + { + name: "no tag", + repository: "docker.io/library/bash", + exp: &ImageSpecs{ + Domain: "docker.io", + Path: []string{"library", "bash"}, + Tag: "latest", + }, + }, + { + name: "http prefix", + repository: "http://docker.io/library/bash:5", + exp: &ImageSpecs{ + Domain: "docker.io", + Path: []string{"library", "bash"}, + Tag: "5", + }, + }, + { + name: "https prefix", + repository: "https://docker.io/library/bash:5", + exp: &ImageSpecs{ + Domain: "docker.io", + Path: []string{"library", "bash"}, + Tag: "5", + }, + }, + { + name: "single path element", + repository: "example.com/app:version", + exp: &ImageSpecs{ + Domain: "example.com", + Path: []string{"app"}, + Tag: "version", + }, + }, + { + name: "triple path element", + repository: "example.com/one/two/three:v1", + exp: &ImageSpecs{ + Domain: "example.com", + Path: []string{"one", "two", "three"}, + Tag: "v1", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := parse(tc.repository) + must.Eq(t, tc.exp, repo) + }) + } +} + +func TestRepository_Match(t *testing.T) { + cases := []struct { + name string + repository string + index string + exp bool + }{ + { + name: "exact", + repository: "docker.io/library/bash", + index: "docker.io/library/bash", + exp: true, + }, + { + name: "domain", + repository: "docker.io/library/bash", + index: "docker.io", + exp: true, + }, + { + name: "sub path", + repository: "docker.io/library/bash", + index: "docker.io/library", + exp: true, + }, + { + name: "wrong domain", + repository: "docker.io/library/bash", + index: "other.com", + exp: false, + }, + { + name: "wrong path", + repository: "docker.io/library/bash", + index: "docker.io/other/bash", + exp: false, + }, + { + name: "extended path", + repository: "docker.io/library/bash", + index: "docker.io/library/bash/other", + exp: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := parse(tc.repository) + result := repo.Match(tc.index) + must.Eq(t, tc.exp, result, must.Sprintf( + "expect %t, repository: %s, index: %s", + tc.exp, + tc.repository, + tc.index, + )) + }) + } +} + +func Test_decode(t *testing.T) { + cases := []struct { + name string + input string + expUser string + expPass string + }{ + { + name: "ok", + input: "dXNlcjE6cGFzczE=", + expUser: "user1", + expPass: "pass1", + }, + { + name: "no split", + input: "dXNlcjE=", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + user, pass := decode(tc.input) + must.Eq(t, tc.expUser, user) + must.Eq(t, tc.expPass, pass) + }) + } +}