diff --git a/go.mod b/go.mod index c2948b29360..22ef695c42e 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/imdario/mergo v0.3.16 github.com/lnquy/cron v1.1.1 github.com/moby/buildkit v0.11.6 + github.com/moby/patternmatcher v0.6.0 github.com/onsi/ginkgo/v2 v2.13.2 github.com/onsi/gomega v1.30.0 github.com/robfig/cron/v3 v3.0.1 diff --git a/go.sum b/go.sum index d77af5daef2..5df9a2ecf12 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/moby/buildkit v0.11.6 h1:VYNdoKk5TVxN7k4RvZgdeM4GOyRvIi4Z8MXOY7xvyUs= github.com/moby/buildkit v0.11.6/go.mod h1:GCqKfHhz+pddzfgaR7WmHVEE3nKKZMMDPpK8mh3ZLv4= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs= github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM= github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= diff --git a/internal/pkg/cli/flag.go b/internal/pkg/cli/flag.go index 619d20f52ec..8b86af31949 100644 --- a/internal/pkg/cli/flag.go +++ b/internal/pkg/cli/flag.go @@ -326,7 +326,7 @@ Format: [container]:KEY=VALUE. Omit container name to apply to all containers.` Example: --port-override 5000:80 binds localhost:5000 to the service's port 80.` proxyFlagDescription = `Optional. Proxy outbound requests to your environment's VPC.` proxyNetworkFlagDescription = `Optional. Set the IP Network used by --proxy.` - watchFlagDescription = `Optional. Watch changes to local files and restart containers when updated.` + watchFlagDescription = `Optional. Watch changes to local files and restart containers when updated. Directories and files in the main .dockerignore file are ignored.` useTaskRoleFlagDescription = "Optional. Run containers with TaskRole credentials instead of session credentials." svcManifestFlagDescription = `Optional. Name of the environment in which the service was deployed; diff --git a/internal/pkg/cli/interfaces.go b/internal/pkg/cli/interfaces.go index 1bada558c00..d0086bb3ab6 100644 --- a/internal/pkg/cli/interfaces.go +++ b/internal/pkg/cli/interfaces.go @@ -755,3 +755,7 @@ type stackConfiguration interface { type secretGetter interface { GetSecretValue(context.Context, string) (string, error) } + +type dockerWorkload interface { + Dockerfile() string +} diff --git a/internal/pkg/cli/mocks/mock_interfaces.go b/internal/pkg/cli/mocks/mock_interfaces.go index decab61a443..6b926c4a59c 100644 --- a/internal/pkg/cli/mocks/mock_interfaces.go +++ b/internal/pkg/cli/mocks/mock_interfaces.go @@ -8245,3 +8245,40 @@ func (mr *MocksecretGetterMockRecorder) GetSecretValue(arg0, arg1 interface{}) * mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSecretValue", reflect.TypeOf((*MocksecretGetter)(nil).GetSecretValue), arg0, arg1) } + +// MockdockerWorkload is a mock of dockerWorkload interface. +type MockdockerWorkload struct { + ctrl *gomock.Controller + recorder *MockdockerWorkloadMockRecorder +} + +// MockdockerWorkloadMockRecorder is the mock recorder for MockdockerWorkload. +type MockdockerWorkloadMockRecorder struct { + mock *MockdockerWorkload +} + +// NewMockdockerWorkload creates a new mock instance. +func NewMockdockerWorkload(ctrl *gomock.Controller) *MockdockerWorkload { + mock := &MockdockerWorkload{ctrl: ctrl} + mock.recorder = &MockdockerWorkloadMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockdockerWorkload) EXPECT() *MockdockerWorkloadMockRecorder { + return m.recorder +} + +// Dockerfile mocks base method. +func (m *MockdockerWorkload) Dockerfile() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Dockerfile") + ret0, _ := ret[0].(string) + return ret0 +} + +// Dockerfile indicates an expected call of Dockerfile. +func (mr *MockdockerWorkloadMockRecorder) Dockerfile() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dockerfile", reflect.TypeOf((*MockdockerWorkload)(nil).Dockerfile)) +} diff --git a/internal/pkg/cli/run_local.go b/internal/pkg/cli/run_local.go index a4843631963..509a0a7d490 100644 --- a/internal/pkg/cli/run_local.go +++ b/internal/pkg/cli/run_local.go @@ -45,6 +45,7 @@ import ( "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation" "github.com/aws/copilot-cli/internal/pkg/describe" "github.com/aws/copilot-cli/internal/pkg/docker/dockerengine" + "github.com/aws/copilot-cli/internal/pkg/docker/dockerfile" "github.com/aws/copilot-cli/internal/pkg/docker/orchestrator" "github.com/aws/copilot-cli/internal/pkg/ecs" "github.com/aws/copilot-cli/internal/pkg/exec" @@ -116,28 +117,29 @@ type runLocalVars struct { type runLocalOpts struct { runLocalVars - sel deploySelector - ecsClient ecsClient - ecsExecutor ecsCommandExecutor - ssm secretGetter - secretsManager secretGetter - sessProvider sessionProvider - sess *session.Session - envManagerSess *session.Session - targetEnv *config.Environment - targetApp *config.Application - store store - ws wsWlDirReader - cmd execRunner - dockerEngine dockerEngineRunner - repository repositoryService - prog progress - orchestrator containerOrchestrator - hostFinder hostFinder - envChecker versionCompatibilityChecker - debounceTime time.Duration - newRecursiveWatcher func() (recursiveWatcher, error) - + sel deploySelector + ecsClient ecsClient + ecsExecutor ecsCommandExecutor + ssm secretGetter + secretsManager secretGetter + sessProvider sessionProvider + sess *session.Session + envManagerSess *session.Session + targetEnv *config.Environment + targetApp *config.Application + store store + ws wsWlDirReader + cmd execRunner + dockerEngine dockerEngineRunner + repository repositoryService + prog progress + orchestrator containerOrchestrator + hostFinder hostFinder + envChecker versionCompatibilityChecker + debounceTime time.Duration + dockerExcludes []string + + newRecursiveWatcher func() (recursiveWatcher, error) buildContainerImages func(mft manifest.DynamicWorkload) (map[string]string, error) configureClients func() error labeledTermPrinter func(fw syncbuffer.FileWriter, bufs []*syncbuffer.LabeledSyncBuffer, opts ...syncbuffer.LabeledTermPrinterOption) clideploy.LabeledTermPrinter @@ -237,6 +239,15 @@ func newRunLocalOpts(vars runLocalVars) (*runLocalOpts, error) { return nil } o.buildContainerImages = func(mft manifest.DynamicWorkload) (map[string]string, error) { + if dockerWkld, ok := mft.Manifest().(dockerWorkload); ok { + dfDir := filepath.Dir(dockerWkld.Dockerfile()) + o.dockerExcludes, err = dockerfile.ReadDockerignore(afero.NewOsFs(), filepath.Join(ws.Path(), dfDir)) + if err != nil { + return nil, err + } + o.filterDockerExcludes() + } + gitShortCommit := imageTagFromGit(o.cmd) image := clideploy.ContainerImageIdentifier{ GitShortCommitTag: gitShortCommit, @@ -596,6 +607,21 @@ func (o *runLocalOpts) prepareTask(ctx context.Context) (orchestrator.Task, erro return task, nil } +func (o *runLocalOpts) filterDockerExcludes() { + wsPath := o.ws.Path() + result := []string{} + + // filter out excludes to the copilot directory, we always want to watch these files + copilotDirPath := filepath.ToSlash(filepath.Join(wsPath, workspace.CopilotDirName)) + for _, exclude := range o.dockerExcludes { + if !strings.HasPrefix(filepath.ToSlash(exclude), copilotDirPath) { + result = append(result, exclude) + } + } + + o.dockerExcludes = result +} + func (o *runLocalOpts) watchLocalFiles(stopCh <-chan struct{}) (<-chan interface{}, <-chan error, error) { workspacePath := o.ws.Path() @@ -615,6 +641,7 @@ func (o *runLocalOpts) watchLocalFiles(stopCh <-chan struct{}) (<-chan interface watcherErrors := watcher.Errors() debounceTimer := time.NewTimer(o.debounceTime) + debounceTimerRunning := false if !debounceTimer.Stop() { // flush the timer in case stop is called after the timer finishes <-debounceTimer.C @@ -643,11 +670,12 @@ func (o *runLocalOpts) watchLocalFiles(stopCh <-chan struct{}) (<-chan interface break } - // check if any subdirectories within copilot directory are hidden - isHidden := false parent := workspacePath suffix, _ := strings.CutPrefix(event.Name, parent+"/") + + // check if any subdirectories within copilot directory are hidden // fsnotify events are always of form /a/b/c, don't use filepath.Split as that's OS dependent + isHidden := false for _, child := range strings.Split(suffix, "/") { parent = filepath.Join(parent, child) subdirHidden, err := file.IsHiddenFile(child) @@ -659,11 +687,27 @@ func (o *runLocalOpts) watchLocalFiles(stopCh <-chan struct{}) (<-chan interface } } - // TODO(Aiden): implement dockerignore blacklist for update - if !isHidden { + // skip updates from files matching .dockerignore patterns + isExcluded := false + for _, pattern := range o.dockerExcludes { + matches, err := filepath.Match(pattern, suffix) + if err != nil { + break + } + if matches { + isExcluded = true + } + } + + if !isHidden && !isExcluded { + if !debounceTimerRunning { + fmt.Println("Restarting task...") + debounceTimerRunning = true + } debounceTimer.Reset(o.debounceTime) } case <-debounceTimer.C: + debounceTimerRunning = false watchCh <- nil } } diff --git a/internal/pkg/cli/run_local_test.go b/internal/pkg/cli/run_local_test.go index fba711e60ce..49f8b41b3f3 100644 --- a/internal/pkg/cli/run_local_test.go +++ b/internal/pkg/cli/run_local_test.go @@ -483,16 +483,17 @@ func TestRunLocalOpts_Execute(t *testing.T) { } testCases := map[string]struct { - inputAppName string - inputEnvName string - inputWkldName string - inputEnvOverrides map[string]string - inputPortOverrides []string - inputWatch bool - inputTaskRole bool - inputProxy bool - inputReader io.Reader - buildImagesError error + inputAppName string + inputEnvName string + inputWkldName string + inputEnvOverrides map[string]string + inputPortOverrides []string + inputDockerExcludes []string + inputWatch bool + inputTaskRole bool + inputProxy bool + inputReader io.Reader + buildImagesError error setupMocks func(t *testing.T, m *runLocalExecuteMocks) wantedWkldName string @@ -948,11 +949,12 @@ ecs exec: all containers failed to retrieve credentials`), } }, }, - "watch flag receives hidden file update, doesn't restart": { - inputAppName: testAppName, - inputWkldName: testWkldName, - inputEnvName: testEnvName, - inputWatch: true, + "watch flag receives hidden file and ignored file update, doesn't restart": { + inputAppName: testAppName, + inputWkldName: testWkldName, + inputEnvName: testEnvName, + inputWatch: true, + inputDockerExcludes: []string{"ignoredDir/*"}, setupMocks: func(t *testing.T, m *runLocalExecuteMocks) { m.ecsClient.EXPECT().TaskDefinition(testAppName, testEnvName, testWkldName).Return(taskDef, nil) m.ssm.EXPECT().GetSecretValue(gomock.Any(), "mysecret").Return("secretvalue", nil) @@ -960,21 +962,25 @@ ecs exec: all containers failed to retrieve credentials`), m.interpolator.EXPECT().Interpolate("").Return("", nil) m.ws.EXPECT().Path().Return("") - eventCh := make(chan fsnotify.Event, 1) + eventCh := make(chan fsnotify.Event, 2) m.watcher.EventsFn = func() <-chan fsnotify.Event { eventCh <- fsnotify.Event{ Name: ".hiddensubdir/mockFilename", Op: fsnotify.Write, } + eventCh <- fsnotify.Event{ + Name: "ignoredDir/mockFilename", + Op: fsnotify.Write, + } return eventCh } - watcherErrCh := make(chan error, 1) + watcherErrCh := make(chan error, 2) m.watcher.ErrorsFn = func() <-chan error { return watcherErrCh } - errCh := make(chan error, 1) + errCh := make(chan error, 2) m.orchestrator.StartFn = func() <-chan error { return errCh } @@ -1192,16 +1198,17 @@ ecs exec: all containers failed to retrieve credentials`), Credentials: credentials.NewStaticCredentials("myEnvID", "myEnvSecret", "myEnvToken"), }, }, - cmd: m.mockRunner, - dockerEngine: m.dockerEngine, - repository: m.repository, - targetEnv: &mockEnv, - targetApp: &mockApp, - prog: m.prog, - orchestrator: m.orchestrator, - hostFinder: m.hostFinder, - envChecker: m.envChecker, - debounceTime: 0, // disable debounce during testing + cmd: m.mockRunner, + dockerEngine: m.dockerEngine, + repository: m.repository, + targetEnv: &mockEnv, + targetApp: &mockApp, + prog: m.prog, + orchestrator: m.orchestrator, + hostFinder: m.hostFinder, + envChecker: m.envChecker, + debounceTime: 0, // disable debounce during testing + dockerExcludes: tc.inputDockerExcludes, newRecursiveWatcher: func() (recursiveWatcher, error) { return m.watcher, nil }, @@ -1971,3 +1978,45 @@ func TestRunLocal_HostDiscovery(t *testing.T) { }) } } + +type runLocalFilterDockerExcludesMocks struct { + ws *mocks.MockwsWlDirReader +} + +func TestRunLocal_FilterDockerExcludes(t *testing.T) { + tests := map[string]struct { + setupMocks func(t *testing.T, m *runLocalFilterDockerExcludesMocks) + + inputDockerExcludes []string + wantedDockerExcludes []string + }{ + "filter out all copilot directories": { + setupMocks: func(t *testing.T, m *runLocalFilterDockerExcludesMocks) { + m.ws.EXPECT().Path().Return("/ws") + }, + inputDockerExcludes: []string{"/ws/copilot/*", "/ws/ignoredfile.go", "/ws/copilot/environments/*"}, + wantedDockerExcludes: []string{"/ws/ignoredfile.go"}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // GIVEN + ctrl := gomock.NewController(t) + defer ctrl.Finish() + m := &runLocalFilterDockerExcludesMocks{ + ws: mocks.NewMockwsWlDirReader(ctrl), + } + tc.setupMocks(t, m) + opts := runLocalOpts{ + dockerExcludes: tc.inputDockerExcludes, + ws: m.ws, + } + + // WHEN + opts.filterDockerExcludes() + + // THEN + require.Equal(t, opts.dockerExcludes, tc.wantedDockerExcludes) + }) + } +} diff --git a/internal/pkg/docker/dockerfile/ignorefile.go b/internal/pkg/docker/dockerfile/ignorefile.go new file mode 100644 index 00000000000..7500cd2e9c2 --- /dev/null +++ b/internal/pkg/docker/dockerfile/ignorefile.go @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package dockerfile + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/moby/patternmatcher/ignorefile" + "github.com/spf13/afero" +) + +// ReadDockerignore reads the .dockerignore file in the context directory and +// returns the list of paths to exclude. +func ReadDockerignore(fs afero.Fs, contextDir string) ([]string, error) { + f, err := fs.Open(filepath.Join(contextDir, ".dockerignore")) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + + patterns, err := ignorefile.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("error reading .dockerignore at %q: %w", contextDir, err) + } + + return patterns, nil +} diff --git a/internal/pkg/docker/dockerfile/ignorefile_test.go b/internal/pkg/docker/dockerfile/ignorefile_test.go new file mode 100644 index 00000000000..a061806c760 --- /dev/null +++ b/internal/pkg/docker/dockerfile/ignorefile_test.go @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package dockerfile + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func TestDockerignoreFile(t *testing.T) { + var ( + defaultPath = "./" + ) + + testCases := map[string]struct { + dockerignoreFilePath string + dockerignoreFile []byte + wantedExcludes []string + wantedErr error + }{ + "reads file that doesn't exist": { + dockerignoreFilePath: "./falsepath", + wantedExcludes: nil, + }, + "dockerignore file is empty": { + dockerignoreFilePath: defaultPath, + wantedExcludes: nil, + }, + "parse dockerignore file": { + dockerignoreFilePath: defaultPath, + dockerignoreFile: []byte(` +copilot/* +copilot/*/* +# commenteddir/* +/copilot/* + testdir/ + `), + wantedExcludes: []string{ + "copilot/*", + "copilot/*/*", + "copilot/*", + "testdir", + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + err := fs.WriteFile("./.dockerignore", tc.dockerignoreFile, 0644) + if err != nil { + t.FailNow() + } + + actualExcludes, err := ReadDockerignore(fs.Fs, tc.dockerignoreFilePath) + if tc.wantedErr != nil { + require.EqualError(t, err, tc.wantedErr.Error()) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantedExcludes, actualExcludes) + } + }) + } +} diff --git a/internal/pkg/manifest/backend_svc.go b/internal/pkg/manifest/backend_svc.go index d35bc1bf104..02a34102017 100644 --- a/internal/pkg/manifest/backend_svc.go +++ b/internal/pkg/manifest/backend_svc.go @@ -103,6 +103,11 @@ func (s *BackendService) requiredEnvironmentFeatures() []string { return features } +// Dockerfile returns the relative path of the Dockerfile in the manifest. +func (s *BackendService) Dockerfile() string { + return s.ImageConfig.Image.dockerfilePath() +} + // Port returns the exposed port in the manifest. // If the backend service is not meant to be reachable, then ok is set to false. func (s *BackendService) Port() (port uint16, ok bool) { diff --git a/internal/pkg/manifest/backend_svc_test.go b/internal/pkg/manifest/backend_svc_test.go index c839802028e..b4f391c567a 100644 --- a/internal/pkg/manifest/backend_svc_test.go +++ b/internal/pkg/manifest/backend_svc_test.go @@ -1208,3 +1208,82 @@ func TestBackendService_ExposedPorts(t *testing.T) { }) } } + +func TestBackendService_Dockerfile(t *testing.T) { + testCases := map[string]struct { + input *BackendService + expectedDockerfilePath string + }{ + "specific dockerfile from buildargs": { + input: &BackendService{ + BackendServiceConfig: BackendServiceConfig{ + ImageConfig: ImageWithHealthcheckAndOptionalPort{ + ImageWithOptionalPort: ImageWithOptionalPort{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Dockerfile: aws.String("path/to/Dockerfile"), + }, + BuildString: aws.String("other/path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "specific dockerfile from buildstring": { + input: &BackendService{ + BackendServiceConfig: BackendServiceConfig{ + ImageConfig: ImageWithHealthcheckAndOptionalPort{ + ImageWithOptionalPort: ImageWithOptionalPort{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildString: aws.String("path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "dockerfile from context": { + input: &BackendService{ + BackendServiceConfig: BackendServiceConfig{ + ImageConfig: ImageWithHealthcheckAndOptionalPort{ + ImageWithOptionalPort: ImageWithOptionalPort{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Context: aws.String("path/to"), + }, + }, + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + } + + for name, tc := range testCases { + svc := tc.input + + t.Run(name, func(t *testing.T) { + // WHEN + dockerfilePath := svc.Dockerfile() + + // THEN + require.Equal(t, tc.expectedDockerfilePath, dockerfilePath) + }) + } +} diff --git a/internal/pkg/manifest/job.go b/internal/pkg/manifest/job.go index fdd1b523dd9..dae34719822 100644 --- a/internal/pkg/manifest/job.go +++ b/internal/pkg/manifest/job.go @@ -133,6 +133,11 @@ func (s *ScheduledJob) requiredEnvironmentFeatures() []string { return features } +// Dockerfile returns the relative path of the Dockerfile in the manifest. +func (j *ScheduledJob) Dockerfile() string { + return j.ImageConfig.Image.dockerfilePath() +} + // Publish returns the list of topics where notifications can be published. func (j *ScheduledJob) Publish() []Topic { return j.ScheduledJobConfig.PublishConfig.publishedTopics() diff --git a/internal/pkg/manifest/job_test.go b/internal/pkg/manifest/job_test.go index 08b91a99bf3..c75aa78544f 100644 --- a/internal/pkg/manifest/job_test.go +++ b/internal/pkg/manifest/job_test.go @@ -448,3 +448,76 @@ func TestScheduledJob_Publish(t *testing.T) { }) } } + +func TestScheduledJob_Dockerfile(t *testing.T) { + testCases := map[string]struct { + input *ScheduledJob + expectedDockerfilePath string + }{ + "specific dockerfile from buildargs": { + input: &ScheduledJob{ + ScheduledJobConfig: ScheduledJobConfig{ + ImageConfig: ImageWithHealthcheck{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Dockerfile: aws.String("path/to/Dockerfile"), + }, + BuildString: aws.String("other/path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "specific dockerfile from buildstring": { + input: &ScheduledJob{ + ScheduledJobConfig: ScheduledJobConfig{ + ImageConfig: ImageWithHealthcheck{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildString: aws.String("path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "dockerfile from context": { + input: &ScheduledJob{ + ScheduledJobConfig: ScheduledJobConfig{ + ImageConfig: ImageWithHealthcheck{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Context: aws.String("path/to"), + }, + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + } + + for name, tc := range testCases { + svc := tc.input + + t.Run(name, func(t *testing.T) { + // WHEN + dockerfilePath := svc.Dockerfile() + + // THEN + require.Equal(t, tc.expectedDockerfilePath, dockerfilePath) + }) + } +} diff --git a/internal/pkg/manifest/lb_web_svc.go b/internal/pkg/manifest/lb_web_svc.go index 1786c1c155d..60d1fd38e85 100644 --- a/internal/pkg/manifest/lb_web_svc.go +++ b/internal/pkg/manifest/lb_web_svc.go @@ -182,6 +182,11 @@ func (s *LoadBalancedWebService) requiredEnvironmentFeatures() []string { return features } +// Dockerfile returns the relative path of the Dockerfile in the manifest. +func (s *LoadBalancedWebService) Dockerfile() string { + return s.ImageConfig.Image.dockerfilePath() +} + // Port returns the exposed port in the manifest. // A LoadBalancedWebService always has a port exposed therefore the boolean is always true. func (s *LoadBalancedWebService) Port() (port uint16, ok bool) { diff --git a/internal/pkg/manifest/lb_web_svc_test.go b/internal/pkg/manifest/lb_web_svc_test.go index a7d6c2e849d..5d0615460de 100644 --- a/internal/pkg/manifest/lb_web_svc_test.go +++ b/internal/pkg/manifest/lb_web_svc_test.go @@ -2969,3 +2969,82 @@ func TestNetworkLoadBalancerConfiguration_NLBListeners(t *testing.T) { }) } } + +func TestLoadBalancedWebService_Dockerfile(t *testing.T) { + testCases := map[string]struct { + input *LoadBalancedWebService + expectedDockerfilePath string + }{ + "specific dockerfile from buildargs": { + input: &LoadBalancedWebService{ + LoadBalancedWebServiceConfig: LoadBalancedWebServiceConfig{ + ImageConfig: ImageWithPortAndHealthcheck{ + ImageWithPort: ImageWithPort{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Dockerfile: aws.String("path/to/Dockerfile"), + }, + BuildString: aws.String("other/path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "specific dockerfile from buildstring": { + input: &LoadBalancedWebService{ + LoadBalancedWebServiceConfig: LoadBalancedWebServiceConfig{ + ImageConfig: ImageWithPortAndHealthcheck{ + ImageWithPort: ImageWithPort{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildString: aws.String("path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "dockerfile from context": { + input: &LoadBalancedWebService{ + LoadBalancedWebServiceConfig: LoadBalancedWebServiceConfig{ + ImageConfig: ImageWithPortAndHealthcheck{ + ImageWithPort: ImageWithPort{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Context: aws.String("path/to"), + }, + }, + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + } + + for name, tc := range testCases { + svc := tc.input + + t.Run(name, func(t *testing.T) { + // WHEN + dockerfilePath := svc.Dockerfile() + + // THEN + require.Equal(t, tc.expectedDockerfilePath, dockerfilePath) + }) + } +} diff --git a/internal/pkg/manifest/rd_web_svc.go b/internal/pkg/manifest/rd_web_svc.go index d8b80be7253..48f702e6978 100644 --- a/internal/pkg/manifest/rd_web_svc.go +++ b/internal/pkg/manifest/rd_web_svc.go @@ -135,6 +135,11 @@ func (s *RequestDrivenWebService) MarshalBinary() ([]byte, error) { return content.Bytes(), nil } +// Dockerfile returns the relative path of the Dockerfile in the manifest. +func (s *RequestDrivenWebService) Dockerfile() string { + return s.ImageConfig.Image.dockerfilePath() +} + // Port returns the exposed the exposed port in the manifest. // A RequestDrivenWebService always has a port exposed therefore the boolean is always true. func (s *RequestDrivenWebService) Port() (port uint16, ok bool) { diff --git a/internal/pkg/manifest/rd_web_svc_test.go b/internal/pkg/manifest/rd_web_svc_test.go index ac2fd947827..024c70c93b6 100644 --- a/internal/pkg/manifest/rd_web_svc_test.go +++ b/internal/pkg/manifest/rd_web_svc_test.go @@ -665,3 +665,76 @@ func TestRequestDrivenWebService_RequiredEnvironmentFeatures(t *testing.T) { }) } } + +func TestRequestDrivenWebService_Dockerfile(t *testing.T) { + testCases := map[string]struct { + input *RequestDrivenWebService + expectedDockerfilePath string + }{ + "specific dockerfile from buildargs": { + input: &RequestDrivenWebService{ + RequestDrivenWebServiceConfig: RequestDrivenWebServiceConfig{ + ImageConfig: ImageWithPort{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Dockerfile: aws.String("path/to/Dockerfile"), + }, + BuildString: aws.String("other/path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "specific dockerfile from buildstring": { + input: &RequestDrivenWebService{ + RequestDrivenWebServiceConfig: RequestDrivenWebServiceConfig{ + ImageConfig: ImageWithPort{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildString: aws.String("path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "dockerfile from context": { + input: &RequestDrivenWebService{ + RequestDrivenWebServiceConfig: RequestDrivenWebServiceConfig{ + ImageConfig: ImageWithPort{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Context: aws.String("path/to"), + }, + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + } + + for name, tc := range testCases { + svc := tc.input + + t.Run(name, func(t *testing.T) { + // WHEN + dockerfilePath := svc.Dockerfile() + + // THEN + require.Equal(t, tc.expectedDockerfilePath, dockerfilePath) + }) + } +} diff --git a/internal/pkg/manifest/worker_svc.go b/internal/pkg/manifest/worker_svc.go index 49ebfa37e8d..6bf2f1f7b94 100644 --- a/internal/pkg/manifest/worker_svc.go +++ b/internal/pkg/manifest/worker_svc.go @@ -34,6 +34,11 @@ type WorkerService struct { parser template.Parser } +// Dockerfile returns the relative path of the Dockerfile in the manifest. +func (s *WorkerService) Dockerfile() string { + return s.ImageConfig.Image.dockerfilePath() +} + // Publish returns the list of topics where notifications can be published. func (s *WorkerService) Publish() []Topic { return s.WorkerServiceConfig.PublishConfig.publishedTopics() diff --git a/internal/pkg/manifest/worker_svc_test.go b/internal/pkg/manifest/worker_svc_test.go index f8fe436df02..fcc5b4bc3d8 100644 --- a/internal/pkg/manifest/worker_svc_test.go +++ b/internal/pkg/manifest/worker_svc_test.go @@ -1836,3 +1836,76 @@ func TestWorkerService_Subscriptions(t *testing.T) { }) } } + +func TestWorkerService_Dockerfile(t *testing.T) { + testCases := map[string]struct { + input *WorkerService + expectedDockerfilePath string + }{ + "specific dockerfile from buildargs": { + input: &WorkerService{ + WorkerServiceConfig: WorkerServiceConfig{ + ImageConfig: ImageWithHealthcheck{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Dockerfile: aws.String("path/to/Dockerfile"), + }, + BuildString: aws.String("other/path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "specific dockerfile from buildstring": { + input: &WorkerService{ + WorkerServiceConfig: WorkerServiceConfig{ + ImageConfig: ImageWithHealthcheck{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildString: aws.String("path/to/Dockerfile"), + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + "dockerfile from context": { + input: &WorkerService{ + WorkerServiceConfig: WorkerServiceConfig{ + ImageConfig: ImageWithHealthcheck{ + Image: Image{ + ImageLocationOrBuild: ImageLocationOrBuild{ + Build: BuildArgsOrString{ + BuildArgs: DockerBuildArgs{ + Context: aws.String("path/to"), + }, + }, + }, + }, + }, + }, + }, + expectedDockerfilePath: "path/to/Dockerfile", + }, + } + + for name, tc := range testCases { + svc := tc.input + + t.Run(name, func(t *testing.T) { + // WHEN + dockerfilePath := svc.Dockerfile() + + // THEN + require.Equal(t, tc.expectedDockerfilePath, dockerfilePath) + }) + } +} diff --git a/internal/pkg/manifest/workload.go b/internal/pkg/manifest/workload.go index a05f51e1168..28d7d713305 100644 --- a/internal/pkg/manifest/workload.go +++ b/internal/pkg/manifest/workload.go @@ -172,32 +172,33 @@ func (i Image) GetLocation() string { // 3. "Dockerfile" located in context dir // 4. "Dockerfile" located in ws root. func (i *ImageLocationOrBuild) BuildConfig(rootDirectory string) *DockerBuildArgs { - df := i.dockerfile() - ctx := i.context() - dockerfile := aws.String(filepath.Join(rootDirectory, defaultDockerfileName)) - context := aws.String(rootDirectory) - - if df != "" && ctx != "" { - dockerfile = aws.String(filepath.Join(rootDirectory, df)) - context = aws.String(filepath.Join(rootDirectory, ctx)) - } - if df != "" && ctx == "" { - dockerfile = aws.String(filepath.Join(rootDirectory, df)) - context = aws.String(filepath.Join(rootDirectory, filepath.Dir(df))) - } - if df == "" && ctx != "" { - dockerfile = aws.String(filepath.Join(rootDirectory, ctx, defaultDockerfileName)) - context = aws.String(filepath.Join(rootDirectory, ctx)) - } return &DockerBuildArgs{ - Dockerfile: dockerfile, - Context: context, + Dockerfile: aws.String(filepath.Join(rootDirectory, i.dockerfilePath())), + Context: aws.String(filepath.Join(rootDirectory, i.contextPath())), Args: i.args(), Target: i.target(), CacheFrom: i.cacheFrom(), } } +// dockerfilePath returns the relative path of a Dockerfile. +// Prefer a specific Dockerfile, then a Dockerfile in the context directory. +func (i *ImageLocationOrBuild) dockerfilePath() string { + if df := i.dockerfile(); df != "" { + return df + } + return filepath.Join(i.context(), defaultDockerfileName) +} + +// dockerfileContext returns the relative context of an image. +// Prefer a specific context, then the dockerfile directory. +func (i *ImageLocationOrBuild) contextPath() string { + if ctx := i.context(); ctx != "" { + return ctx + } + return filepath.Dir(i.dockerfile()) +} + // dockerfile returns the path to the workload's Dockerfile. If no dockerfile is specified, // returns "". func (i *ImageLocationOrBuild) dockerfile() string { @@ -390,7 +391,7 @@ func (b *BuildArgsOrString) UnmarshalYAML(value *yaml.Node) error { // DockerBuildArgs represents the options specifiable under the "build" field // of Docker Compose services. For more information, see: -// https://docs.docker.com/compose/compose-file/#build +// https://docs.docker.com/compose/compose-file/build type DockerBuildArgs struct { Context *string `yaml:"context,omitempty"` Dockerfile *string `yaml:"dockerfile,omitempty"`