From c1fea1d2c0760ce0a6ddd510b8672c7d770b7b58 Mon Sep 17 00:00:00 2001 From: Paolo Cuffiani Date: Thu, 18 Apr 2024 14:46:25 +0200 Subject: [PATCH 1/4] refactor to implement a few unit tests, send coverage to Codecov --- .github/workflows/build.yml | 3 + ...ubmisson.yml => dependency-submission.yml} | 1 - .github/workflows/test.yml | 41 ++--- ecr/token.go | 72 ++++++++ ecr/token_test.go | 157 ++++++++++++++++++ go.mod | 2 +- main.go | 36 +--- 7 files changed, 248 insertions(+), 64 deletions(-) rename .github/workflows/{dependency-submisson.yml => dependency-submission.yml} (99%) create mode 100644 ecr/token.go create mode 100644 ecr/token_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e175552..5ff5c19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,9 @@ on: push: tags: [ v* ] +permissions: + contents: write + jobs: build: name: Build and release diff --git a/.github/workflows/dependency-submisson.yml b/.github/workflows/dependency-submission.yml similarity index 99% rename from .github/workflows/dependency-submisson.yml rename to .github/workflows/dependency-submission.yml index 13cb0e7..d7d2540 100644 --- a/.github/workflows/dependency-submisson.yml +++ b/.github/workflows/dependency-submission.yml @@ -7,7 +7,6 @@ on: permissions: contents: write - jobs: go-action-detection: name: Submit dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f27311..5131f99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [ main ] +permissions: + id-token: write + contents: read + jobs: lint: name: Run linter @@ -24,8 +28,8 @@ jobs: with: version: latest - build: - name: Test build succeeds + unit: + name: Run unit tests runs-on: ubuntu-latest timeout-minutes: 5 @@ -43,31 +47,10 @@ jobs: - name: Build run: go build -v ./... - # unit: - # name: Run unit tests - # runs-on: ubuntu-latest - # timeout-minutes: 5 - - # steps: - # - name: Checkout - # uses: actions/checkout@v4 - # with: - # fetch-depth: 2 - - # - name: Set up Go - # uses: actions/setup-go@v5 - # with: - # go-version-file: go.mod + - name: Test + run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... - # - name: Build - # run: go build -v ./... - - # - name: Test - # run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... - # timeout-minutes: 1 - - # - name: Upload coverage to Codecov - # run: bash <(curl -s https://codecov.io/bash) - # env: - # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # timeout-minutes: 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + use_oidc: true diff --git a/ecr/token.go b/ecr/token.go new file mode 100644 index 0000000..de32f4d --- /dev/null +++ b/ecr/token.go @@ -0,0 +1,72 @@ +package ecr + +import ( + "context" + "encoding/base64" + "errors" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "strings" +) + +// InvalidTokenError is returned when token is not in base64-encoded `username:password` format. +var InvalidTokenError = errors.New("unexpected token format") + +// UnexpectedResponseError is returned when ECR client returns zero or more than one types.AuthorizationData in its response. +var UnexpectedResponseError = errors.New("unexpected number of authorization data returned") + +// Interface required to obtain an ECR authentication token. +type ecrTokenClient interface { + GetAuthorizationToken(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) +} + +// getClient returns an ecrTokenClient instantiating a new AWS ECR client using AWS default configuration with options. +func getClient(context context.Context, optsFns ...func(*config.LoadOptions) error) (ecrTokenClient, error) { + if cfg, err := config.LoadDefaultConfig(context, optsFns...); err != nil { + return nil, err + } else { + return ecr.NewFromConfig(cfg), nil + } +} + +// Obtain a types.AuthorizationData from an ecrTokenClient. +func getAuthData(context context.Context, client ecrTokenClient) (*types.AuthorizationData, error) { + if token, err := client.GetAuthorizationToken(context, &ecr.GetAuthorizationTokenInput{}); err != nil { + return nil, err + } else if len(token.AuthorizationData) != 1 { + return nil, UnexpectedResponseError + } else { + return &token.AuthorizationData[0], nil + } +} + +// AuthorizationToken extends types.AuthorizationData by adding Username and Password explicitly. +type AuthorizationToken struct { + types.AuthorizationData + Username *string + Password *string +} + +// NewToken builds an AuthorizationToken from types.AuthorizationData. +func NewToken(authData *types.AuthorizationData) (*AuthorizationToken, error) { + token := *authData.AuthorizationToken + if data, err := base64.StdEncoding.DecodeString(token); err != nil { + return nil, err + } else if parts := strings.SplitN(string(data), ":", 2); len(parts) != 2 { + return nil, InvalidTokenError + } else { + return &AuthorizationToken{*authData, &parts[0], &parts[1]}, nil + } +} + +// GetToken obtains an AuthorizationToken from AWS ECR client using the passed context and options +func GetToken(context context.Context, optsFns ...func(*config.LoadOptions) error) (*AuthorizationToken, error) { + if client, err := getClient(context, optsFns...); err != nil { + return nil, err + } else if authData, err := getAuthData(context, client); err != nil { + return nil, err + } else { + return NewToken(authData) + } +} diff --git a/ecr/token_test.go b/ecr/token_test.go new file mode 100644 index 0000000..1c2c540 --- /dev/null +++ b/ecr/token_test.go @@ -0,0 +1,157 @@ +package ecr + +import ( + "context" + "encoding/base64" + "errors" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "testing" + "time" +) + +func must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} +func ind[T any](val T) *T { return &val } + +type mockECRClient func(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) + +func (m mockECRClient) GetAuthorizationToken(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { + return m(ctx, params, optFns...) +} + +func TestGetAuthData(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := types.AuthorizationData{AuthorizationToken: ind("Zm9vOmJhcg==")} + client := mockECRClient(func(context.Context, *ecr.GetAuthorizationTokenInput, ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { + return &ecr.GetAuthorizationTokenOutput{AuthorizationData: []types.AuthorizationData{expected}}, nil + }) + + authData, err := getAuthData(context.TODO(), client) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if authData == nil { + t.Fatalf("expected authorization data, got nil") + } + + if expected.AuthorizationToken != authData.AuthorizationToken { + t.Errorf("expected authorization token %v, got %v", expected.AuthorizationToken, authData.AuthorizationToken) + } + }) + + testError := errors.New("my error") + fail := map[string]struct { + client ecrTokenClient + expected error + }{ + "Error": { + mockECRClient(func(context.Context, *ecr.GetAuthorizationTokenInput, ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { + return nil, testError + }), + testError, + }, + "Invalid(EmptyAuthorizationDataArray)": { + mockECRClient(func(context.Context, *ecr.GetAuthorizationTokenInput, ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { + return &ecr.GetAuthorizationTokenOutput{AuthorizationData: []types.AuthorizationData{}}, nil + }), + UnexpectedResponseError, + }, + "Invalid(AuthorizationDataArrayTooLong)": { + mockECRClient(func(context.Context, *ecr.GetAuthorizationTokenInput, ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { + return &ecr.GetAuthorizationTokenOutput{AuthorizationData: []types.AuthorizationData{{AuthorizationToken: ind("Zm9vOmJhcg==")}, {AuthorizationToken: ind("Zm9vOmJhcjpiYXo=")}}}, nil + }), + UnexpectedResponseError, + }, + } + for name, tt := range fail { + t.Run(name, func(t *testing.T) { + client, expected := tt.client, tt.expected + authData, err := getAuthData(context.TODO(), client) + if authData != nil { + t.Fatalf("expected nil authorization data, got %v", authData) + } + if !errors.Is(err, expected) { + t.Errorf("expected error %v, got %v", expected, err) + } + }) + } +} + +func TestNewToken(t *testing.T) { + success := map[string]struct { + input *types.AuthorizationData + expected [2]string + }{ + "Success(foo:bar)": { + &types.AuthorizationData{AuthorizationToken: ind("Zm9vOmJhcg=="), ExpiresAt: ind(must(time.Parse(time.RFC3339, "2024-04-18T13:18:00Z")))}, + [2]string{"foo", "bar"}, + }, + "Success(foo:bar:baz)": { + &types.AuthorizationData{AuthorizationToken: ind("Zm9vOmJhcjpiYXo="), ExpiresAt: ind(must(time.Parse(time.RFC3339, "2024-04-18T13:18:00Z")))}, + [2]string{"foo", "bar:baz"}, + }, + } + + for name, tt := range success { + t.Run(name, func(t *testing.T) { + input, username, password := tt.input, tt.expected[0], tt.expected[1] + token, err := NewToken(input) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if token == nil { + t.Fatalf("expected token, got nil") + } + + if input.AuthorizationToken != token.AuthorizationToken { + t.Errorf("expected authorization token %v, got %v", input.AuthorizationToken, token.AuthorizationToken) + } + if input.ExpiresAt != token.ExpiresAt { + t.Errorf("expected expiration %s, got %s", input.ExpiresAt, token.ExpiresAt) + } + if input.ProxyEndpoint != token.ProxyEndpoint { + t.Errorf("expected proxy endpoint %v, got %v", input.ProxyEndpoint, token.ProxyEndpoint) + } + if input.AuthorizationToken != token.AuthorizationToken { + t.Errorf("expected authorization token %v, got %v", input.AuthorizationToken, token.AuthorizationToken) + } + if username != *token.Username { + t.Errorf("expected username %s, got %s", username, *token.Username) + } + if password != *token.Password { + t.Errorf("expected password %s, got %s", password, *token.Password) + } + }) + } + + fail := map[string]struct { + input *types.AuthorizationData + expected error + }{ + "Invalid(CorruptInput)": { + &types.AuthorizationData{AuthorizationToken: ind("__NOT_A_BASE64_STRING!"), ExpiresAt: ind(must(time.Parse(time.RFC3339, "2024-04-18T13:18:00Z")))}, + base64.CorruptInputError(0), + }, + "Invalid(`foo`)": { + &types.AuthorizationData{AuthorizationToken: ind("Zm9v"), ExpiresAt: ind(must(time.Parse(time.RFC3339, "2024-04-18T13:18:00Z")))}, + InvalidTokenError, + }, + } + for name, tt := range fail { + t.Run(name, func(t *testing.T) { + input, expected := tt.input, tt.expected + token, err := NewToken(input) + if token != nil { + t.Fatalf("expected nil token, got %v", token) + } + if !errors.Is(err, expected) { + t.Errorf("expected error %v, got %v", expected, err) + } + }) + } +} diff --git a/go.mod b/go.mod index 39d6aad..461d9a9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/chialab/aws-ecr-get-login-password -go 1.21 +go 1.22 require ( github.com/aws/aws-sdk-go-v2/config v1.27.11 diff --git a/main.go b/main.go index d903175..58ed9c1 100644 --- a/main.go +++ b/main.go @@ -2,45 +2,15 @@ package main import ( "context" - "encoding/base64" "fmt" + "github.com/chialab/aws-ecr-get-login-password/ecr" "log" - "os" - "strings" - - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/ecr" ) func main() { - if token, err := getToken(); err != nil { + if token, err := ecr.GetToken(context.Background()); err != nil { log.Fatal(err) } else { - fmt.Println(token) - } -} - -// Retrieve token for authentication against ECR registries. -func getToken() (string, error) { - os.Setenv("AWS_SDK_LOAD_CONFIG", "1") - cfg, err := config.LoadDefaultConfig(context.TODO()) - if err != nil { - return "", err - } - - svc := ecr.NewFromConfig(cfg) - token, err := svc.GetAuthorizationToken(context.TODO(), &ecr.GetAuthorizationTokenInput{}) - if err != nil { - return "", err - } - - authData := token.AuthorizationData[0].AuthorizationToken - data, err := base64.StdEncoding.DecodeString(*authData) - if err != nil { - return "", err + fmt.Println(*token.Password) } - - parts := strings.SplitN(string(data), ":", 2) - - return parts[1], nil } From 905993f6a275112f377588ce320212ff1258409e Mon Sep 17 00:00:00 2001 From: Paolo Cuffiani Date: Thu, 18 Apr 2024 14:54:31 +0200 Subject: [PATCH 2/4] use Codecov token as OIDC is very poorly documented --- .github/workflows/test.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5131f99..09dd8cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,10 +6,6 @@ on: pull_request: branches: [ main ] -permissions: - id-token: write - contents: read - jobs: lint: name: Run linter @@ -53,4 +49,4 @@ jobs: - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 with: - use_oidc: true + token: ${{ secrets.CODECOV_TOKEN }} From 0a008e06956388dfa3c2f1330ed2568cf2a53b7e Mon Sep 17 00:00:00 2001 From: Paolo Cuffiani Date: Thu, 18 Apr 2024 15:09:11 +0200 Subject: [PATCH 3/4] try to use manual build mode for CodeQL --- .github/workflows/codeql-analysis.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 52ddca6..36412cf 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,6 +25,17 @@ jobs: uses: github/codeql-action/init@v3 with: languages: go + build-mode: manual + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + run: go build -v ./... - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 + with: + category: '/language:go' From 9f216d42e3b6c07714a214f4dcba61ded93ae31a Mon Sep 17 00:00:00 2001 From: Paolo Cuffiani Date: Thu, 18 Apr 2024 15:12:18 +0200 Subject: [PATCH 4/4] setup go before initializing CodeQL --- .github/workflows/codeql-analysis.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 36412cf..435581e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -21,17 +21,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: go build-mode: manual - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Build run: go build -v ./...