Repository: external-secrets/external-secrets (go)

Issue:
Add support for AWS ECR Public in `ECRAuthorizationToken` generator
**Is your feature request related to a problem? Please describe.**
The `ECRAuthorizationToken` generator kind seems to only be able to get a private ECR authorization token. This token is only valid for private registries within the AWS account. If you would like to use the AWS ECR Public Gallery, there is a separate API and IAM permission associated with it that needs to be used.

The AWS CLI command is as follows for logging in to such a registry:

`aws ecr-public get-login-password --region us-east-1`

Note the use of `ecr-public` vs `ecr`, and the region I believe has to be `us-east-1` (global).

**Describe the solution you'd like**
The ability to manage AWS ECR Public authorization tokens via `external-secrets`. This would greatly simplify credential management for OCI repositories which are hosted in the AWS public gallery. Right now its a mess of cronjobs to try and fudge it.

**Describe alternatives you've considered**
One solution might be the implementation of an `ECRPublicAuthorizationToken` kind. I don't really know enough about the implementation to know whether or not adding a `scope: <private|public>` to the `spec` might be possible while still using an `ECRAuthorizationToken`.

**Additional context**
The `ecrpublic` service appears to have dedicated support in the AWS SDK for it:

https://github.com/aws/aws-sdk-go/tree/main/service/ecrpublic

I would have a go at trying to implement something, but fear the result would be somewhat crude.

Pull request:
Add AWS ECR Public authorization token support
## Problem Statement

Add support for AWS ECR Public authorization tokens

## Related Issue

Fixes #4228

## Proposed Changes

Added an initial implementation which  ECR Public authorization tokens

## Checklist

- [X] I have read the [contribution guidelines](https://external-secrets.io/latest/contributing/process/#submitting-a-pull-request)
- [X] All commits are signed with `git commit --signoff`
- [X] My changes have reasonable test coverage
- [X] All tests pass with `make test`
- [X] I ensured my PR is ready for review with `make reviewable`


Patch:
diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml
index e135f9bb2..3ace57443 100644
--- a/.github/workflows/helm.yml
+++ b/.github/workflows/helm.yml
@@ -36,7 +36,7 @@ jobs:
 
       - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
         with:
-          python-version: 3.7
+          python-version: 3.11
 
       - name: Set up chart-testing
         uses: helm/chart-testing-action@e6669bcd63d7cb57cb4380c33043eebe5d111992 # v2.6.1
diff --git a/apis/generators/v1alpha1/types_ecr.go b/apis/generators/v1alpha1/types_ecr.go
index b66a7f3b2..19e124b86 100644
--- a/apis/generators/v1alpha1/types_ecr.go
+++ b/apis/generators/v1alpha1/types_ecr.go
@@ -32,6 +32,11 @@ type ECRAuthorizationTokenSpec struct {
 	// desired AWS service.
 	// +optional
 	Role string `json:"role,omitempty"`
+
+	// Scope specifies the ECR service scope.
+	// Valid options are private and public.
+	// +optional
+	Scope string `json:"scope,omitempty"`
 }
 
 // AWSAuth tells the controller how to do authentication with aws.
diff --git a/config/crds/bases/generators.external-secrets.io_clustergenerators.yaml b/config/crds/bases/generators.external-secrets.io_clustergenerators.yaml
index a4bf5a1a5..c5bcaa0fa 100644
--- a/config/crds/bases/generators.external-secrets.io_clustergenerators.yaml
+++ b/config/crds/bases/generators.external-secrets.io_clustergenerators.yaml
@@ -348,6 +348,11 @@ spec:
                           You can assume a role before making calls to the
                           desired AWS service.
                         type: string
+                      scope:
+                        description: |-
+                          Scope specifies the ECR service scope.
+                          Valid options are private and public.
+                        type: string
                     required:
                     - region
                     type: object
diff --git a/config/crds/bases/generators.external-secrets.io_ecrauthorizationtokens.yaml b/config/crds/bases/generators.external-secrets.io_ecrauthorizationtokens.yaml
index 292bee764..9d10f672c 100644
--- a/config/crds/bases/generators.external-secrets.io_ecrauthorizationtokens.yaml
+++ b/config/crds/bases/generators.external-secrets.io_ecrauthorizationtokens.yaml
@@ -183,6 +183,11 @@ spec:
                   You can assume a role before making calls to the
                   desired AWS service.
                 type: string
+              scope:
+                description: |-
+                  Scope specifies the ECR service scope.
+                  Valid options are private and public.
+                type: string
             required:
             - region
             type: object
diff --git a/deploy/crds/bundle.yaml b/deploy/crds/bundle.yaml
index 85d24bc2b..68f233358 100644
--- a/deploy/crds/bundle.yaml
+++ b/deploy/crds/bundle.yaml
@@ -14170,6 +14170,11 @@ spec:
                             You can assume a role before making calls to the
                             desired AWS service.
                           type: string
+                        scope:
+                          description: |-
+                            Scope specifies the ECR service scope.
+                            Valid options are private and public.
+                          type: string
                       required:
                         - region
                       type: object
@@ -15652,6 +15657,11 @@ spec:
                     You can assume a role before making calls to the
                     desired AWS service.
                   type: string
+                scope:
+                  description: |-
+                    Scope specifies the ECR service scope.
+                    Valid options are private and public.
+                  type: string
               required:
                 - region
               type: object
diff --git a/pkg/generator/ecr/ecr.go b/pkg/generator/ecr/ecr.go
index 257ea4baf..578b492d3 100644
--- a/pkg/generator/ecr/ecr.go
+++ b/pkg/generator/ecr/ecr.go
@@ -25,6 +25,8 @@ import (
 	"github.com/aws/aws-sdk-go/aws/session"
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/aws/aws-sdk-go/service/ecr/ecriface"
+	"github.com/aws/aws-sdk-go/service/ecrpublic"
+	"github.com/aws/aws-sdk-go/service/ecrpublic/ecrpubliciface"
 	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/yaml"
@@ -37,14 +39,15 @@ import (
 type Generator struct{}
 
 const (
-	errNoSpec     = "no config spec provided"
-	errParseSpec  = "unable to parse spec: %w"
-	errCreateSess = "unable to create aws session: %w"
-	errGetToken   = "unable to get authorization token: %w"
+	errNoSpec          = "no config spec provided"
+	errParseSpec       = "unable to parse spec: %w"
+	errCreateSess      = "unable to create aws session: %w"
+	errGetPrivateToken = "unable to get authorization token: %w"
+	errGetPublicToken  = "unable to get public authorization token: %w"
 )
 
 func (g *Generator) Generate(ctx context.Context, jsonSpec *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, error) {
-	return g.generate(ctx, jsonSpec, kube, namespace, ecrFactory)
+	return g.generate(ctx, jsonSpec, kube, namespace, ecrPrivateFactory, ecrPublicFactory)
 }
 
 func (g *Generator) generate(
@@ -52,7 +55,8 @@ func (g *Generator) generate(
 	jsonSpec *apiextensions.JSON,
 	kube client.Client,
 	namespace string,
-	ecrFunc ecrFactoryFunc,
+	ecrPrivateFunc ecrPrivateFactoryFunc,
+	ecrPublicFunc ecrPublicFactoryFunc,
 ) (map[string][]byte, error) {
 	if jsonSpec == nil {
 		return nil, errors.New(errNoSpec)
@@ -76,10 +80,19 @@ func (g *Generator) generate(
 	if err != nil {
 		return nil, fmt.Errorf(errCreateSess, err)
 	}
-	client := ecrFunc(sess)
+
+	if res.Spec.Scope == "public" {
+		return fetchECRPublicToken(sess, ecrPublicFunc)
+	}
+
+	return fetchECRPrivateToken(sess, ecrPrivateFunc)
+}
+
+func fetchECRPrivateToken(sess *session.Session, ecrPrivateFunc ecrPrivateFactoryFunc) (map[string][]byte, error) {
+	client := ecrPrivateFunc(sess)
 	out, err := client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
 	if err != nil {
-		return nil, fmt.Errorf(errGetToken, err)
+		return nil, fmt.Errorf(errGetPrivateToken, err)
 	}
 	if len(out.AuthorizationData) != 1 {
 		return nil, fmt.Errorf("unexpected number of authorization tokens. expected 1, found %d", len(out.AuthorizationData))
@@ -104,12 +117,41 @@ func (g *Generator) generate(
 	}, nil
 }
 
-type ecrFactoryFunc func(aws *session.Session) ecriface.ECRAPI
+func fetchECRPublicToken(sess *session.Session, ecrPublicFunc ecrPublicFactoryFunc) (map[string][]byte, error) {
+	client := ecrPublicFunc(sess)
+	out, err := client.GetAuthorizationToken(&ecrpublic.GetAuthorizationTokenInput{})
+	if err != nil {
+		return nil, fmt.Errorf(errGetPublicToken, err)
+	}
+
+	decodedToken, err := base64.StdEncoding.DecodeString(*out.AuthorizationData.AuthorizationToken)
+	if err != nil {
+		return nil, err
+	}
+	parts := strings.Split(string(decodedToken), ":")
+	if len(parts) != 2 {
+		return nil, errors.New("unexpected token format")
+	}
+
+	exp := out.AuthorizationData.ExpiresAt.UTC().Unix()
+	return map[string][]byte{
+		"username":   []byte(parts[0]),
+		"password":   []byte(parts[1]),
+		"expires_at": []byte(strconv.FormatInt(exp, 10)),
+	}, nil
+}
+
+type ecrPrivateFactoryFunc func(aws *session.Session) ecriface.ECRAPI
+type ecrPublicFactoryFunc func(aws *session.Session) ecrpubliciface.ECRPublicAPI
 
-func ecrFactory(aws *session.Session) ecriface.ECRAPI {
+func ecrPrivateFactory(aws *session.Session) ecriface.ECRAPI {
 	return ecr.New(aws)
 }
 
+func ecrPublicFactory(aws *session.Session) ecrpubliciface.ECRPublicAPI {
+	return ecrpublic.New(aws)
+}
+
 func parseSpec(data []byte) (*genv1alpha1.ECRAuthorizationToken, error) {
 	var spec genv1alpha1.ECRAuthorizationToken
 	err := yaml.Unmarshal(data, &spec)


Test patch:
diff --git a/pkg/generator/ecr/ecr_test.go b/pkg/generator/ecr/ecr_test.go
index e16d7553b..1c9714db2 100644
--- a/pkg/generator/ecr/ecr_test.go
+++ b/pkg/generator/ecr/ecr_test.go
@@ -25,6 +25,8 @@ import (
 	"github.com/aws/aws-sdk-go/aws/session"
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/aws/aws-sdk-go/service/ecr/ecriface"
+	"github.com/aws/aws-sdk-go/service/ecrpublic"
+	"github.com/aws/aws-sdk-go/service/ecrpublic/ecrpubliciface"
 	v1 "k8s.io/api/core/v1"
 	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -35,11 +37,12 @@ import (
 
 func TestGenerate(t *testing.T) {
 	type args struct {
-		ctx           context.Context
-		jsonSpec      *apiextensions.JSON
-		kube          client.Client
-		namespace     string
-		authTokenFunc func(*ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error)
+		ctx                  context.Context
+		jsonSpec             *apiextensions.JSON
+		kube                 client.Client
+		namespace            string
+		authTokenPrivateFunc func(*ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error)
+		authTokenPublicFunc  func(*ecrpublic.GetAuthorizationTokenInput) (*ecrpublic.GetAuthorizationTokenOutput, error)
 	}
 	tests := []struct {
 		name    string
@@ -58,7 +61,7 @@ func TestGenerate(t *testing.T) {
 		{
 			name: "invalid json",
 			args: args{
-				authTokenFunc: func(gati *ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
+				authTokenPrivateFunc: func(gati *ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
 					return nil, errors.New("boom")
 				},
 				jsonSpec: &apiextensions.JSON{
@@ -68,7 +71,7 @@ func TestGenerate(t *testing.T) {
 			wantErr: true,
 		},
 		{
-			name: "full spec",
+			name: "private ECR full spec",
 			args: args{
 				namespace: "foobar",
 				kube: clientfake.NewClientBuilder().WithObjects(&v1.Secret{
@@ -81,7 +84,7 @@ func TestGenerate(t *testing.T) {
 						"access-secret": []byte("bar"),
 					},
 				}).Build(),
-				authTokenFunc: func(in *ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
+				authTokenPrivateFunc: func(in *ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
 					t := time.Unix(1234, 0)
 					return &ecr.GetAuthorizationTokenOutput{
 						AuthorizationData: []*ecr.AuthorizationData{
@@ -99,6 +102,7 @@ kind: ECRAuthorizationToken
 spec:
   region: eu-west-1
   role: "my-role"
+  scope: private
   auth:
     secretRef:
       accessKeyIDSecretRef:
@@ -116,6 +120,34 @@ spec:
 				"expires_at":     []byte("1234"),
 			},
 		},
+		{
+			name: "public ECR full spec",
+			args: args{
+				namespace: "foobar",
+				authTokenPublicFunc: func(in *ecrpublic.GetAuthorizationTokenInput) (*ecrpublic.GetAuthorizationTokenOutput, error) {
+					t := time.Unix(5678, 0)
+					return &ecrpublic.GetAuthorizationTokenOutput{
+						AuthorizationData: &ecrpublic.AuthorizationData{
+							AuthorizationToken: utilpointer.To(base64.StdEncoding.EncodeToString([]byte("pubuser:pubpass"))),
+							ExpiresAt:          &t,
+						},
+					}, nil
+				},
+				jsonSpec: &apiextensions.JSON{
+					Raw: []byte(`apiVersion: generators.external-secrets.io/v1alpha1
+kind: ECRAuthorizationToken
+spec:
+  region: us-east-1
+  role: "my-role"
+  scope: public`),
+				},
+			},
+			want: map[string][]byte{
+				"username":   []byte("pubuser"),
+				"password":   []byte("pubpass"),
+				"expires_at": []byte("5678"),
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -126,8 +158,13 @@ spec:
 				tt.args.kube,
 				tt.args.namespace,
 				func(aws *session.Session) ecriface.ECRAPI {
-					return &FakeECR{
-						authTokenFunc: tt.args.authTokenFunc,
+					return &FakeECRPrivate{
+						authTokenFunc: tt.args.authTokenPrivateFunc,
+					}
+				},
+				func(aws *session.Session) ecrpubliciface.ECRPublicAPI {
+					return &FakeECRPublic{
+						authTokenFunc: tt.args.authTokenPublicFunc,
 					}
 				},
 			)
@@ -142,11 +179,20 @@ spec:
 	}
 }
 
-type FakeECR struct {
+type FakeECRPrivate struct {
 	ecriface.ECRAPI
 	authTokenFunc func(*ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error)
 }
 
-func (e *FakeECR) GetAuthorizationToken(in *ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
+func (e *FakeECRPrivate) GetAuthorizationToken(in *ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
+	return e.authTokenFunc(in)
+}
+
+type FakeECRPublic struct {
+	ecrpubliciface.ECRPublicAPI
+	authTokenFunc func(*ecrpublic.GetAuthorizationTokenInput) (*ecrpublic.GetAuthorizationTokenOutput, error)
+}
+
+func (e *FakeECRPublic) GetAuthorizationToken(in *ecrpublic.GetAuthorizationTokenInput) (*ecrpublic.GetAuthorizationTokenOutput, error) {
 	return e.authTokenFunc(in)
 }