Repository: kubernetes/cloud-provider-aws
Pull request #157 (2020-11-20 01:05:48)

Add ECR creds provider
**What type of PR is this?**
/kind feature

**What this PR does / why we need it**:
Adds the credentials provider for ECR as part of the extraction effort.

**Special notes for your reviewer**:
- Currently breaks the build due to 1.20 dependencies. DO NOT MERGE.
- This PR is a slightly modified version of the example [here](https://github.com/andrewsykim/kubelet-credential-providers). `framework` folder has been copied without any modifications.

**Does this PR introduce a user-facing change?**:
```release-note
Add credentials provider for ECR
```


k8s-ci-robot: @ayberk: Adding the "do-not-merge/release-note-label-needed" label because no release-note block was detected, please follow our [release note process](https://git.k8s.io/community/contributors/guide/release-notes.md) to remove it.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
k8s-ci-robot: Hi @ayberk. Thanks for your PR.

I'm waiting for a [kubernetes](https://github.com/orgs/kubernetes/people) member to verify that this patch is reasonable to test. If it is, they should reply with `/ok-to-test` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](https://git.k8s.io/community/community-membership.md#member) to skip this step.

Once the patch is verified, the new status will be reflected by the `ok-to-test` label.

I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=kubernetes%2Fcloud-provider-aws).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>

k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes/cloud-provider-aws/pull/157#" title="Author self-approved">ayberk</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **justinsb** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @justinsb` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes%2Fcloud-provider-aws).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes/cloud-provider-aws/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["justinsb"]} -->
ayberk: /assign @andrewsykim 
/assign @nckturner 
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes/cloud-provider-aws/pull/157#" title="Author self-approved">ayberk</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **andrewsykim** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @andrewsykim` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes%2Fcloud-provider-aws).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes/cloud-provider-aws/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["andrewsykim"]} -->
ayberk: /hold until we update to 1.20 (#151)
andrewsykim: /ok-to-test
k8s-ci-robot: @ayberk: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-cloud-provider-aws-test | 2987c1e72db5e8d3bb81e88a8f79386f371bffeb | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/cloud-provider-aws/157/pull-cloud-provider-aws-test/1334301615816970240) | `/test pull-cloud-provider-aws-test`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes&repo=cloud-provider-aws&pr=157). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Aayberk). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes/cloud-provider-aws/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @ayberk: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-cloud-provider-aws-test | 2987c1e72db5e8d3bb81e88a8f79386f371bffeb | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/cloud-provider-aws/157/pull-cloud-provider-aws-test/1334301615816970240) | `/test pull-cloud-provider-aws-test`
pull-cloud-provider-aws-check | 2987c1e72db5e8d3bb81e88a8f79386f371bffeb | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/cloud-provider-aws/157/pull-cloud-provider-aws-check/1334301615795998720) | `/test pull-cloud-provider-aws-check`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes&repo=cloud-provider-aws&pr=157). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Aayberk). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes/cloud-provider-aws/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @ayberk: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-cloud-provider-aws-test | 2987c1e72db5e8d3bb81e88a8f79386f371bffeb | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/cloud-provider-aws/157/pull-cloud-provider-aws-test/1334301615816970240) | `/test pull-cloud-provider-aws-test`
pull-cloud-provider-aws-check | fbb119400f43d67c51eb6c3132adcf89bb3969e0 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/cloud-provider-aws/157/pull-cloud-provider-aws-check/1337527745298567168) | `/test pull-cloud-provider-aws-check`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes&repo=cloud-provider-aws&pr=157). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Aayberk). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes/cloud-provider-aws/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
ayberk: /test pull-cloud-provider-aws-check
k8s-ci-robot: @ayberk: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-cloud-provider-aws-check | fbb119400f43d67c51eb6c3132adcf89bb3969e0 | [link](https://prow.k8s.io/view/gs/kubernetes-jenkins/pr-logs/pull/cloud-provider-aws/157/pull-cloud-provider-aws-check/1337533621124206592) | `/test pull-cloud-provider-aws-check`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes&repo=cloud-provider-aws&pr=157). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Aayberk). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes/cloud-provider-aws/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
ayberk: /unhold

Patch:
diff --git a/.gitignore b/.gitignore
index f922b97..2e628ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
 /aws-cloud-controller-manager
+/ecr-credential-provider
 /cloudconfig
+
+.vscode/
diff --git a/Makefile b/Makefile
index d1a6900..c1227e0 100644
--- a/Makefile
+++ b/Makefile
@@ -26,6 +26,12 @@ aws-cloud-controller-manager: $(SOURCES)
 		-o=aws-cloud-controller-manager \
 		cmd/aws-cloud-controller-manager/main.go
 
+ecr-credential-provider:  $(shell find ./cmd/ecr-credential-provider -name '*.go')
+	 GO111MODULE=on CGO_ENABLED=0 GOOS=$(GOOS) GOPROXY=$(GOPROXY) go build \
+		-ldflags="-w -s -X 'main.version=$(VERSION)'" \
+		-o=ecr-credential-provider \
+		cmd/ecr-credential-provider/*.go
+
 .PHONY: docker-build
 docker-build:
 	docker build \
diff --git a/cmd/aws-cloud-controller-manager/main.go b/cmd/aws-cloud-controller-manager/main.go
index a559199..1981304 100644
--- a/cmd/aws-cloud-controller-manager/main.go
+++ b/cmd/aws-cloud-controller-manager/main.go
@@ -117,10 +117,8 @@ func main() {
 				os.Exit(1)
 			}
 
-			cloudConfigFile := c.ComponentConfig.KubeCloudShared.CloudProvider.CloudConfigFile
-
 			// initialize cloud provider with the cloud provider name and config file provided
-			cloud, err := cloudprovider.InitCloudProvider(cloudProvider, cloudConfigFile)
+			cloud, err := cloudprovider.InitCloudProvider(cloudProvider, "")
 			if err != nil {
 				klog.Fatalf("Cloud provider could not be initialized: %v", err)
 			}
diff --git a/cmd/ecr-credential-provider/main.go b/cmd/ecr-credential-provider/main.go
new file mode 100644
index 0000000..096fa9d
--- /dev/null
+++ b/cmd/ecr-credential-provider/main.go
@@ -0,0 +1,159 @@
+/*
+Copyright 2020 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"context"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"net/url"
+	"os"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/ecr"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/klog/v2"
+	"k8s.io/kubelet/pkg/apis/credentialprovider/v1alpha1"
+)
+
+var ecrPattern = regexp.MustCompile(`^(\d{12})\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.(amazonaws\.com(\.cn)?|sc2s\.sgov\.gov|c2s\.ic\.gov)$`)
+
+// ECR abstracts the calls we make to aws-sdk for testing purposes
+type ECR interface {
+	GetAuthorizationToken(input *ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error)
+}
+
+type ecrPlugin struct {
+	ecr ECR
+}
+
+func defaultECRProvider(region string, registryID string) (*ecr.ECR, error) {
+	sess, err := session.NewSessionWithOptions(session.Options{
+		Config:            aws.Config{Region: aws.String(region)},
+		SharedConfigState: session.SharedConfigEnable,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return ecr.New(sess), nil
+}
+
+func (e *ecrPlugin) GetCredentials(ctx context.Context, image string, args []string) (*v1alpha1.CredentialProviderResponse, error) {
+	registryID, region, registry, err := parseRepoURL(image)
+	if err != nil {
+		return nil, err
+	}
+
+	if e.ecr == nil {
+		e.ecr, err = defaultECRProvider(region, registryID)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	output, err := e.ecr.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{
+		RegistryIds: []*string{aws.String(registryID)},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	if output == nil {
+		return nil, errors.New("response output from ECR was nil")
+	}
+
+	if len(output.AuthorizationData) == 0 {
+		return nil, errors.New("authorization data was empty")
+	}
+
+	data := output.AuthorizationData[0]
+	if data.AuthorizationToken == nil {
+		return nil, errors.New("authorization token in response was nil")
+	}
+
+	decodedToken, err := base64.StdEncoding.DecodeString(aws.StringValue(data.AuthorizationToken))
+	if err != nil {
+		return nil, err
+	}
+
+	parts := strings.SplitN(string(decodedToken), ":", 2)
+	if len(parts) != 2 {
+		return nil, errors.New("error parsing username and password from authorization token")
+	}
+
+	var cacheDuration *metav1.Duration
+	expiresAt := data.ExpiresAt
+	if expiresAt == nil {
+		// explicitly set cache duration to 0 if expiresAt was nil so that
+		// kubelet does not cache it in-memory
+		cacheDuration = &metav1.Duration{Duration: 0}
+	} else {
+		// halving duration in order to compensate for the time loss between
+		// the token creation and passing it all the way to kubelet.
+		duration := time.Duration((expiresAt.Unix() - time.Now().Unix()) / 2)
+		if duration > 0 {
+			cacheDuration = &metav1.Duration{Duration: duration}
+		}
+	}
+
+	return &v1alpha1.CredentialProviderResponse{
+		CacheKeyType:  v1alpha1.RegistryPluginCacheKeyType,
+		CacheDuration: cacheDuration,
+		Auth: map[string]v1alpha1.AuthConfig{
+			registry: {
+				Username: parts[0],
+				Password: parts[1],
+			},
+		},
+	}, nil
+
+}
+
+// parseRepoURL parses and splits the registry URL
+// returns (registryID, region, registry).
+// <registryID>.dkr.ecr(-fips).<region>.amazonaws.com(.cn)
+func parseRepoURL(image string) (string, string, string, error) {
+	if !strings.Contains(image, "https://") {
+		image = "https://" + image
+	}
+	parsed, err := url.Parse(image)
+	if err != nil {
+		return "", "", "", fmt.Errorf("error parsing image %s: %v", image, err)
+	}
+
+	splitURL := ecrPattern.FindStringSubmatch(parsed.Hostname())
+	if len(splitURL) < 4 {
+		return "", "", "", fmt.Errorf("%s is not a valid ECR repository URL", parsed.Hostname())
+	}
+
+	return splitURL[1], splitURL[3], parsed.Hostname(), nil
+}
+
+func main() {
+	p := NewCredentialProvider(&ecrPlugin{})
+	if err := p.Run(context.TODO()); err != nil {
+		klog.Errorf("Error running credential provider plugin: %v", err)
+		os.Exit(1)
+	}
+}
diff --git a/cmd/ecr-credential-provider/plugin.go b/cmd/ecr-credential-provider/plugin.go
new file mode 100644
index 0000000..2a43706
--- /dev/null
+++ b/cmd/ecr-credential-provider/plugin.go
@@ -0,0 +1,152 @@
+/*
+Copyright 2020 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"bufio"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/serializer"
+	"k8s.io/apimachinery/pkg/runtime/serializer/json"
+	"k8s.io/kubelet/pkg/apis/credentialprovider/install"
+	"k8s.io/kubelet/pkg/apis/credentialprovider/v1alpha1"
+)
+
+var (
+	scheme = runtime.NewScheme()
+	codecs = serializer.NewCodecFactory(scheme)
+)
+
+func init() {
+	install.Install(scheme)
+}
+
+// CredentialProvider is an interface implemented by the kubelet credential provider plugin to fetch
+// the username/password based on the provided image name.
+type CredentialProvider interface {
+	GetCredentials(ctx context.Context, image string, args []string) (response *v1alpha1.CredentialProviderResponse, err error)
+}
+
+// ExecPlugin implements the exec-based plugin for fetching credentials that is invoked by the kubelet.
+type ExecPlugin struct {
+	plugin CredentialProvider
+}
+
+// NewCredentialProvider returns an instance of execPlugin that fetches
+// credentials based on the provided plugin implementing the CredentialProvider interface.
+func NewCredentialProvider(plugin CredentialProvider) *ExecPlugin {
+	return &ExecPlugin{plugin}
+}
+
+// Run executes the credential provider plugin. Required information for the plugin request (in
+// the form of v1alpha1.CredentialProviderRequest) is provided via stdin from the kubelet.
+// The CredentialProviderResponse, containing the username/password required for pulling
+// the provided image, will be sent back to the kubelet via stdout.
+func (e *ExecPlugin) Run(ctx context.Context) error {
+	return e.runPlugin(ctx, os.Stdin, os.Stdout, os.Args[1:])
+}
+
+func (e *ExecPlugin) runPlugin(ctx context.Context, r io.Reader, w io.Writer, args []string) error {
+	data, err := ioutil.ReadAll(r)
+	if err != nil {
+		return err
+	}
+
+	gvk, err := json.DefaultMetaFactory.Interpret(data)
+	if err != nil {
+		return err
+	}
+
+	if gvk.GroupVersion() != v1alpha1.SchemeGroupVersion {
+		return fmt.Errorf("group version %s is not supported", gvk.GroupVersion())
+	}
+
+	request, err := decodeRequest(data)
+	if err != nil {
+		return err
+	}
+
+	if request.Image == "" {
+		return errors.New("image in plugin request was empty")
+	}
+
+	response, err := e.plugin.GetCredentials(ctx, request.Image, args)
+	if err != nil {
+		return err
+	}
+
+	if response == nil {
+		return errors.New("CredentialProviderResponse from plugin was nil")
+	}
+
+	encodedResponse, err := encodeResponse(response)
+	if err != nil {
+		return err
+	}
+
+	writer := bufio.NewWriter(w)
+	defer writer.Flush()
+	if _, err := writer.Write(encodedResponse); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func decodeRequest(data []byte) (*v1alpha1.CredentialProviderRequest, error) {
+	obj, gvk, err := codecs.UniversalDecoder(v1alpha1.SchemeGroupVersion).Decode(data, nil, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	if gvk.Kind != "CredentialProviderRequest" {
+		return nil, fmt.Errorf("kind was %q, expected CredentialProviderRequest", gvk.Kind)
+	}
+
+	if gvk.Group != v1alpha1.GroupName {
+		return nil, fmt.Errorf("group was %q, expected %s", gvk.Group, v1alpha1.GroupName)
+	}
+
+	request, ok := obj.(*v1alpha1.CredentialProviderRequest)
+	if !ok {
+		return nil, fmt.Errorf("unable to convert %T to *CredentialProviderRequest", obj)
+	}
+
+	return request, nil
+}
+
+func encodeResponse(response *v1alpha1.CredentialProviderResponse) ([]byte, error) {
+	mediaType := "application/json"
+	info, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), mediaType)
+	if !ok {
+		return nil, fmt.Errorf("unsupported media type %q", mediaType)
+	}
+
+	encoder := codecs.EncoderForVersion(info.Serializer, v1alpha1.SchemeGroupVersion)
+	data, err := runtime.Encode(encoder, response)
+	if err != nil {
+		return nil, fmt.Errorf("failed to encode response: %v", err)
+	}
+
+	return data, nil
+}
diff --git a/go.mod b/go.mod
index 2c31d90..e1e6edf 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
 	k8s.io/code-generator v0.20.0
 	k8s.io/component-base v0.20.0
 	k8s.io/klog/v2 v2.4.0
+	k8s.io/kubelet v0.20.0
 	k8s.io/legacy-cloud-providers v0.20.0
 	k8s.io/utils v0.0.0-20201110183641-67b214c5f920
 )
diff --git a/go.sum b/go.sum
index e9ae0d4..c1e1d3a 100644
--- a/go.sum
+++ b/go.sum
@@ -797,6 +797,9 @@ k8s.io/klog/v2 v2.4.0 h1:7+X0fUguPyrKEC4WjH8iGDg3laWgMo5tMnRTIGTTxGQ=
 k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
 k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd h1:sOHNzJIkytDF6qadMNKhhDRpc6ODik8lVC6nOur7B2c=
 k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM=
+k8s.io/kubelet v0.20.0 h1:vtQMgZ7B79vCUyEpoij2Oy6UmcJx0/D2lNXfViVTBMg=
+k8s.io/kubelet v0.20.0/go.mod h1:lMdjO1NA+JZXSYtxb48pQmNERmC+vVIXIYkJIugVhl0=
+k8s.io/kubernetes v1.20.0 h1:mnc69esJC3PJgSptxNJomGz2gBthyGLSEy18WiyRH4U=
 k8s.io/legacy-cloud-providers v0.20.0 h1:bF2WKO3ZWEjBvCv9RJxsxOAGrmQHWSgTiDBjK8uW2iw=
 k8s.io/legacy-cloud-providers v0.20.0/go.mod h1:1jEkaU7h9+b1EYdfWDBvhFAr+QpRfUjQfK+dGhxPGfA=
 k8s.io/utils v0.0.0-20201110183641-67b214c5f920 h1:CbnUZsM497iRC5QMVkHwyl8s2tB3g7yaSHkYPkpgelw=
diff --git a/pkg/providers/v2/mocks/mock_ecr.go b/pkg/providers/v2/mocks/mock_ecr.go
new file mode 100644
index 0000000..a4d6a8b
--- /dev/null
+++ b/pkg/providers/v2/mocks/mock_ecr.go
@@ -0,0 +1,49 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: cmd/ecr-credential-provider/main.go
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	ecr "github.com/aws/aws-sdk-go/service/ecr"
+	gomock "github.com/golang/mock/gomock"
+	reflect "reflect"
+)
+
+// MockECR is a mock of ECR interface
+type MockECR struct {
+	ctrl     *gomock.Controller
+	recorder *MockECRMockRecorder
+}
+
+// MockECRMockRecorder is the mock recorder for MockECR
+type MockECRMockRecorder struct {
+	mock *MockECR
+}
+
+// NewMockECR creates a new mock instance
+func NewMockECR(ctrl *gomock.Controller) *MockECR {
+	mock := &MockECR{ctrl: ctrl}
+	mock.recorder = &MockECRMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockECR) EXPECT() *MockECRMockRecorder {
+	return m.recorder
+}
+
+// GetAuthorizationToken mocks base method
+func (m *MockECR) GetAuthorizationToken(input *ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetAuthorizationToken", input)
+	ret0, _ := ret[0].(*ecr.GetAuthorizationTokenOutput)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetAuthorizationToken indicates an expected call of GetAuthorizationToken
+func (mr *MockECRMockRecorder) GetAuthorizationToken(input interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizationToken", reflect.TypeOf((*MockECR)(nil).GetAuthorizationToken), input)
+}


Test patch:
diff --git a/cmd/ecr-credential-provider/main_test.go b/cmd/ecr-credential-provider/main_test.go
new file mode 100644
index 0000000..181c400
--- /dev/null
+++ b/cmd/ecr-credential-provider/main_test.go
@@ -0,0 +1,250 @@
+/*
+Copyright 2020 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"context"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/ecr"
+	"github.com/golang/mock/gomock"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/cloud-provider-aws/pkg/providers/v2/mocks"
+	"k8s.io/kubelet/pkg/apis/credentialprovider/v1alpha1"
+)
+
+func generateGetAuthorizationTokenOutput(user string, password string, proxy string, expiration *time.Time) *ecr.GetAuthorizationTokenOutput {
+	creds := []byte(fmt.Sprintf("%s:%s", user, password))
+	data := &ecr.AuthorizationData{
+		AuthorizationToken: aws.String(base64.StdEncoding.EncodeToString(creds)),
+		ExpiresAt:          expiration,
+		ProxyEndpoint:      aws.String(proxy),
+	}
+	output := &ecr.GetAuthorizationTokenOutput{
+		AuthorizationData: []*ecr.AuthorizationData{data},
+	}
+	return output
+}
+
+func generateResponse(registry string, username string, password string) *v1alpha1.CredentialProviderResponse {
+	return &v1alpha1.CredentialProviderResponse{
+		CacheKeyType:  v1alpha1.RegistryPluginCacheKeyType,
+		CacheDuration: &metav1.Duration{Duration: 0},
+		Auth: map[string]v1alpha1.AuthConfig{
+			registry: {
+				Username: username,
+				Password: password,
+			},
+		},
+	}
+}
+
+func Test_GetCredentials(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockECR := mocks.NewMockECR(ctrl)
+
+	testcases := []struct {
+		name                        string
+		image                       string
+		args                        []string
+		getAuthorizationTokenOutput *ecr.GetAuthorizationTokenOutput
+		getAuthorizationTokenError  error
+		response                    *v1alpha1.CredentialProviderResponse
+		expectedError               error
+	}{
+		{
+			name:                        "success",
+			image:                       "123456789123.dkr.ecr.us-west-2.amazonaws.com",
+			getAuthorizationTokenOutput: generateGetAuthorizationTokenOutput("user", "pass", "", nil),
+			response:                    generateResponse("123456789123.dkr.ecr.us-west-2.amazonaws.com", "user", "pass"),
+		},
+		{
+			name:                        "empty authorization data",
+			image:                       "123456789123.dkr.ecr.us-west-2.amazonaws.com",
+			getAuthorizationTokenOutput: &ecr.GetAuthorizationTokenOutput{},
+			getAuthorizationTokenError:  nil,
+			expectedError:               errors.New("authorization data was empty"),
+		},
+		{
+			name:                        "nil response",
+			image:                       "123456789123.dkr.ecr.us-west-2.amazonaws.com",
+			getAuthorizationTokenOutput: nil,
+			getAuthorizationTokenError:  nil,
+			expectedError:               errors.New("response output from ECR was nil"),
+		},
+		{
+			name:                        "empty authorization token",
+			image:                       "123456789123.dkr.ecr.us-west-2.amazonaws.com",
+			getAuthorizationTokenOutput: &ecr.GetAuthorizationTokenOutput{AuthorizationData: []*ecr.AuthorizationData{{}}},
+			getAuthorizationTokenError:  nil,
+			expectedError:               errors.New("authorization token in response was nil"),
+		},
+		{
+			name:                        "invalid authorization token",
+			image:                       "123456789123.dkr.ecr.us-west-2.amazonaws.com",
+			getAuthorizationTokenOutput: nil,
+			getAuthorizationTokenError:  errors.New("getAuthorizationToken failed"),
+			expectedError:               errors.New("getAuthorizationToken failed"),
+		},
+		{
+			name:  "invalid authorization token",
+			image: "123456789123.dkr.ecr.us-west-2.amazonaws.com",
+			getAuthorizationTokenOutput: &ecr.GetAuthorizationTokenOutput{
+				AuthorizationData: []*ecr.AuthorizationData{
+					{AuthorizationToken: aws.String(base64.StdEncoding.EncodeToString([]byte(fmt.Sprint("foo"))))},
+				},
+			},
+			getAuthorizationTokenError: nil,
+			expectedError:              errors.New("error parsing username and password from authorization token"),
+		},
+	}
+
+	for _, testcase := range testcases {
+		t.Run(testcase.name, func(t *testing.T) {
+			p := &ecrPlugin{ecr: mockECR}
+			mockECR.EXPECT().GetAuthorizationToken(gomock.Any()).Return(testcase.getAuthorizationTokenOutput, testcase.getAuthorizationTokenError)
+
+			creds, err := p.GetCredentials(context.TODO(), testcase.image, testcase.args)
+
+			if testcase.expectedError != nil && (testcase.expectedError.Error() != err.Error()) {
+				t.Fatalf("expected %s, got %s", testcase.expectedError.Error(), err.Error())
+			}
+
+			if testcase.expectedError == nil {
+				if creds.CacheKeyType != testcase.response.CacheKeyType {
+					t.Fatalf("Unexpected CacheKeyType. Expected: %s, got: %s", testcase.response.CacheKeyType, creds.CacheKeyType)
+				}
+
+				if creds.Auth[testcase.image] != testcase.response.Auth[testcase.image] {
+					t.Fatalf("Unexpected Auth. Expected: %s, got: %s", testcase.response.Auth[testcase.image], creds.Auth[testcase.image])
+				}
+
+				if creds.CacheDuration.Duration != testcase.response.CacheDuration.Duration {
+					t.Fatalf("Unexpected CacheDuration. Expected: %s, got: %s", testcase.response.CacheDuration.Duration, creds.CacheDuration.Duration)
+				}
+			}
+		})
+	}
+}
+
+func Test_ParseURL(t *testing.T) {
+	testcases := []struct {
+		name       string
+		image      string
+		registryID string
+		region     string
+		registry   string
+		err        error
+	}{
+		{
+			name:       "success",
+			image:      "123456789123.dkr.ecr.us-west-2.amazonaws.com",
+			registryID: "123456789123",
+			region:     "us-west-2",
+			registry:   "123456789123.dkr.ecr.us-west-2.amazonaws.com",
+			err:        nil,
+		},
+		{
+			name:       "invalid registry",
+			image:      "foobar",
+			registryID: "",
+			region:     "",
+			registry:   "",
+			err:        errors.New("foobar is not a valid ECR repository URL"),
+		},
+		{
+			name:       "invalid URL",
+			image:      "foobar  ",
+			registryID: "",
+			region:     "",
+			registry:   "",
+			err:        errors.New("error parsing image https://foobar  : parse \"https://foobar  \": invalid character \" \" in host name"),
+		},
+	}
+
+	for _, testcase := range testcases {
+		t.Run(testcase.name, func(t *testing.T) {
+			registryID, region, registry, err := parseRepoURL(testcase.image)
+
+			if testcase.err != nil && (testcase.err.Error() != err.Error()) {
+				t.Fatalf("expected error %s, got %s", testcase.err, err)
+			}
+
+			if registryID != testcase.registryID {
+				t.Fatalf("registryID mismatch. Expected %s, got %s", testcase.registryID, registryID)
+			}
+
+			if region != testcase.region {
+				t.Fatalf("region mismatch. Expected %s, got %s", testcase.region, region)
+			}
+
+			if registry != testcase.registry {
+				t.Fatalf("registry mismatch. Expected %s, got %s", testcase.registry, registry)
+			}
+		})
+	}
+}
+
+func TestRegistryPatternMatch(t *testing.T) {
+	grid := []struct {
+		Registry string
+		Expected bool
+	}{
+		{"123456789012.dkr.ecr.lala-land-1.amazonaws.com", true},
+		// fips
+		{"123456789012.dkr.ecr-fips.lala-land-1.amazonaws.com", true},
+		// .cn
+		{"123456789012.dkr.ecr.lala-land-1.amazonaws.com.cn", true},
+		// registry ID too long
+		{"1234567890123.dkr.ecr.lala-land-1.amazonaws.com", false},
+		// registry ID too short
+		{"12345678901.dkr.ecr.lala-land-1.amazonaws.com", false},
+		// registry ID has invalid chars
+		{"12345678901A.dkr.ecr.lala-land-1.amazonaws.com", false},
+		// region has invalid chars
+		{"123456789012.dkr.ecr.lala-land-1!.amazonaws.com", false},
+		// region starts with invalid char
+		{"123456789012.dkr.ecr.#lala-land-1.amazonaws.com", false},
+		// invalid host suffix
+		{"123456789012.dkr.ecr.lala-land-1.amazonaws.hacker.com", false},
+		// invalid host suffix
+		{"123456789012.dkr.ecr.lala-land-1.hacker.com", false},
+		// invalid host suffix
+		{"123456789012.dkr.ecr.lala-land-1.amazonaws.lol", false},
+		// without dkr
+		{"123456789012.dog.ecr.lala-land-1.amazonaws.com", false},
+		// without ecr
+		{"123456789012.dkr.cat.lala-land-1.amazonaws.com", false},
+		// without amazonaws
+		{"123456789012.dkr.cat.lala-land-1.awsamazon.com", false},
+		// too short
+		{"123456789012.lala-land-1.amazonaws.com", false},
+	}
+	for _, g := range grid {
+		actual := ecrPattern.MatchString(g.Registry)
+		if actual != g.Expected {
+			t.Errorf("unexpected pattern match value, want %v for %s", g.Expected, g.Registry)
+		}
+	}
+}
diff --git a/cmd/ecr-credential-provider/plugin_test.go b/cmd/ecr-credential-provider/plugin_test.go
new file mode 100644
index 0000000..456602d
--- /dev/null
+++ b/cmd/ecr-credential-provider/plugin_test.go
@@ -0,0 +1,101 @@
+/*
+Copyright 2020 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"bytes"
+	"context"
+	"reflect"
+	"testing"
+	"time"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/kubelet/pkg/apis/credentialprovider/v1alpha1"
+)
+
+type fakePlugin struct {
+}
+
+func (f *fakePlugin) GetCredentials(ctx context.Context, image string, args []string) (*v1alpha1.CredentialProviderResponse, error) {
+	return &v1alpha1.CredentialProviderResponse{
+		CacheKeyType:  v1alpha1.RegistryPluginCacheKeyType,
+		CacheDuration: &metav1.Duration{Duration: 10 * time.Minute},
+		Auth: map[string]v1alpha1.AuthConfig{
+			"*.registry.io": {
+				Username: "user",
+				Password: "password",
+			},
+		},
+	}, nil
+}
+
+func Test_runPlugin(t *testing.T) {
+	testcases := []struct {
+		name        string
+		in          *bytes.Buffer
+		expectedOut []byte
+		expectErr   bool
+	}{
+		{
+			name: "successful test case",
+			in:   bytes.NewBufferString(`{"kind":"CredentialProviderRequest","apiVersion":"credentialprovider.kubelet.k8s.io/v1alpha1","image":"test.registry.io/foobar"}`),
+			expectedOut: []byte(`{"kind":"CredentialProviderResponse","apiVersion":"credentialprovider.kubelet.k8s.io/v1alpha1","cacheKeyType":"Registry","cacheDuration":"10m0s","auth":{"*.registry.io":{"username":"user","password":"password"}}}
+`),
+			expectErr: false,
+		},
+		{
+			name:        "invalid kind",
+			in:          bytes.NewBufferString(`{"kind":"CredentialProviderFoo","apiVersion":"credentialprovider.kubelet.k8s.io/v1alpha1","image":"test.registry.io/foobar"}`),
+			expectedOut: nil,
+			expectErr:   true,
+		},
+		{
+			name:        "invalid apiVersion",
+			in:          bytes.NewBufferString(`{"kind":"CredentialProviderRequest","apiVersion":"foo.k8s.io/v1alpha1","image":"test.registry.io/foobar"}`),
+			expectedOut: nil,
+			expectErr:   true,
+		},
+		{
+			name:        "empty image",
+			in:          bytes.NewBufferString(`{"kind":"CredentialProviderRequest","apiVersion":"credentialprovider.kubelet.k8s.io/v1alpha1","image":""}`),
+			expectedOut: nil,
+			expectErr:   true,
+		},
+	}
+
+	for _, testcase := range testcases {
+		t.Run(testcase.name, func(t *testing.T) {
+			p := NewCredentialProvider(&fakePlugin{})
+
+			out := &bytes.Buffer{}
+			err := p.runPlugin(context.TODO(), testcase.in, out, nil)
+			if err != nil && !testcase.expectErr {
+				t.Fatal(err)
+			}
+
+			if err == nil && testcase.expectErr {
+				t.Error("expected error but got none")
+			}
+
+			if !reflect.DeepEqual(out.Bytes(), testcase.expectedOut) {
+				t.Logf("actual output: %v", string(out.Bytes()))
+				t.Logf("expected  output: %v", string(testcase.expectedOut))
+				t.Errorf("unexpected output")
+			}
+		})
+	}
+}