Skip to content

Commit 997a3d0

Browse files
fix: support amazon-ecr-credential-helper & check for bucket existence before emptying the bucket (#2365)
### Summary #2323 - Depending on the value of `credsStore` attribute (inside docker config file), the code either performs the `docker login` or skips it. #2070 - Added code to check for the existence of the bucket using `s3.HeadBucket` API before proceed to empty the contents ### Issue number Fixes #2323 & #2070 ---- By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent 5b4fc31 commit 997a3d0

File tree

8 files changed

+283
-7
lines changed

8 files changed

+283
-7
lines changed

internal/pkg/aws/s3/mocks/mock_s3.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/pkg/aws/s3/s3.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"archive/zip"
99
"bytes"
1010
"fmt"
11+
"github.com/aws/aws-sdk-go/aws/awserr"
1112
"io"
1213
"path"
1314
"strconv"
@@ -22,6 +23,7 @@ import (
2223

2324
const (
2425
artifactDirName = "manual"
26+
notFound = "NotFound"
2527
)
2628

2729
type s3ManagerAPI interface {
@@ -31,6 +33,7 @@ type s3ManagerAPI interface {
3133
type s3API interface {
3234
ListObjectVersions(input *s3.ListObjectVersionsInput) (*s3.ListObjectVersionsOutput, error)
3335
DeleteObjects(input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error)
36+
HeadBucket (input *s3.HeadBucketInput) (*s3.HeadBucketOutput, error)
3437
}
3538

3639
// NamedBinary is a named binary to be uploaded.
@@ -105,6 +108,17 @@ func (s *S3) ZipAndUpload(bucket, key string, files ...NamedBinary) (string, err
105108
func (s *S3) EmptyBucket(bucket string) error {
106109
var listResp *s3.ListObjectVersionsOutput
107110
var err error
111+
112+
// Bucket is exists check to make sure the bucket exists before proceeding in emptying it
113+
isExists, err:= s.isBucketExists(bucket)
114+
if err!= nil {
115+
return fmt.Errorf("unable to determine the existance of bucket %s: %w", bucket, err)
116+
}
117+
118+
if !isExists {
119+
return nil
120+
}
121+
108122
listParams := &s3.ListObjectVersionsInput{
109123
Bucket: aws.String(bucket),
110124
}
@@ -161,3 +175,19 @@ func ParseURL(url string) (bucket string, key string, err error) {
161175
bucket, key = strings.Split(parsedURL[0], ".")[0], parsedURL[1]
162176
return
163177
}
178+
179+
// Check whether the bucket exists before proceeding with empty the bucket
180+
func (s *S3) isBucketExists(bucket string) (bool, error) {
181+
input := &s3.HeadBucketInput{
182+
Bucket: aws.String(bucket),
183+
}
184+
_, err := s.s3Client.HeadBucket(input)
185+
if err != nil {
186+
if aerr, ok := err.(awserr.Error); ok && aerr.Code() == notFound {
187+
return false, nil
188+
}
189+
return false, err
190+
}
191+
192+
return true, nil
193+
}

internal/pkg/aws/s3/s3_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"bytes"
99
"errors"
1010
"fmt"
11+
"github.com/aws/aws-sdk-go/aws/awserr"
1112
"io"
1213
"io/ioutil"
1314
"strconv"
@@ -216,6 +217,9 @@ func TestS3_EmptyBucket(t *testing.T) {
216217
"should delete all objects within the bucket": {
217218
inBucket: "mockBucket",
218219
mockS3Client: func(m *mocks.Mocks3API) {
220+
m.EXPECT().HeadBucket(&s3.HeadBucketInput{
221+
Bucket: aws.String("mockBucket"),
222+
}).Return(nil, nil)
219223
m.EXPECT().ListObjectVersions(&s3.ListObjectVersionsInput{
220224
Bucket: aws.String("mockBucket"),
221225
}).Return(&s3.ListObjectVersionsOutput{
@@ -235,6 +239,9 @@ func TestS3_EmptyBucket(t *testing.T) {
235239
"should batch delete all objects within the bucket": {
236240
inBucket: "mockBucket",
237241
mockS3Client: func(m *mocks.Mocks3API) {
242+
m.EXPECT().HeadBucket(&s3.HeadBucketInput{
243+
Bucket: aws.String("mockBucket"),
244+
}).Return(nil, nil)
238245
m.EXPECT().ListObjectVersions(&s3.ListObjectVersionsInput{
239246
Bucket: aws.String("mockBucket"),
240247
}).Return(&s3.ListObjectVersionsOutput{
@@ -266,6 +273,9 @@ func TestS3_EmptyBucket(t *testing.T) {
266273
"should delete all objects within the bucket including delete markers": {
267274
inBucket: "mockBucket",
268275
mockS3Client: func(m *mocks.Mocks3API) {
276+
m.EXPECT().HeadBucket(&s3.HeadBucketInput{
277+
Bucket: aws.String("mockBucket"),
278+
}).Return(nil, nil)
269279
m.EXPECT().ListObjectVersions(&s3.ListObjectVersionsInput{
270280
Bucket: aws.String("mockBucket"),
271281
}).Return(&s3.ListObjectVersionsOutput{
@@ -286,6 +296,9 @@ func TestS3_EmptyBucket(t *testing.T) {
286296
"should wrap up error if fail to list objects": {
287297
inBucket: "mockBucket",
288298
mockS3Client: func(m *mocks.Mocks3API) {
299+
m.EXPECT().HeadBucket(&s3.HeadBucketInput{
300+
Bucket: aws.String("mockBucket"),
301+
}).Return(nil, nil)
289302
m.EXPECT().ListObjectVersions(&s3.ListObjectVersionsInput{
290303
Bucket: aws.String("mockBucket"),
291304
}).Return(nil, errors.New("some error"))
@@ -296,6 +309,9 @@ func TestS3_EmptyBucket(t *testing.T) {
296309
"should not invoke DeleteObjects if bucket is empty": {
297310
inBucket: "mockBucket",
298311
mockS3Client: func(m *mocks.Mocks3API) {
312+
m.EXPECT().HeadBucket(&s3.HeadBucketInput{
313+
Bucket: aws.String("mockBucket"),
314+
}).Return(nil, nil)
299315
m.EXPECT().ListObjectVersions(gomock.Any()).Return(&s3.ListObjectVersionsOutput{
300316
IsTruncated: aws.Bool(false),
301317
}, nil)
@@ -306,6 +322,9 @@ func TestS3_EmptyBucket(t *testing.T) {
306322
"should wrap up error if fail to delete objects": {
307323
inBucket: "mockBucket",
308324
mockS3Client: func(m *mocks.Mocks3API) {
325+
m.EXPECT().HeadBucket(&s3.HeadBucketInput{
326+
Bucket: aws.String("mockBucket"),
327+
}).Return(nil, nil)
309328
m.EXPECT().ListObjectVersions(&s3.ListObjectVersionsInput{
310329
Bucket: aws.String("mockBucket"),
311330
}).Return(&s3.ListObjectVersionsOutput{
@@ -322,6 +341,27 @@ func TestS3_EmptyBucket(t *testing.T) {
322341

323342
wantErr: fmt.Errorf("delete objects from bucket mockBucket: some error"),
324343
},
344+
"should not proceed with deletion as the bucket doesnt exists":{
345+
inBucket: "mockBucket",
346+
mockS3Client: func(m *mocks.Mocks3API) {
347+
m.EXPECT().HeadBucket(&s3.HeadBucketInput{
348+
Bucket: aws.String("mockBucket"),
349+
}).Return(nil, awserr.New(notFound, "message", nil))
350+
},
351+
352+
wantErr: nil,
353+
},
354+
"should throw error while perform bucket exists check":{
355+
inBucket: "mockBucket",
356+
mockS3Client: func(m *mocks.Mocks3API) {
357+
m.EXPECT().HeadBucket(&s3.HeadBucketInput{
358+
Bucket: aws.String("mockBucket"),
359+
}).Return(nil, awserr.New("Unknown", "message", nil))
360+
},
361+
362+
wantErr: fmt.Errorf("unable to determine the existance of bucket %s: %w", "mockBucket",
363+
awserr.New("Unknown", "message", nil)),
364+
},
325365
}
326366

327367
for name, tc := range testCases {

internal/pkg/exec/docker.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"bytes"
99
"encoding/json"
1010
"fmt"
11+
"io/ioutil"
12+
"os"
1113
"os/exec"
1214
"path/filepath"
1315
"sort"
@@ -19,12 +21,14 @@ type DockerCommand struct {
1921
runner
2022
// Override in unit tests.
2123
buf *bytes.Buffer
24+
homePath string
2225
}
2326

2427
// NewDockerCommand returns a DockerCommand.
2528
func NewDockerCommand() DockerCommand {
2629
return DockerCommand{
2730
runner: NewCmd(),
31+
homePath: userHomeDirectory(),
2832
}
2933
}
3034

@@ -39,6 +43,15 @@ type BuildArguments struct {
3943
Args map[string]string // Optional. Build args to pass via `--build-arg` flags. Equivalent to ARG directives in dockerfile.
4044
}
4145

46+
type dockerConfig struct {
47+
CredsStore string `json:"credsStore,omitempty"`
48+
CredHelpers map[string]string `json:"credHelpers,omitempty"`
49+
}
50+
51+
const(
52+
credStoreECRLogin = "ecr-login" // set on `credStore` attribute in docker configuration file
53+
)
54+
4255
// Build will run a `docker build` command for the given ecr repo URI and build arguments.
4356
func (c DockerCommand) Build(in *BuildArguments) error {
4457
dfDir := in.Context
@@ -159,3 +172,61 @@ func imageName(uri, tag string) string {
159172
}
160173
return fmt.Sprintf("%s:%s", uri, tag)
161174
}
175+
176+
// IsEcrCredentialHelperEnabled return true if ecr-login is enabled either globally or registry level
177+
func (c DockerCommand) IsEcrCredentialHelperEnabled(uri string) bool {
178+
// Make sure the program is able to obtain the home directory
179+
splits := strings.Split(uri, "/")
180+
if c.homePath == "" || len(splits) == 0 {
181+
return false
182+
}
183+
184+
// Look into the default locations
185+
pathsToTry := []string{filepath.Join(".docker", "config.json"), ".dockercfg"}
186+
for _, path := range pathsToTry {
187+
content, err := ioutil.ReadFile(filepath.Join(c.homePath, path))
188+
if err != nil {
189+
// if we can't read the file keep going
190+
continue
191+
}
192+
193+
config, err := parseCredFromDockerConfig(content)
194+
if err != nil {
195+
continue
196+
}
197+
198+
if config.CredsStore == credStoreECRLogin || config.CredHelpers[splits[0]] == credStoreECRLogin {
199+
return true
200+
}
201+
}
202+
203+
return false
204+
}
205+
206+
func parseCredFromDockerConfig(config []byte) (*dockerConfig, error) {
207+
/*
208+
Sample docker config file
209+
{
210+
"credsStore" : "ecr-login",
211+
"credHelpers": {
212+
"dummyaccountId.dkr.ecr.region.amazonaws.com": "ecr-login"
213+
}
214+
}
215+
*/
216+
cred := dockerConfig{}
217+
err := json.Unmarshal(config, &cred)
218+
if err != nil {
219+
return nil, err
220+
}
221+
222+
return &cred, nil
223+
}
224+
225+
func userHomeDirectory() string {
226+
home, err := os.UserHomeDir()
227+
if err!= nil{
228+
return ""
229+
}
230+
231+
return home
232+
}

internal/pkg/exec/docker_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"bytes"
88
"errors"
99
"fmt"
10+
"github.com/spf13/afero"
1011
"os/exec"
12+
"path/filepath"
1113
"testing"
1214

1315
"github.com/golang/mock/gomock"
@@ -350,3 +352,91 @@ func TestDockerCommand_CheckDockerEngineRunning(t *testing.T) {
350352
})
351353
}
352354
}
355+
356+
func TestIsEcrCredentialHelperEnabled(t *testing.T){
357+
var mockRunner *Mockrunner
358+
workspace := "test/copilot/.docker"
359+
registry := "dummyaccountid.dkr.ecr.region.amazonaws.com"
360+
uri := fmt.Sprintf("%s/ui/app", registry)
361+
362+
testCases := map[string]struct {
363+
setupMocks func(controller *gomock.Controller)
364+
inBuffer *bytes.Buffer
365+
mockFileSystem func(fs afero.Fs)
366+
postExec func(fs afero.Fs)
367+
isEcrRepo bool
368+
}{
369+
"ecr-login check global level": {
370+
mockFileSystem: func(fs afero.Fs) {
371+
fs.MkdirAll(workspace, 0755)
372+
afero.WriteFile(fs, filepath.Join(workspace, "config.json"), []byte(fmt.Sprintf("{\"credsStore\":\"%s\"}", credStoreECRLogin)), 0644)
373+
},
374+
setupMocks: func(c *gomock.Controller) {
375+
mockRunner = NewMockrunner(c)
376+
},
377+
postExec: func(fs afero.Fs){
378+
fs.RemoveAll(workspace)
379+
},
380+
isEcrRepo: true,
381+
},
382+
"ecr-login check registry level": {
383+
mockFileSystem: func(fs afero.Fs) {
384+
fs.MkdirAll(workspace, 0755)
385+
afero.WriteFile(fs, filepath.Join(workspace, "config.json"), []byte(fmt.Sprintf("{\"credhelpers\":{\"%s\": \"%s\"}}", registry, credStoreECRLogin)), 0644)
386+
},
387+
setupMocks: func(c *gomock.Controller) {
388+
mockRunner = NewMockrunner(c)
389+
},
390+
postExec: func(fs afero.Fs){
391+
fs.RemoveAll(workspace)
392+
},
393+
isEcrRepo: true,
394+
},
395+
"default login check registry level": {
396+
mockFileSystem: func(fs afero.Fs) {
397+
fs.MkdirAll(workspace, 0755)
398+
afero.WriteFile(fs, filepath.Join(workspace, "config.json"), []byte(fmt.Sprintf("{\"credhelpers\":{\"%s\": \"%s\"}}", registry, "desktop")), 0644)
399+
},
400+
setupMocks: func(c *gomock.Controller) {
401+
mockRunner = NewMockrunner(c)
402+
},
403+
postExec: func(fs afero.Fs){
404+
fs.RemoveAll(workspace)
405+
},
406+
isEcrRepo: false,
407+
},
408+
"no file check": {
409+
mockFileSystem: func(fs afero.Fs) {
410+
fs.MkdirAll(workspace, 0755)
411+
},
412+
setupMocks: func(c *gomock.Controller) {
413+
mockRunner = NewMockrunner(c)
414+
},
415+
postExec: func(fs afero.Fs){
416+
fs.RemoveAll(workspace)
417+
},
418+
isEcrRepo: false,
419+
},
420+
}
421+
422+
for name, tc := range testCases {
423+
t.Run(name, func(t *testing.T) {
424+
// Create an empty FileSystem
425+
fs := afero.NewOsFs()
426+
427+
controller := gomock.NewController(t)
428+
tc.setupMocks(controller)
429+
tc.mockFileSystem(fs)
430+
s := DockerCommand{
431+
runner: mockRunner,
432+
buf: tc.inBuffer,
433+
homePath: "test/copilot",
434+
}
435+
436+
credStore := s.IsEcrCredentialHelperEnabled(uri)
437+
tc.postExec(fs)
438+
439+
require.Equal(t, tc.isEcrRepo, credStore)
440+
})
441+
}
442+
}

0 commit comments

Comments
 (0)