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) + }) + } +}