diff --git a/Makefile b/Makefile index 400acb1c1590cc46f1bf3e9cd8091675ebb01592..c9ed9dd5f38a268708bdf649ba18a6b76f477da8 100644 --- a/Makefile +++ b/Makefile @@ -159,6 +159,7 @@ mocks: $(MOCKERY) GOPATH=$(ORIGINAL_GOPATH) mockery $(MOCKERY_FLAGS) -dir=./vendor/github.com/ayufan/golang-kardianos-service -output=./helpers/service/mocks -name='(Interface)' GOPATH=$(ORIGINAL_GOPATH) mockery $(MOCKERY_FLAGS) -dir=./helpers/docker -all -inpkg GOPATH=$(ORIGINAL_GOPATH) mockery $(MOCKERY_FLAGS) -dir=./helpers/certificate -all -inpkg + GOPATH=$(ORIGINAL_GOPATH) mockery $(MOCKERY_FLAGS) -dir=./executors/docker -all -inpkg GOPATH=$(ORIGINAL_GOPATH) mockery $(MOCKERY_FLAGS) -dir=./cache -all -inpkg GOPATH=$(ORIGINAL_GOPATH) mockery $(MOCKERY_FLAGS) -dir=./common -all -inpkg GOPATH=$(ORIGINAL_GOPATH) mockery $(MOCKERY_FLAGS) -dir=./log -all -inpkg diff --git a/executors/docker/executor_docker.go b/executors/docker/executor_docker.go index c1573f430e93152f6b54a118e099fa9f96954134..5be1017bfdbadaa4eefb30ad7ca54a0320679c87 100644 --- a/executors/docker/executor_docker.go +++ b/executors/docker/executor_docker.go @@ -3,7 +3,6 @@ package docker import ( "bytes" "context" - "crypto/md5" "errors" "fmt" "io" @@ -26,8 +25,9 @@ import ( "gitlab.com/gitlab-org/gitlab-runner/common" "gitlab.com/gitlab-org/gitlab-runner/executors" + "gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/volumes" "gitlab.com/gitlab-org/gitlab-runner/helpers" - docker_helpers "gitlab.com/gitlab-org/gitlab-runner/helpers/docker" + "gitlab.com/gitlab-org/gitlab-runner/helpers/docker" "gitlab.com/gitlab-org/gitlab-runner/helpers/docker/helperimage" "gitlab.com/gitlab-org/gitlab-runner/helpers/featureflags" ) @@ -56,15 +56,15 @@ type executor struct { builds []string // IDs of successfully created build containers services []*types.Container - caches []string // IDs of cache containers - binds []string links []string devices []container.DeviceMapping usedImages map[string]string usedImagesLock sync.RWMutex + + volumesManager volumes.Manager } func init() { @@ -339,20 +339,6 @@ func (e *executor) getBuildImage() (*types.ImageInspect, error) { return image, nil } -func (e *executor) getAbsoluteContainerPath(dir string) string { - if path.IsAbs(dir) { - return dir - } - return path.Join(e.Build.FullProjectDir(), dir) -} - -func (e *executor) addHostVolume(hostPath, containerPath string) error { - containerPath = e.getAbsoluteContainerPath(containerPath) - e.Debugln("Using host-based", hostPath, "for", containerPath, "...") - e.binds = append(e.binds, fmt.Sprintf("%v:%v", hostPath, containerPath)) - return nil -} - func (e *executor) getLabels(containerType string, otherLabels ...string) map[string]string { labels := make(map[string]string) labels[dockerLabelPrefix+".job.id"] = strconv.Itoa(e.Build.ID) @@ -372,201 +358,10 @@ func (e *executor) getLabels(containerType string, otherLabels ...string) map[st return labels } -// createCacheVolume returns the id of the created container, or an error -func (e *executor) createCacheVolume(containerName, containerPath string) (string, error) { - cacheImage, err := e.getPrebuiltImage() - if err != nil { - return "", err - } - - cmd := []string{"gitlab-runner-helper", "cache-init", containerPath} - // TODO: Remove in 12.0 to start using the command from `gitlab-runner-helper` - if e.checkOutdatedHelperImage() { - e.Debugln(featureflags.DockerHelperImageV2, "is not set, falling back to old command") - cmd = []string{"gitlab-runner-cache", containerPath} - } - - config := &container.Config{ - Image: cacheImage.ID, - Cmd: cmd, - Volumes: map[string]struct{}{ - containerPath: {}, - }, - Labels: e.getLabels("cache", "cache.dir="+containerPath), - } - - hostConfig := &container.HostConfig{ - LogConfig: container.LogConfig{ - Type: "json-file", - }, - } - - resp, err := e.client.ContainerCreate(e.Context, config, hostConfig, nil, containerName) - if err != nil { - if resp.ID != "" { - e.temporary = append(e.temporary, resp.ID) - } - return "", err - } - - e.Debugln("Starting cache container", resp.ID, "...") - err = e.client.ContainerStart(e.Context, resp.ID, types.ContainerStartOptions{}) - if err != nil { - e.temporary = append(e.temporary, resp.ID) - return "", err - } - - e.Debugln("Waiting for cache container", resp.ID, "...") - err = e.waitForContainer(e.Context, resp.ID) - if err != nil { - e.temporary = append(e.temporary, resp.ID) - return "", err - } - - return resp.ID, nil -} - -func (e *executor) addCacheVolume(containerPath string) error { - var err error - containerPath = e.getAbsoluteContainerPath(containerPath) - - // disable cache for automatic container cache, but leave it for host volumes (they are shared on purpose) - if e.Config.Docker.DisableCache { - e.Debugln("Container cache for", containerPath, " is disabled.") - return nil - } - - hash := md5.Sum([]byte(containerPath)) - - // use host-based cache - if cacheDir := e.Config.Docker.CacheDir; cacheDir != "" { - hostPath := fmt.Sprintf("%s/%s/%x", cacheDir, e.Build.ProjectUniqueName(), hash) - hostPath, err := filepath.Abs(hostPath) - if err != nil { - return err - } - e.Debugln("Using path", hostPath, "as cache for", containerPath, "...") - e.binds = append(e.binds, fmt.Sprintf("%v:%v", filepath.ToSlash(hostPath), containerPath)) - return nil - } - - // get existing cache container - var containerID string - containerName := fmt.Sprintf("%s-cache-%x", e.Build.ProjectUniqueName(), hash) - if inspected, err := e.client.ContainerInspect(e.Context, containerName); err == nil { - // check if we have valid cache, if not remove the broken container - if _, ok := inspected.Config.Volumes[containerPath]; !ok { - e.Debugln("Removing broken cache container for ", containerPath, "path") - e.removeContainer(e.Context, inspected.ID) - } else { - containerID = inspected.ID - } - } - - // create new cache container for that project - if containerID == "" { - containerID, err = e.createCacheVolume(containerName, containerPath) - if err != nil { - return err - } - } - - e.Debugln("Using container", containerID, "as cache", containerPath, "...") - e.caches = append(e.caches, containerID) - return nil -} - -func (e *executor) addVolume(volume string) error { - var err error - hostVolume := strings.SplitN(volume, ":", 2) - switch len(hostVolume) { - case 2: - err = e.addHostVolume(hostVolume[0], hostVolume[1]) - - case 1: - // disable cache disables - err = e.addCacheVolume(hostVolume[0]) - } - - if err != nil { - e.Errorln("Failed to create container volume for", volume, err) - } - return err -} - func fakeContainer(id string, names ...string) *types.Container { return &types.Container{ID: id, Names: names} } -func (e *executor) createBuildVolume() error { - parentDir := e.Build.RootDir - - if e.Build.IsFeatureFlagOn(featureflags.UseLegacyBuildsDirForDocker) { - // Cache Git sources: - // take path of the projects directory, - // because we use `rm -rf` which could remove the mounted volume - parentDir = path.Dir(e.Build.FullProjectDir()) - } - - if !path.IsAbs(parentDir) && parentDir != "/" { - return common.MakeBuildError("build directory needs to be absolute and non-root path") - } - - if e.isHostMountedVolume(e.Build.RootDir, e.Config.Docker.Volumes...) { - return nil - } - - if e.Build.GetGitStrategy() == common.GitFetch && !e.Config.Docker.DisableCache { - // create persistent cache container - return e.addVolume(parentDir) - } - - // create temporary cache container - id, err := e.createCacheVolume("", parentDir) - if err != nil { - return err - } - - e.caches = append(e.caches, id) - e.temporary = append(e.temporary, id) - - return nil -} - -func (e *executor) createUserVolumes() (err error) { - for _, volume := range e.Config.Docker.Volumes { - err = e.addVolume(volume) - if err != nil { - return - } - } - return nil -} - -func (e *executor) isHostMountedVolume(dir string, volumes ...string) bool { - isParentOf := func(parent string, dir string) bool { - for dir != "/" && dir != "." { - if dir == parent { - return true - } - dir = path.Dir(dir) - } - return false - } - - for _, volume := range volumes { - hostVolume := strings.Split(volume, ":") - if len(hostVolume) < 2 { - continue - } - - if isParentOf(path.Clean(hostVolume[1]), path.Clean(dir)) { - return true - } - } - return false -} - func (e *executor) parseDeviceString(deviceString string) (device container.DeviceMapping, err error) { // Split the device string PathOnHost[:PathInContainer[:CgroupPermissions]] parts := strings.Split(deviceString, ":") @@ -698,9 +493,9 @@ func (e *executor) createService(serviceIndex int, service, version, image strin ExtraHosts: e.Config.Docker.ExtraHosts, Privileged: e.Config.Docker.Privileged, NetworkMode: container.NetworkMode(e.Config.Docker.NetworkMode), - Binds: e.binds, + Binds: e.volumesManager.Binds(), ShmSize: e.Config.Docker.ShmSize, - VolumesFrom: e.caches, + VolumesFrom: e.volumesManager.ContainerIDs(), Tmpfs: e.Config.Docker.ServicesTmpfs, LogConfig: container.LogConfig{ Type: "json-file", @@ -879,7 +674,7 @@ func (e *executor) createContainer(containerType string, imageDefinition common. // By default we use caches container, // but in later phases we hook to previous build container - volumesFrom := e.caches + volumesFrom := e.volumesManager.ContainerIDs() if len(e.builds) > 0 { volumesFrom = []string{ e.builds[len(e.builds)-1], @@ -908,7 +703,7 @@ func (e *executor) createContainer(containerType string, imageDefinition common. ExtraHosts: e.Config.Docker.ExtraHosts, NetworkMode: container.NetworkMode(e.Config.Docker.NetworkMode), Links: append(e.Config.Docker.Links, e.links...), - Binds: e.binds, + Binds: e.volumesManager.Binds(), ShmSize: e.Config.Docker.ShmSize, VolumeDriver: e.Config.Docker.VolumeDriver, VolumesFrom: append(e.Config.Docker.VolumesFrom, volumesFrom...), @@ -1189,15 +984,13 @@ func (e *executor) validateOSType() error { return nil } -func (e *executor) createDependencies() (err error) { - err = e.bindDevices() +func (e *executor) createDependencies() error { + err := e.bindDevices() if err != nil { return err } - e.SetCurrentStage(DockerExecutorStageCreatingBuildVolumes) - e.Debugln("Creating build volume...") - err = e.createBuildVolume() + err = e.createVolumes() if err != nil { return err } @@ -1209,18 +1002,81 @@ func (e *executor) createDependencies() (err error) { return err } - e.SetCurrentStage(DockerExecutorStageCreatingUserVolumes) - e.Debugln("Creating user-defined volumes...") - err = e.createUserVolumes() + return nil +} + +func (e *executor) createVolumes() error { + err := e.createVolumesManager() if err != nil { return err } - return + e.SetCurrentStage(DockerExecutorStageCreatingUserVolumes) + e.Debugln("Creating user-defined volumes...") + + for _, volume := range e.Config.Docker.Volumes { + err = e.volumesManager.Create(volume) + if err == volumes.ErrCacheVolumesDisabled { + e.Warningln(fmt.Sprintf( + "Container based cache volumes creation is disabled. Will not create volume for %q", + volume, + )) + continue + } + + if err != nil { + return err + } + } + + e.SetCurrentStage(DockerExecutorStageCreatingBuildVolumes) + e.Debugln("Creating build volume...") + + return e.createBuildVolume() } +func (e *executor) createBuildVolume() error { + jobsDir := e.Build.RootDir + + // TODO: Remove in 12.3 + if e.Build.IsFeatureFlagOn(featureflags.UseLegacyBuildsDirForDocker) { + // Cache Git sources: + // take path of the projects directory, + // because we use `rm -rf` which could remove the mounted volume + jobsDir = path.Dir(e.Build.FullProjectDir()) + } + + var err error + + if e.Build.GetGitStrategy() == common.GitFetch { + err = e.volumesManager.Create(jobsDir) + if err == nil { + return nil + } + + if err == volumes.ErrCacheVolumesDisabled { + err = e.volumesManager.CreateTemporary(jobsDir) + } + } else { + err = e.volumesManager.CreateTemporary(jobsDir) + } + + if err != nil { + if _, ok := err.(*volumes.ErrVolumeAlreadyDefined); !ok { + return err + } + } + + return nil +} func (e *executor) Prepare(options common.ExecutorPrepareOptions) error { - err := e.prepareBuildsDir(options.Config) + e.SetCurrentStage(DockerExecutorStagePrepare) + + if options.Config.Docker == nil { + return errors.New("missing docker configuration") + } + + err := e.prepareBuildsDir(options) if err != nil { return err } @@ -1231,14 +1087,9 @@ func (e *executor) Prepare(options common.ExecutorPrepareOptions) error { } if e.BuildShell.PassFile { - return errors.New("Docker doesn't support shells that require script file") + return errors.New("docker doesn't support shells that require script file") } - if options.Config.Docker == nil { - return errors.New("Missing docker configuration") - } - - e.SetCurrentStage(DockerExecutorStagePrepare) imageName, err := e.expandImageName(e.Build.Image.Name, []string{}) if err != nil { return err @@ -1258,14 +1109,30 @@ func (e *executor) Prepare(options common.ExecutorPrepareOptions) error { return nil } -func (e *executor) prepareBuildsDir(config *common.RunnerConfig) error { - rootDir := config.BuildsDir - if rootDir == "" { - rootDir = e.DefaultBuildsDir - } - if e.isHostMountedVolume(rootDir, config.Docker.Volumes...) { +var ( + buildDirectoryNotAbsoluteErr = common.MakeBuildError("build directory needs to be an absolute path") + + buildDirectoryIsRootPathErr = common.MakeBuildError("build directory needs to be a non-root path") +) + +func (e *executor) prepareBuildsDir(options common.ExecutorPrepareOptions) error { + // We need to set proper value for e.SharedBuildsDir because + // it's required to properly start the job, what is done inside of + // e.AbstractExecutor.Prepare() + // And a started job is required for Volumes Manager to work, so it's + // done before the manager is even created. + if volumes.IsHostMountedVolume(e.RootDir(), options.Config.Docker.Volumes...) { e.SharedBuildsDir = true } + + if !filepath.IsAbs(e.RootDir()) { + return buildDirectoryNotAbsoluteErr + } + + if e.RootDir() == "/" { + return buildDirectoryIsRootPathErr + } + return nil } @@ -1289,6 +1156,10 @@ func (e *executor) Cleanup() { remove(temporaryID) } + if e.volumesManager != nil { + <-e.volumesManager.Cleanup(ctx) + } + wg.Wait() if e.client != nil { @@ -1426,7 +1297,3 @@ func (e *executor) readContainerLogs(containerID string) string { containerLog := containerBuffer.String() return strings.TrimSpace(containerLog) } - -func (e *executor) checkOutdatedHelperImage() bool { - return !e.Build.IsFeatureFlagOn(featureflags.DockerHelperImageV2) && e.Config.Docker.HelperImage != "" -} diff --git a/executors/docker/executor_docker_test.go b/executors/docker/executor_docker_test.go index 0abf9b85999310167a17c57ac5f010f9755580e7..8be43fbcd6ef5d83415a7af75765a80dbc504498 100644 --- a/executors/docker/executor_docker_test.go +++ b/executors/docker/executor_docker_test.go @@ -23,6 +23,7 @@ import ( "gitlab.com/gitlab-org/gitlab-runner/common" "gitlab.com/gitlab-org/gitlab-runner/executors" + "gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/volumes" "gitlab.com/gitlab-org/gitlab-runner/helpers" docker_helpers "gitlab.com/gitlab-org/gitlab-runner/helpers/docker" "gitlab.com/gitlab-org/gitlab-runner/helpers/docker/helperimage" @@ -197,7 +198,11 @@ func testServiceFromNamedImage(t *testing.T, description, imageName, serviceName containerName := fmt.Sprintf("runner-abcdef12-project-0-concurrent-0-%s-0", strings.Replace(serviceName, "/", "__", -1)) networkID := "network-id" - e := executor{client: &c} + e := executor{ + client: &c, + info: types.Info{OSType: helperimage.OSTypeLinux}, + } + options := buildImagePullOptions(e, imageName) e.Config = common.RunnerConfig{} e.Config.Docker = &common.DockerConfig{} @@ -233,6 +238,10 @@ func testServiceFromNamedImage(t *testing.T, description, imageName, serviceName Return(nil). Once() + c.On("ImageInspectWithRaw", mock.Anything, "gitlab/gitlab-runner-helper:x86_64-latest"). + Return(types.ImageInspect{ID: "helper-image-id"}, nil, nil). + Once() + c.On("ContainerCreate", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(container.ContainerCreateCreatedBody{ID: containerName}, nil). Once() @@ -241,8 +250,11 @@ func testServiceFromNamedImage(t *testing.T, description, imageName, serviceName Return(nil). Once() + err := e.createVolumesManager() + require.NoError(t, err) + linksMap := make(map[string]*types.Container) - err := e.createFromServiceDefinition(0, common.Image{Name: description}, linksMap) + err = e.createFromServiceDefinition(0, common.Image{Name: description}, linksMap) assert.NoError(t, err) } @@ -542,37 +554,337 @@ func TestDockerGetExistingDockerImageIfPullFails(t *testing.T) { assert.Nil(t, image, "No existing image") } -func TestHostMountedBuildsDirectory(t *testing.T) { - tests := []struct { - path string - volumes []string - result bool +func TestPrepareBuildsDir(t *testing.T) { + tests := map[string]struct { + rootDir string + volumes []string + expectedSharedBuildsDir bool + expectedError error }{ - {"/build", []string{"/build:/build"}, true}, - {"/build", []string{"/build/:/build"}, true}, - {"/build", []string{"/build"}, false}, - {"/build", []string{"/folder:/folder"}, false}, - {"/build", []string{"/folder"}, false}, - {"/build/other/directory", []string{"/build/:/build"}, true}, - {"/build/other/directory", []string{}, false}, - } - - for _, i := range tests { - c := common.RunnerConfig{ - RunnerSettings: common.RunnerSettings{ - BuildsDir: i.path, - Docker: &common.DockerConfig{ - Volumes: i.volumes, + "rootDir mounted as host based volume": { + rootDir: "/build", + volumes: []string{"/build:/build"}, + expectedSharedBuildsDir: true, + }, + "rootDir mounted as container based volume": { + rootDir: "/build", + volumes: []string{"/build"}, + expectedSharedBuildsDir: false, + }, + "rootDir not mounted as volume": { + rootDir: "/build", + volumes: []string{"/folder:/folder"}, + expectedSharedBuildsDir: false, + }, + "rootDir's parent mounted as volume": { + rootDir: "/build/other/directory", + volumes: []string{"/build/:/build"}, + expectedSharedBuildsDir: true, + }, + "rootDir is not an absolute path": { + rootDir: "builds", + expectedError: buildDirectoryNotAbsoluteErr, + }, + "rootDir is /": { + rootDir: "/", + expectedError: buildDirectoryIsRootPathErr, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + c := common.RunnerConfig{ + RunnerSettings: common.RunnerSettings{ + BuildsDir: test.rootDir, + Docker: &common.DockerConfig{ + Volumes: test.volumes, + }, }, + } + + options := common.ExecutorPrepareOptions{ + Config: &c, + } + + e := &executor{ + AbstractExecutor: executors.AbstractExecutor{ + Config: c, + }, + } + err := e.prepareBuildsDir(options) + assert.Equal(t, test.expectedError, err) + assert.Equal(t, test.expectedSharedBuildsDir, e.SharedBuildsDir) + }) + } +} + +func TestCreateVolumes(t *testing.T) { + defaultBuildsDir := "/default-builds-dir" + + tests := map[string]struct { + volumes []string + buildsDir string + gitStrategy string + volumesManagerAssertions func(*volumes.MockManager) + adjustConfiguration func(e *executor) + expectedError error + }{ + "no volumes defined, empty buildsDir, clone strategy, no errors": { + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("CreateTemporary", defaultBuildsDir). + Return(nil). + Once() }, - } - e := &executor{} + }, + "no volumes defined, empty buildsDir, clone strategy, duplicated entry error": { + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("CreateTemporary", defaultBuildsDir). + Return(volumes.NewErrVolumeAlreadyDefined(defaultBuildsDir)). + Once() + }, + }, + "no volumes defined, empty buildsDir, clone strategy, other error": { + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("CreateTemporary", defaultBuildsDir). + Return(errors.New("test-error")). + Once() + }, + expectedError: errors.New("test-error"), + }, + "no volumes defined, defined buildsDir, clone strategy, no errors": { + buildsDir: "/builds", + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("CreateTemporary", "/builds"). + Return(nil). + Once() + }, + }, + "no volumes defined, defined buildsDir, clone strategy, duplicated entry error": { + buildsDir: "/builds", + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("CreateTemporary", "/builds"). + Return(volumes.NewErrVolumeAlreadyDefined("/builds")). + Once() + }, + }, + "no volumes defined, defined buildsDir, clone strategy, other error": { + buildsDir: "/builds", + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("CreateTemporary", "/builds"). + Return(errors.New("test-error")). + Once() + }, + expectedError: errors.New("test-error"), + }, + "no volumes defined, defined buildsDir, fetch strategy, no errors": { + buildsDir: "/builds", + gitStrategy: "fetch", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/builds"). + Return(nil). + Once() + }, + }, + "no volumes defined, defined buildsDir, fetch strategy, duplicated entry error": { + buildsDir: "/builds", + gitStrategy: "fetch", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/builds"). + Return(volumes.NewErrVolumeAlreadyDefined("/builds")). + Once() + }, + }, + "no volumes defined, defined buildsDir, fetch strategy, cache containers disabled error": { + buildsDir: "/builds", + gitStrategy: "fetch", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/builds"). + Return(volumes.ErrCacheVolumesDisabled). + Once() + + vm.On("CreateTemporary", "/builds"). + Return(nil). + Once() + }, + }, + "no volumes defined, defined buildsDir, fetch strategy, cache containers disabled error and temporary returns duplicated error": { + buildsDir: "/builds", + gitStrategy: "fetch", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/builds"). + Return(volumes.ErrCacheVolumesDisabled). + Once() + + vm.On("CreateTemporary", "/builds"). + Return(volumes.NewErrVolumeAlreadyDefined("/builds")). + Once() + }, + }, + "no volumes defined, defined buildsDir, fetch strategy, cache containers disabled error and temporary returns other error": { + buildsDir: "/builds", + gitStrategy: "fetch", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/builds"). + Return(volumes.ErrCacheVolumesDisabled). + Once() + + vm.On("CreateTemporary", "/builds"). + Return(errors.New("test-error")). + Once() + }, + expectedError: errors.New("test-error"), + }, + "no volumes defined, defined buildsDir, fetch strategy, other error": { + buildsDir: "/builds", + gitStrategy: "fetch", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/builds"). + Return(errors.New("test-error")). + Once() + }, + expectedError: errors.New("test-error"), + }, + // TODO: Remove in 12.3 + "no volumes defined, defined buildsDir, clone strategy, UseLegacyBuildsDirForDocker FF enabled": { + buildsDir: "/builds", + gitStrategy: "fetch", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/builds/group"). + Return(errors.New("test-error")). + Once() + }, + adjustConfiguration: func(e *executor) { + e.Build.Variables = append(e.Build.Variables, common.JobVariable{ + Key: featureflags.UseLegacyBuildsDirForDocker, + Value: "true", + }) + }, + expectedError: errors.New("test-error"), + }, + "volumes defined, empty buildsDir, clone strategy, no errors on user volume": { + volumes: []string{"/volume"}, + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/volume"). + Return(nil). + Once() + vm.On("CreateTemporary", defaultBuildsDir). + Return(nil). + Once() + }, + }, + "volumes defined, empty buildsDir, clone strategy, cache containers disabled error on user volume": { + volumes: []string{"/volume"}, + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/volume"). + Return(volumes.ErrCacheVolumesDisabled). + Once() + vm.On("CreateTemporary", defaultBuildsDir). + Return(nil). + Once() + }, + }, + "volumes defined, empty buildsDir, clone strategy, duplicated error on user volume": { + volumes: []string{"/volume"}, + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/volume"). + Return(volumes.NewErrVolumeAlreadyDefined("/volume")). + Once() + }, + expectedError: volumes.NewErrVolumeAlreadyDefined("/volume"), + }, + "volumes defined, empty buildsDir, clone strategy, other error on user volume": { + volumes: []string{"/volume"}, + gitStrategy: "clone", + volumesManagerAssertions: func(vm *volumes.MockManager) { + vm.On("Create", "/volume"). + Return(errors.New("test-error")). + Once() + }, + expectedError: errors.New("test-error"), + }, + } - t.Log("Testing", i.path, "if volumes are configured to:", i.volumes, "...") - assert.Equal(t, i.result, e.isHostMountedVolume(i.path, i.volumes...)) + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + volumesManagerMock := new(volumes.MockManager) + + oldCreateVolumesManager := createVolumesManager + defer func() { + volumesManagerMock.AssertExpectations(t) + createVolumesManager = oldCreateVolumesManager + }() + createVolumesManager = func(_ *executor) (volumes.Manager, error) { + return volumesManagerMock, nil + } - e.prepareBuildsDir(&c) - assert.Equal(t, i.result, e.SharedBuildsDir) + if test.volumesManagerAssertions != nil { + test.volumesManagerAssertions(volumesManagerMock) + } + + c := common.RunnerConfig{ + RunnerCredentials: common.RunnerCredentials{ + Token: "abcdef1234567890", + }, + RunnerSettings: common.RunnerSettings{ + BuildsDir: test.buildsDir, + Docker: &common.DockerConfig{ + Volumes: test.volumes, + }, + }, + } + e := &executor{ + AbstractExecutor: executors.AbstractExecutor{ + Build: &common.Build{ + ProjectRunnerID: 0, + Runner: &c, + JobResponse: common.JobResponse{ + JobInfo: common.JobInfo{ + ProjectID: 0, + }, + GitInfo: common.GitInfo{ + RepoURL: "https://gitlab.example.com/group/project.git", + }, + }, + }, + Config: c, + ExecutorOptions: executors.ExecutorOptions{ + DefaultBuildsDir: defaultBuildsDir, + }, + }, + info: types.Info{ + OSType: helperimage.OSTypeLinux, + }, + } + + e.Build.Variables = append(e.Build.Variables, common.JobVariable{ + Key: "GIT_STRATEGY", + Value: test.gitStrategy, + }) + + if test.adjustConfiguration != nil { + test.adjustConfiguration(e) + } + + err := e.Build.StartBuild( + e.RootDir(), + e.CacheDir(), + e.CustomBuildEnabled(), + e.SharedBuildsDir, + ) + require.NoError(t, err) + + err = e.createVolumes() + assert.Equal(t, test.expectedError, err) + }) } } @@ -900,7 +1212,10 @@ func TestDockerWatchOn_1_12_4(t *testing.T) { e.Trace = &common.Trace{Writer: output} err := e.connectDocker() - assert.NoError(t, err) + require.NoError(t, err) + + err = e.createVolumesManager() + require.NoError(t, err) container, err := e.createContainer("build", common.Image{Name: common.TestAlpineImage}, []string{"/bin/sh"}, []string{}) assert.NoError(t, err) @@ -952,6 +1267,7 @@ func prepareTestDockerConfiguration(t *testing.T, dockerConfig *common.DockerCon e := &executor{} e.client = c + e.info = types.Info{OSType: helperimage.OSTypeLinux} e.Config.Docker = dockerConfig e.Build = &common.Build{ Runner: &common.RunnerConfig{}, @@ -961,6 +1277,8 @@ func prepareTestDockerConfiguration(t *testing.T, dockerConfig *common.DockerCon Environment: []string{}, } + c.On("ImageInspectWithRaw", mock.Anything, "gitlab/gitlab-runner-helper:x86_64-latest"). + Return(types.ImageInspect{ID: "helper-image-id"}, nil, nil).Once() c.On("ImageInspectWithRaw", mock.Anything, "alpine"). Return(types.ImageInspect{ID: "123"}, []byte{}, nil).Twice() c.On("ImagePullBlocking", mock.Anything, "alpine:latest", mock.Anything). @@ -980,7 +1298,10 @@ func testDockerConfigurationWithJobContainer(t *testing.T, dockerConfig *common. c.On("ContainerInspect", mock.Anything, "abc"). Return(types.ContainerJSON{}, nil).Once() - _, err := e.createContainer("build", common.Image{Name: "alpine"}, []string{"/bin/sh"}, []string{}) + err := e.createVolumesManager() + require.NoError(t, err) + + _, err = e.createContainer("build", common.Image{Name: "alpine"}, []string{"/bin/sh"}, []string{}) assert.NoError(t, err, "Should create container without errors") } @@ -991,7 +1312,10 @@ func testDockerConfigurationWithServiceContainer(t *testing.T, dockerConfig *com c.On("ContainerStart", mock.Anything, "abc", mock.Anything). Return(nil).Once() - _, err := e.createService(0, "build", "latest", "alpine", common.Image{Command: []string{"/bin/sh"}}) + err := e.createVolumesManager() + require.NoError(t, err) + + _, err = e.createService(0, "build", "latest", "alpine", common.Image{Command: []string{"/bin/sh"}}) assert.NoError(t, err, "Should create service container without errors") } @@ -1223,84 +1547,43 @@ func TestCheckOSType(t *testing.T) { } // TODO: Remove in 12.0 -func TestCreateCacheVolumeFeatureFlag(t *testing.T) { - cacheDir := "/cache" +func TestOutdatedHelperImage(t *testing.T) { + ffNotSet := common.JobVariables{} + ffSet := common.JobVariables{ + {Key: featureflags.DockerHelperImageV2, Value: "true"}, + } - cases := []struct { - name string - variables common.JobVariables - helperImage string - expectedCmd []string + testCases := map[string]struct { + helperImage string + variables common.JobVariables + expectedResult bool }{ - { - name: "Helper image is not specified", - variables: common.JobVariables{}, - helperImage: "", - expectedCmd: []string{"gitlab-runner-helper", "cache-init", cacheDir}, + "helper image not set and FF set to false": { + variables: ffNotSet, + helperImage: "", + expectedResult: false, }, - { - name: "Helper image is not specified and FF still turned on", - variables: common.JobVariables{ - common.JobVariable{Key: featureflags.DockerHelperImageV2, Value: "true"}, - }, - helperImage: "", - expectedCmd: []string{"gitlab-runner-helper", "cache-init", cacheDir}, + "helper image not set and FF set to true": { + variables: ffSet, + helperImage: "", + expectedResult: false, }, - { - name: "Helper image is specified", - variables: common.JobVariables{}, - helperImage: "gitlab/gitlab-runner-helper:x86_64-latest", - expectedCmd: []string{"gitlab-runner-cache", cacheDir}, + "helper image set and FF set to false": { + variables: ffNotSet, + helperImage: "gitlab/gitlab-runner-helper:x86_64-latest", + expectedResult: true, }, - { - name: "Helper image is specified & FF variable is set to true", - variables: common.JobVariables{ - common.JobVariable{Key: featureflags.DockerHelperImageV2, Value: "true"}, - }, - helperImage: "gitlab/gitlab-runner-helper:x86_64-latest", - expectedCmd: []string{"gitlab-runner-helper", "cache-init", cacheDir}, + "helper image set and FF set to true": { + variables: ffSet, + helperImage: "gitlab/gitlab-runner-helper:x86_64-latest", + expectedResult: false, }, } - for _, testCase := range cases { - t.Run(testCase.name, func(t *testing.T) { - helperImageID := fmt.Sprintf("%s-helperImage-%d", t.Name(), time.Now().Unix()) - cacheContainerID := fmt.Sprintf("%s-cacheContainer-%d", t.Name(), time.Now().Unix()) - containerName := fmt.Sprintf("%s-cacheContainerName-%d", t.Name(), time.Now().Unix()) - - mClient := docker_helpers.MockClient{} - defer mClient.AssertExpectations(t) - mClient.On("ImageInspectWithRaw", mock.Anything, mock.Anything). - Return(types.ImageInspect{ID: helperImageID}, nil, nil) - mClient.On("ContainerStart", mock.Anything, cacheContainerID, mock.Anything).Return(nil).Once() - mClient.On("ContainerInspect", mock.Anything, cacheContainerID).Return(types.ContainerJSON{ - ContainerJSONBase: &types.ContainerJSONBase{ - State: &types.ContainerState{ - ExitCode: 0, - }, - }, - }, nil).Once() - if testCase.helperImage != "" { - mClient.On("ImagePullBlocking", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() - } - - executor := setUpExecutorForFeatureFlag(testCase.variables, testCase.helperImage, &mClient) - - expectedConfig := &container.Config{ - Image: helperImageID, - Cmd: testCase.expectedCmd, - Volumes: map[string]struct{}{ - cacheDir: {}, - }, - Labels: executor.getLabels("cache", "cache.dir="+cacheDir), - } - - mClient.On("ContainerCreate", mock.Anything, expectedConfig, mock.Anything, mock.Anything, containerName). - Return(container.ContainerCreateCreatedBody{ID: cacheContainerID}, nil). - Once() - - _, err := executor.createCacheVolume(containerName, cacheDir) - assert.NoError(t, err) + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + e := setUpExecutorForFeatureFlag(testCase.variables, testCase.helperImage, nil) + assert.Equal(t, testCase.expectedResult, e.checkOutdatedHelperImage()) }) } } diff --git a/executors/docker/internal/volumes/cache_container.go b/executors/docker/internal/volumes/cache_container.go new file mode 100644 index 0000000000000000000000000000000000000000..480ab9376d3887a045d1890b9b37644e2fb4ba11 --- /dev/null +++ b/executors/docker/internal/volumes/cache_container.go @@ -0,0 +1,169 @@ +package volumes + +import ( + "context" + "fmt" + "sync" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + + "gitlab.com/gitlab-org/gitlab-runner/helpers/docker" +) + +type containerClient interface { + docker_helpers.Client + + LabelContainer(container *container.Config, containerType string, otherLabels ...string) + WaitForContainer(id string) error + RemoveContainer(ctx context.Context, id string) error +} + +type CacheContainersManager interface { + FindOrCleanExisting(containerName string, containerPath string) string + Create(containerName string, containerPath string) (string, error) + Cleanup(ctx context.Context, ids []string) chan bool +} + +type cacheContainerManager struct { + ctx context.Context + logger debugLogger + + containerClient containerClient + + helperImage *types.ImageInspect + outdatedHelperImage bool + failedContainerIDs []string +} + +func NewCacheContainerManager(ctx context.Context, logger debugLogger, cClient containerClient, helperImage *types.ImageInspect, outdatedHelperImage bool) CacheContainersManager { + return &cacheContainerManager{ + ctx: ctx, + logger: logger, + containerClient: cClient, + helperImage: helperImage, + outdatedHelperImage: outdatedHelperImage, + } +} + +func (m *cacheContainerManager) FindOrCleanExisting(containerName string, containerPath string) string { + inspected, err := m.containerClient.ContainerInspect(m.ctx, containerName) + if err != nil { + m.logger.Debugln(fmt.Sprintf("Error while inspecting %q container: %v", containerName, err)) + return "" + } + + // check if we have valid cache, if not remove the broken container + _, ok := inspected.Config.Volumes[containerPath] + if !ok { + m.logger.Debugln(fmt.Sprintf("Removing broken cache container for %q path", containerPath)) + err = m.containerClient.RemoveContainer(m.ctx, inspected.ID) + m.logger.Debugln(fmt.Sprintf("Cache container for %q path removed with %v", containerPath, err)) + + return "" + } + + return inspected.ID +} + +func (m *cacheContainerManager) Create(containerName string, containerPath string) (string, error) { + containerID, err := m.createCacheContainer(containerName, containerPath) + if err != nil { + return "", err + } + + err = m.startCacheContainer(containerID) + if err != nil { + return "", err + } + + return containerID, nil +} + +func (m *cacheContainerManager) createCacheContainer(containerName string, containerPath string) (string, error) { + config := &container.Config{ + Image: m.helperImage.ID, + Cmd: m.getCacheCommand(containerPath), + Volumes: map[string]struct{}{ + containerPath: {}, + }, + } + m.containerClient.LabelContainer(config, "cache", "cache.dir="+containerPath) + + hostConfig := &container.HostConfig{ + LogConfig: container.LogConfig{ + Type: "json-file", + }, + } + + resp, err := m.containerClient.ContainerCreate(m.ctx, config, hostConfig, nil, containerName) + if err != nil { + if resp.ID != "" { + m.failedContainerIDs = append(m.failedContainerIDs, resp.ID) + } + + return "", err + } + + return resp.ID, nil +} + +func (m *cacheContainerManager) getCacheCommand(containerPath string) []string { + // TODO: Remove in 12.0 to start using the command from `gitlab-runner-helper` + if m.outdatedHelperImage { + m.logger.Debugln("Falling back to old gitlab-runner-cache command") + return []string{"gitlab-runner-cache", containerPath} + } + + return []string{"gitlab-runner-helper", "cache-init", containerPath} + +} + +func (m *cacheContainerManager) startCacheContainer(containerID string) error { + m.logger.Debugln(fmt.Sprintf("Starting cache container %q...", containerID)) + err := m.containerClient.ContainerStart(m.ctx, containerID, types.ContainerStartOptions{}) + if err != nil { + m.failedContainerIDs = append(m.failedContainerIDs, containerID) + + return err + } + + m.logger.Debugln(fmt.Sprintf("Waiting for cache container %q...", containerID)) + err = m.containerClient.WaitForContainer(containerID) + if err != nil { + m.failedContainerIDs = append(m.failedContainerIDs, containerID) + + return err + } + + return nil +} + +func (m *cacheContainerManager) Cleanup(ctx context.Context, ids []string) chan bool { + done := make(chan bool, 1) + + ids = append(m.failedContainerIDs, ids...) + + go func() { + wg := new(sync.WaitGroup) + wg.Add(len(ids)) + for _, id := range ids { + m.remove(ctx, wg, id) + } + + wg.Wait() + done <- true + }() + + return done +} + +func (m *cacheContainerManager) remove(ctx context.Context, wg *sync.WaitGroup, id string) { + go func() { + err := m.containerClient.RemoveContainer(ctx, id) + if err != nil { + m.logger.Debugln(fmt.Sprintf("Error while removing the container: %v", err)) + } + wg.Done() + }() +} diff --git a/executors/docker/internal/volumes/cache_container_test.go b/executors/docker/internal/volumes/cache_container_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5b031b3806ebee6d39c17fcdb421d64001bf168b --- /dev/null +++ b/executors/docker/internal/volumes/cache_container_test.go @@ -0,0 +1,253 @@ +package volumes + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestNewCacheContainerManager(t *testing.T) { + logger := newDebugLoggerMock() + + m := NewCacheContainerManager(context.Background(), logger, nil, nil, true) + assert.IsType(t, &cacheContainerManager{}, m) +} + +func getCacheContainerManager() (*cacheContainerManager, *mockContainerClient) { + cClient := new(mockContainerClient) + + m := &cacheContainerManager{ + logger: newDebugLoggerMock(), + containerClient: cClient, + failedContainerIDs: make([]string, 0), + helperImage: &types.ImageInspect{ID: "helper-image"}, + outdatedHelperImage: false, + } + + return m, cClient +} + +func TestCacheContainerManager_FindExistingCacheContainer(t *testing.T) { + containerName := "container-name" + containerPath := "container-path" + + testCases := map[string]struct { + inspectResult types.ContainerJSON + inspectError error + expectedContainerID string + expectedRemoveID string + }{ + "error on container inspection": { + inspectError: errors.New("test error"), + expectedContainerID: "", + }, + "container with valid cache exists": { + inspectResult: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + ID: "existingWithValidCacheID", + }, + Config: &container.Config{ + Volumes: map[string]struct{}{ + containerPath: {}, + }, + }, + }, + inspectError: nil, + expectedContainerID: "existingWithValidCacheID", + }, + "container without valid cache exists": { + inspectResult: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + ID: "existingWithInvalidCacheID", + }, + Config: &container.Config{ + Volumes: map[string]struct{}{ + "different-path": {}, + }, + }, + }, + inspectError: nil, + expectedContainerID: "", + expectedRemoveID: "existingWithInvalidCacheID", + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + m, cClient := getCacheContainerManager() + defer cClient.AssertExpectations(t) + + cClient.On("ContainerInspect", mock.Anything, containerName). + Return(testCase.inspectResult, testCase.inspectError). + Once() + + if testCase.expectedRemoveID != "" { + cClient.On("RemoveContainer", mock.Anything, testCase.expectedRemoveID). + Return(nil). + Once() + } + + containerID := m.FindOrCleanExisting(containerName, containerPath) + assert.Equal(t, testCase.expectedContainerID, containerID) + }) + } +} + +func TestCacheContainerManager_CreateCacheContainer(t *testing.T) { + containerName := "container-name" + containerPath := "container-path" + + testCases := map[string]struct { + expectedContainerID string + createResult container.ContainerCreateCreatedBody + createError error + containerID string + startError error + waitForContainerError error + expectedFailedContainerID string + expectedError error + }{ + "error on container create": { + createError: errors.New("test error"), + expectedError: errors.New("test error"), + }, + "error on container create with returnedID": { + createResult: container.ContainerCreateCreatedBody{ + ID: "containerID", + }, + createError: errors.New("test error"), + expectedFailedContainerID: "containerID", + expectedError: errors.New("test error"), + }, + "error on container start": { + createResult: container.ContainerCreateCreatedBody{ + ID: "containerID", + }, + containerID: "containerID", + startError: errors.New("test error"), + expectedFailedContainerID: "containerID", + expectedError: errors.New("test error"), + }, + "error on wait for container": { + createResult: container.ContainerCreateCreatedBody{ + ID: "containerID", + }, + containerID: "containerID", + waitForContainerError: errors.New("test error"), + expectedFailedContainerID: "containerID", + expectedError: errors.New("test error"), + }, + "success": { + createResult: container.ContainerCreateCreatedBody{ + ID: "containerID", + }, + containerID: "containerID", + expectedContainerID: "containerID", + expectedError: nil, + }, + } + + // TODO: Remove in 12.0 + outdatedHelperImageValues := map[bool][]string{ + true: {"gitlab-runner-cache", "container-path"}, + false: {"gitlab-runner-helper", "cache-init", "container-path"}, + } + + for testName, testCase := range testCases { + for outdatedHelperImage, expectedCommand := range outdatedHelperImageValues { + t.Run(fmt.Sprintf("%s-outdated-helper-image-is-%v", testName, outdatedHelperImage), func(t *testing.T) { + m, cClient := getCacheContainerManager() + m.outdatedHelperImage = outdatedHelperImage + + defer cClient.AssertExpectations(t) + + configMatcher := mock.MatchedBy(func(config *container.Config) bool { + if config.Image != "helper-image" { + return false + } + + if len(config.Cmd) != len(expectedCommand) { + return false + } + + return config.Cmd[0] == expectedCommand[0] + }) + + cClient.On("LabelContainer", configMatcher, "cache", fmt.Sprintf("cache.dir=%s", containerPath)). + Once() + + cClient.On("ContainerCreate", mock.Anything, configMatcher, mock.Anything, mock.Anything, containerName). + Return(testCase.createResult, testCase.createError). + Once() + + if testCase.createError == nil { + cClient.On("ContainerStart", mock.Anything, testCase.containerID, mock.Anything). + Return(testCase.startError). + Once() + + if testCase.startError == nil { + cClient.On("WaitForContainer", testCase.containerID). + Return(testCase.waitForContainerError). + Once() + } + } + + require.Empty(t, m.failedContainerIDs, "Initial list of failed containers should be empty") + + containerID, err := m.Create(containerName, containerPath) + assert.Equal(t, err, testCase.expectedError) + assert.Equal(t, testCase.expectedContainerID, containerID) + + if testCase.expectedFailedContainerID != "" { + assert.Len(t, m.failedContainerIDs, 1) + assert.Contains( + t, m.failedContainerIDs, testCase.expectedFailedContainerID, + "List of failed container should be updated with %s", testCase.expectedContainerID, + ) + } else { + assert.Empty(t, m.failedContainerIDs, "List of failed containers should not be updated") + } + }) + } + } +} + +func TestCacheContainerManager_Cleanup(t *testing.T) { + ctx := context.Background() + + containerClientMock := new(mockContainerClient) + defer containerClientMock.AssertExpectations(t) + + loggerMock := new(mockDebugLogger) + defer loggerMock.AssertExpectations(t) + + containerClientMock.On("RemoveContainer", ctx, "failed-container-1"). + Return(nil). + Once() + containerClientMock.On("RemoveContainer", ctx, "container-1-with-remove-error"). + Return(errors.New("test-error")). + Once() + containerClientMock.On("RemoveContainer", ctx, "container-1"). + Return(nil). + Once() + + loggerMock.On("Debugln", "Error while removing the container: test-error"). + Once() + + m := &cacheContainerManager{ + containerClient: containerClientMock, + logger: loggerMock, + failedContainerIDs: []string{"failed-container-1", "container-1-with-remove-error"}, + } + + done := m.Cleanup(ctx, []string{"container-1"}) + + <-done +} diff --git a/executors/docker/internal/volumes/manager.go b/executors/docker/internal/volumes/manager.go new file mode 100644 index 0000000000000000000000000000000000000000..4a3a09e009c211455a4ec566fd4dc7ffd6d468f5 --- /dev/null +++ b/executors/docker/internal/volumes/manager.go @@ -0,0 +1,185 @@ +package volumes + +import ( + "context" + "errors" + "fmt" + "path" + "path/filepath" + "strings" +) + +var ErrCacheVolumesDisabled = errors.New("cache volumes feature disabled") + +type Manager interface { + Create(volume string) error + CreateTemporary(containerPath string) error + Binds() []string + ContainerIDs() []string + Cleanup(ctx context.Context) chan bool +} + +type ManagerConfig struct { + CacheDir string + BaseContainerPath string + UniqueName string + DisableCache bool +} + +type manager struct { + config ManagerConfig + logger debugLogger + + cacheContainersManager CacheContainersManager + + volumeBindings []string + cacheContainerIDs []string + tmpContainerIDs []string + + managedVolumes pathList +} + +func NewManager(logger debugLogger, ccManager CacheContainersManager, config ManagerConfig) Manager { + return &manager{ + config: config, + logger: logger, + cacheContainersManager: ccManager, + volumeBindings: make([]string, 0), + cacheContainerIDs: make([]string, 0), + tmpContainerIDs: make([]string, 0), + managedVolumes: pathList{}, + } +} + +func (m *manager) Create(volume string) error { + if len(volume) < 1 { + return nil + } + + hostVolume := strings.SplitN(volume, ":", 2) + + var err error + switch len(hostVolume) { + case 2: + err = m.addHostVolume(hostVolume[0], hostVolume[1]) + case 1: + err = m.addCacheVolume(hostVolume[0]) + } + + return err +} + +func (m *manager) addHostVolume(hostPath string, containerPath string) error { + containerPath = m.getAbsoluteContainerPath(containerPath) + + err := m.managedVolumes.Add(containerPath) + if err != nil { + return err + } + + m.appendVolumeBind(hostPath, containerPath) + + return nil +} + +func (m *manager) getAbsoluteContainerPath(dir string) string { + if path.IsAbs(dir) { + return dir + } + + return path.Join(m.config.BaseContainerPath, dir) +} + +func (m *manager) appendVolumeBind(hostPath string, containerPath string) { + m.logger.Debugln(fmt.Sprintf("Using host-based %q for %q...", hostPath, containerPath)) + + bindDefinition := fmt.Sprintf("%v:%v", filepath.ToSlash(hostPath), containerPath) + m.volumeBindings = append(m.volumeBindings, bindDefinition) +} + +func (m *manager) addCacheVolume(containerPath string) error { + // disable cache for automatic container cache, + // but leave it for host volumes (they are shared on purpose) + if m.config.DisableCache { + m.logger.Debugln("Cache containers feature is disabled") + + return ErrCacheVolumesDisabled + } + + if m.config.CacheDir != "" { + return m.createHostBasedCacheVolume(containerPath) + } + + _, err := m.createContainerBasedCacheVolume(containerPath) + + return err +} + +func (m *manager) createHostBasedCacheVolume(containerPath string) error { + containerPath = m.getAbsoluteContainerPath(containerPath) + + err := m.managedVolumes.Add(containerPath) + if err != nil { + return err + } + + hostPath := fmt.Sprintf("%s/%s/%s", m.config.CacheDir, m.config.UniqueName, hashContainerPath(containerPath)) + hostPath, err = filepath.Abs(hostPath) + if err != nil { + return err + } + + m.appendVolumeBind(hostPath, containerPath) + + return nil +} + +func (m *manager) createContainerBasedCacheVolume(containerPath string) (string, error) { + containerPath = m.getAbsoluteContainerPath(containerPath) + + err := m.managedVolumes.Add(containerPath) + if err != nil { + return "", err + } + + containerName := fmt.Sprintf("%s-cache-%s", m.config.UniqueName, hashContainerPath(containerPath)) + containerID := m.cacheContainersManager.FindOrCleanExisting(containerName, containerPath) + + // create new cache container for that project + if containerID == "" { + var err error + + containerID, err = m.cacheContainersManager.Create(containerName, containerPath) + if err != nil { + return "", err + } + } + + m.logger.Debugln(fmt.Sprintf("Using container %q as cache %q...", containerID, containerPath)) + m.cacheContainerIDs = append(m.cacheContainerIDs, containerID) + + return containerID, nil +} + +func (m *manager) CreateTemporary(containerPath string) error { + id, err := m.createContainerBasedCacheVolume(containerPath) + if err != nil { + return err + } + + m.tmpContainerIDs = append(m.tmpContainerIDs, id) + + return nil +} + +func (m *manager) Binds() []string { + return m.volumeBindings +} + +func (m *manager) ContainerIDs() []string { + return m.cacheContainerIDs +} + +func (m *manager) Cleanup(ctx context.Context) chan bool { + return m.cacheContainersManager.Cleanup(ctx, m.tmpContainerIDs) +} diff --git a/executors/docker/internal/volumes/manager_test.go b/executors/docker/internal/volumes/manager_test.go new file mode 100644 index 0000000000000000000000000000000000000000..fad42cc54cb074c01456fbd20a41aa454b578ee3 --- /dev/null +++ b/executors/docker/internal/volumes/manager_test.go @@ -0,0 +1,476 @@ +package volumes + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func newDebugLoggerMock() *mockDebugLogger { + loggerMock := new(mockDebugLogger) + loggerMock.On("Debugln", mock.Anything) + + return loggerMock +} + +func TestErrVolumeAlreadyDefined(t *testing.T) { + err := NewErrVolumeAlreadyDefined("test-path") + assert.EqualError(t, err, `volume for container path "test-path" is already defined`) +} + +func TestNewDefaultManager(t *testing.T) { + logger := newDebugLoggerMock() + + m := NewManager(logger, nil, ManagerConfig{}) + assert.IsType(t, &manager{}, m) +} + +func newDefaultManager(config ManagerConfig) *manager { + m := &manager{ + logger: newDebugLoggerMock(), + config: config, + managedVolumes: make(map[string]bool, 0), + } + + return m +} + +func addCacheContainerManager(manager *manager) *MockCacheContainersManager { + containerManager := new(MockCacheContainersManager) + + manager.cacheContainersManager = containerManager + + return containerManager +} + +func TestDefaultManager_CreateUserVolumes_HostVolume(t *testing.T) { + testCases := map[string]struct { + volume string + baseContainerPath string + expectedBinding []string + expectedError error + }{ + "no volumes specified": { + volume: "", + expectedBinding: []string{"/host:/duplicated"}, + }, + "volume with absolute path": { + volume: "/host:/volume", + expectedBinding: []string{"/host:/duplicated", "/host:/volume"}, + }, + "volume with absolute path and with baseContainerPath specified": { + volume: "/host:/volume", + baseContainerPath: "/builds", + expectedBinding: []string{"/host:/duplicated", "/host:/volume"}, + }, + "volume without absolute path and without baseContainerPath specified": { + volume: "/host:volume", + expectedBinding: []string{"/host:/duplicated", "/host:volume"}, + }, + "volume without absolute path and with baseContainerPath specified": { + volume: "/host:volume", + baseContainerPath: "/builds/project", + expectedBinding: []string{"/host:/duplicated", "/host:/builds/project/volume"}, + }, + "duplicated volume specification": { + volume: "/host/new:/duplicated", + expectedBinding: []string{"/host:/duplicated"}, + expectedError: NewErrVolumeAlreadyDefined("/duplicated"), + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + config := ManagerConfig{ + BaseContainerPath: testCase.baseContainerPath, + } + + m := newDefaultManager(config) + + err := m.Create("/host:/duplicated") + require.NoError(t, err) + + err = m.Create(testCase.volume) + assert.Equal(t, testCase.expectedError, err) + assert.Equal(t, testCase.expectedBinding, m.volumeBindings) + }) + } +} + +func TestDefaultManager_CreateUserVolumes_CacheVolume_Disabled(t *testing.T) { + expectedBinding := []string{"/host:/duplicated"} + + testCases := map[string]struct { + volume string + baseContainerPath string + + expectedCacheContainerIDs []string + expectedConfigVolume string + expectedError error + }{ + "no volumes specified": { + volume: "", + }, + "volume with absolute path, without baseContainerPath and with disableCache": { + volume: "/volume", + baseContainerPath: "", + expectedError: ErrCacheVolumesDisabled, + }, + "volume with absolute path, with baseContainerPath and with disableCache": { + volume: "/volume", + baseContainerPath: "/builds/project", + expectedError: ErrCacheVolumesDisabled, + }, + "volume without absolute path, without baseContainerPath and with disableCache": { + volume: "volume", + expectedError: ErrCacheVolumesDisabled, + }, + "volume without absolute path, with baseContainerPath and with disableCache": { + volume: "volume", + baseContainerPath: "/builds/project", + expectedError: ErrCacheVolumesDisabled, + }, + "duplicated volume definition": { + volume: "/duplicated", + baseContainerPath: "", + expectedError: ErrCacheVolumesDisabled, + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + config := ManagerConfig{ + BaseContainerPath: testCase.baseContainerPath, + DisableCache: true, + } + + m := newDefaultManager(config) + + err := m.Create("/host:/duplicated") + require.NoError(t, err) + + err = m.Create(testCase.volume) + assert.Equal(t, testCase.expectedError, err) + assert.Equal(t, expectedBinding, m.volumeBindings) + }) + } +} + +func TestDefaultManager_CreateUserVolumes_CacheVolume_HostBased(t *testing.T) { + testCases := map[string]struct { + volume string + baseContainerPath string + cacheDir string + uniqueName string + + expectedBinding []string + expectedCacheContainerIDs []string + expectedConfigVolume string + expectedError error + }{ + "volume with absolute path, without baseContainerPath and with cacheDir": { + volume: "/volume", + cacheDir: "/cache", + uniqueName: "uniq", + expectedBinding: []string{"/host:/duplicated", "/cache/uniq/14331bf18c8e434c4b3f48a8c5cc79aa:/volume"}, + }, + "volume with absolute path, with baseContainerPath and with cacheDir": { + volume: "/volume", + baseContainerPath: "/builds/project", + cacheDir: "/cache", + uniqueName: "uniq", + expectedBinding: []string{"/host:/duplicated", "/cache/uniq/14331bf18c8e434c4b3f48a8c5cc79aa:/volume"}, + }, + "volume without absolute path, without baseContainerPath and with cacheDir": { + volume: "volume", + cacheDir: "/cache", + uniqueName: "uniq", + expectedBinding: []string{"/host:/duplicated", "/cache/uniq/210ab9e731c9c36c2c38db15c28a8d1c:volume"}, + }, + "volume without absolute path, with baseContainerPath and with cacheDir": { + volume: "volume", + baseContainerPath: "/builds/project", + cacheDir: "/cache", + uniqueName: "uniq", + expectedBinding: []string{"/host:/duplicated", "/cache/uniq/f69aef9fb01e88e6213362a04877452d:/builds/project/volume"}, + }, + "duplicated volume definition": { + volume: "/duplicated", + cacheDir: "/cache", + uniqueName: "uniq", + expectedBinding: []string{"/host:/duplicated"}, + expectedError: NewErrVolumeAlreadyDefined("/duplicated"), + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + config := ManagerConfig{ + BaseContainerPath: testCase.baseContainerPath, + DisableCache: false, + CacheDir: testCase.cacheDir, + UniqueName: testCase.uniqueName, + } + + m := newDefaultManager(config) + + err := m.Create("/host:/duplicated") + require.NoError(t, err) + + err = m.Create(testCase.volume) + assert.Equal(t, testCase.expectedError, err) + assert.Equal(t, testCase.expectedBinding, m.volumeBindings) + }) + } +} + +func TestDefaultManager_CreateUserVolumes_CacheVolume_ContainerBased(t *testing.T) { + testCases := map[string]struct { + volume string + baseContainerPath string + uniqueName string + expectedContainerName string + expectedContainerPath string + existingContainerID string + newContainerID string + expectedCacheContainerID string + expectedError error + }{ + "volume with absolute path, without baseContainerPath and with existing container": { + volume: "/volume", + baseContainerPath: "", + uniqueName: "uniq", + expectedContainerName: "uniq-cache-14331bf18c8e434c4b3f48a8c5cc79aa", + expectedContainerPath: "/volume", + existingContainerID: "existingContainerID", + expectedCacheContainerID: "existingContainerID", + }, + "volume with absolute path, without baseContainerPath and with new container": { + volume: "/volume", + baseContainerPath: "", + uniqueName: "uniq", + expectedContainerName: "uniq-cache-14331bf18c8e434c4b3f48a8c5cc79aa", + expectedContainerPath: "/volume", + existingContainerID: "", + newContainerID: "newContainerID", + expectedCacheContainerID: "newContainerID", + }, + "volume without absolute path, without baseContainerPath and with existing container": { + volume: "volume", + baseContainerPath: "", + uniqueName: "uniq", + expectedContainerName: "uniq-cache-210ab9e731c9c36c2c38db15c28a8d1c", + expectedContainerPath: "volume", + existingContainerID: "existingContainerID", + expectedCacheContainerID: "existingContainerID", + }, + "volume without absolute path, without baseContainerPath and with new container": { + volume: "volume", + baseContainerPath: "", + uniqueName: "uniq", + expectedContainerName: "uniq-cache-210ab9e731c9c36c2c38db15c28a8d1c", + expectedContainerPath: "volume", + existingContainerID: "", + newContainerID: "newContainerID", + expectedCacheContainerID: "newContainerID", + }, + "volume without absolute path, with baseContainerPath and with existing container": { + volume: "volume", + baseContainerPath: "/builds/project", + uniqueName: "uniq", + expectedContainerName: "uniq-cache-f69aef9fb01e88e6213362a04877452d", + expectedContainerPath: "/builds/project/volume", + existingContainerID: "existingContainerID", + expectedCacheContainerID: "existingContainerID", + }, + "volume without absolute path, with baseContainerPath and with new container": { + volume: "volume", + baseContainerPath: "/builds/project", + uniqueName: "uniq", + expectedContainerName: "uniq-cache-f69aef9fb01e88e6213362a04877452d", + expectedContainerPath: "/builds/project/volume", + existingContainerID: "", + newContainerID: "newContainerID", + expectedCacheContainerID: "newContainerID", + }, + "duplicated volume definition": { + volume: "/duplicated", + uniqueName: "uniq", + expectedError: NewErrVolumeAlreadyDefined("/duplicated"), + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + config := ManagerConfig{ + BaseContainerPath: testCase.baseContainerPath, + UniqueName: testCase.uniqueName, + DisableCache: false, + } + + m := newDefaultManager(config) + containerManager := addCacheContainerManager(m) + + defer containerManager.AssertExpectations(t) + + err := m.Create("/host:/duplicated") + require.NoError(t, err) + + if testCase.volume != "/duplicated" { + containerManager.On("FindOrCleanExisting", testCase.expectedContainerName, testCase.expectedContainerPath). + Return(testCase.existingContainerID). + Once() + + if testCase.newContainerID != "" { + containerManager.On("Create", testCase.expectedContainerName, testCase.expectedContainerPath). + Return(testCase.newContainerID, nil). + Once() + } + } + + err = m.Create(testCase.volume) + assert.Equal(t, testCase.expectedError, err) + + if testCase.expectedCacheContainerID != "" { + assert.Contains(t, m.cacheContainerIDs, testCase.expectedCacheContainerID) + } + }) + } +} + +func TestDefaultManager_CreateUserVolumes_CacheVolume_ContainerBased_WithError(t *testing.T) { + config := ManagerConfig{ + BaseContainerPath: "/builds/project", + UniqueName: "unique", + } + + m := newDefaultManager(config) + containerManager := addCacheContainerManager(m) + + defer containerManager.AssertExpectations(t) + + containerManager.On("FindOrCleanExisting", "unique-cache-f69aef9fb01e88e6213362a04877452d", "/builds/project/volume"). + Return(""). + Once() + + containerManager.On("Create", "unique-cache-f69aef9fb01e88e6213362a04877452d", "/builds/project/volume"). + Return("", errors.New("test error")). + Once() + + err := m.Create("volume") + assert.Error(t, err) +} + +func TestDefaultManager_CreateTemporary(t *testing.T) { + testCases := map[string]struct { + volume string + newContainerID string + containerCreateError error + expectedContainerName string + expectedContainerPath string + expectedCacheContainerID string + expectedTmpContainerID string + expectedError error + }{ + "volume created": { + volume: "volume", + newContainerID: "newContainerID", + expectedContainerName: "uniq-cache-f69aef9fb01e88e6213362a04877452d", + expectedContainerPath: "/builds/project/volume", + expectedCacheContainerID: "newContainerID", + expectedTmpContainerID: "newContainerID", + }, + "cache container creation error": { + volume: "volume", + newContainerID: "", + containerCreateError: errors.New("test-error"), + expectedError: errors.New("test-error"), + }, + "duplicated volume definition": { + volume: "/duplicated", + expectedError: NewErrVolumeAlreadyDefined("/duplicated"), + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + config := ManagerConfig{ + BaseContainerPath: "/builds/project", + UniqueName: "unique", + } + + m := newDefaultManager(config) + containerManager := addCacheContainerManager(m) + + defer containerManager.AssertExpectations(t) + + err := m.Create("/host:/duplicated") + require.NoError(t, err) + + if testCase.volume != "/duplicated" { + containerManager.On("FindOrCleanExisting", "unique-cache-f69aef9fb01e88e6213362a04877452d", "/builds/project/volume"). + Return(""). + Once() + + containerManager.On("Create", "unique-cache-f69aef9fb01e88e6213362a04877452d", "/builds/project/volume"). + Return(testCase.newContainerID, testCase.containerCreateError). + Once() + } + + err = m.CreateTemporary(testCase.volume) + assert.Equal(t, testCase.expectedError, err) + + if testCase.expectedCacheContainerID != "" { + assert.Contains(t, m.cacheContainerIDs, testCase.expectedCacheContainerID) + } + + if testCase.expectedTmpContainerID != "" { + assert.Contains(t, m.tmpContainerIDs, testCase.expectedTmpContainerID) + } + }) + } +} + +func TestDefaultManager_Binds(t *testing.T) { + expectedElements := []string{"element1", "element2"} + m := &manager{ + volumeBindings: expectedElements, + } + + assert.Equal(t, expectedElements, m.Binds()) +} + +func TestDefaultManager_ContainerIDs(t *testing.T) { + expectedElements := []string{"element1", "element2"} + m := &manager{ + cacheContainerIDs: expectedElements, + } + + assert.Equal(t, expectedElements, m.ContainerIDs()) +} + +func TestDefaultManager_Cleanup(t *testing.T) { + ccManager := new(MockCacheContainersManager) + defer ccManager.AssertExpectations(t) + + doneCh := make(chan bool, 1) + + ccManager.On("Cleanup", mock.Anything, []string{"container-1"}). + Run(func(_ mock.Arguments) { + close(doneCh) + }). + Return(doneCh). + Once() + + m := &manager{ + cacheContainersManager: ccManager, + tmpContainerIDs: []string{"container-1"}, + } + + done := m.Cleanup(context.Background()) + <-done +} diff --git a/executors/docker/internal/volumes/mock_CacheContainersManager.go b/executors/docker/internal/volumes/mock_CacheContainersManager.go new file mode 100644 index 0000000000000000000000000000000000000000..1dbde192322b16c3883b2b7a15e3f2eb3567b81d --- /dev/null +++ b/executors/docker/internal/volumes/mock_CacheContainersManager.go @@ -0,0 +1,62 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package volumes + +import context "context" +import mock "github.com/stretchr/testify/mock" + +// MockCacheContainersManager is an autogenerated mock type for the CacheContainersManager type +type MockCacheContainersManager struct { + mock.Mock +} + +// Cleanup provides a mock function with given fields: ctx, ids +func (_m *MockCacheContainersManager) Cleanup(ctx context.Context, ids []string) chan bool { + ret := _m.Called(ctx, ids) + + var r0 chan bool + if rf, ok := ret.Get(0).(func(context.Context, []string) chan bool); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan bool) + } + } + + return r0 +} + +// Create provides a mock function with given fields: containerName, containerPath +func (_m *MockCacheContainersManager) Create(containerName string, containerPath string) (string, error) { + ret := _m.Called(containerName, containerPath) + + var r0 string + if rf, ok := ret.Get(0).(func(string, string) string); ok { + r0 = rf(containerName, containerPath) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(containerName, containerPath) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindOrCleanExisting provides a mock function with given fields: containerName, containerPath +func (_m *MockCacheContainersManager) FindOrCleanExisting(containerName string, containerPath string) string { + ret := _m.Called(containerName, containerPath) + + var r0 string + if rf, ok := ret.Get(0).(func(string, string) string); ok { + r0 = rf(containerName, containerPath) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} diff --git a/executors/docker/internal/volumes/mock_Manager.go b/executors/docker/internal/volumes/mock_Manager.go new file mode 100644 index 0000000000000000000000000000000000000000..9706cccb5470964bf0f6ac8c9e2d8b975453f0ac --- /dev/null +++ b/executors/docker/internal/volumes/mock_Manager.go @@ -0,0 +1,87 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package volumes + +import context "context" +import mock "github.com/stretchr/testify/mock" + +// MockManager is an autogenerated mock type for the Manager type +type MockManager struct { + mock.Mock +} + +// Binds provides a mock function with given fields: +func (_m *MockManager) Binds() []string { + ret := _m.Called() + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + +// Cleanup provides a mock function with given fields: ctx +func (_m *MockManager) Cleanup(ctx context.Context) chan bool { + ret := _m.Called(ctx) + + var r0 chan bool + if rf, ok := ret.Get(0).(func(context.Context) chan bool); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan bool) + } + } + + return r0 +} + +// ContainerIDs provides a mock function with given fields: +func (_m *MockManager) ContainerIDs() []string { + ret := _m.Called() + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + +// Create provides a mock function with given fields: volume +func (_m *MockManager) Create(volume string) error { + ret := _m.Called(volume) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(volume) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// CreateTemporary provides a mock function with given fields: containerPath +func (_m *MockManager) CreateTemporary(containerPath string) error { + ret := _m.Called(containerPath) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(containerPath) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/executors/docker/internal/volumes/mock_containerClient.go b/executors/docker/internal/volumes/mock_containerClient.go new file mode 100644 index 0000000000000000000000000000000000000000..1b741a55d8058bec323d9b2ce7a47ec0dbad4f7f --- /dev/null +++ b/executors/docker/internal/volumes/mock_containerClient.go @@ -0,0 +1,355 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package volumes + +import container "github.com/docker/docker/api/types/container" +import context "context" +import io "io" +import mock "github.com/stretchr/testify/mock" +import network "github.com/docker/docker/api/types/network" +import types "github.com/docker/docker/api/types" + +// mockContainerClient is an autogenerated mock type for the containerClient type +type mockContainerClient struct { + mock.Mock +} + +// Close provides a mock function with given fields: +func (_m *mockContainerClient) Close() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ContainerAttach provides a mock function with given fields: ctx, _a1, options +func (_m *mockContainerClient) ContainerAttach(ctx context.Context, _a1 string, options types.ContainerAttachOptions) (types.HijackedResponse, error) { + ret := _m.Called(ctx, _a1, options) + + var r0 types.HijackedResponse + if rf, ok := ret.Get(0).(func(context.Context, string, types.ContainerAttachOptions) types.HijackedResponse); ok { + r0 = rf(ctx, _a1, options) + } else { + r0 = ret.Get(0).(types.HijackedResponse) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, types.ContainerAttachOptions) error); ok { + r1 = rf(ctx, _a1, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ContainerCreate provides a mock function with given fields: ctx, config, hostConfig, networkingConfig, containerName +func (_m *mockContainerClient) ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, containerName string) (container.ContainerCreateCreatedBody, error) { + ret := _m.Called(ctx, config, hostConfig, networkingConfig, containerName) + + var r0 container.ContainerCreateCreatedBody + if rf, ok := ret.Get(0).(func(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, string) container.ContainerCreateCreatedBody); ok { + r0 = rf(ctx, config, hostConfig, networkingConfig, containerName) + } else { + r0 = ret.Get(0).(container.ContainerCreateCreatedBody) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, string) error); ok { + r1 = rf(ctx, config, hostConfig, networkingConfig, containerName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ContainerExecAttach provides a mock function with given fields: ctx, execID, config +func (_m *mockContainerClient) ContainerExecAttach(ctx context.Context, execID string, config types.ExecStartCheck) (types.HijackedResponse, error) { + ret := _m.Called(ctx, execID, config) + + var r0 types.HijackedResponse + if rf, ok := ret.Get(0).(func(context.Context, string, types.ExecStartCheck) types.HijackedResponse); ok { + r0 = rf(ctx, execID, config) + } else { + r0 = ret.Get(0).(types.HijackedResponse) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, types.ExecStartCheck) error); ok { + r1 = rf(ctx, execID, config) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ContainerExecCreate provides a mock function with given fields: ctx, _a1, config +func (_m *mockContainerClient) ContainerExecCreate(ctx context.Context, _a1 string, config types.ExecConfig) (types.IDResponse, error) { + ret := _m.Called(ctx, _a1, config) + + var r0 types.IDResponse + if rf, ok := ret.Get(0).(func(context.Context, string, types.ExecConfig) types.IDResponse); ok { + r0 = rf(ctx, _a1, config) + } else { + r0 = ret.Get(0).(types.IDResponse) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, types.ExecConfig) error); ok { + r1 = rf(ctx, _a1, config) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ContainerInspect provides a mock function with given fields: ctx, containerID +func (_m *mockContainerClient) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) { + ret := _m.Called(ctx, containerID) + + var r0 types.ContainerJSON + if rf, ok := ret.Get(0).(func(context.Context, string) types.ContainerJSON); ok { + r0 = rf(ctx, containerID) + } else { + r0 = ret.Get(0).(types.ContainerJSON) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, containerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ContainerKill provides a mock function with given fields: ctx, containerID, signal +func (_m *mockContainerClient) ContainerKill(ctx context.Context, containerID string, signal string) error { + ret := _m.Called(ctx, containerID, signal) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, containerID, signal) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ContainerLogs provides a mock function with given fields: ctx, _a1, options +func (_m *mockContainerClient) ContainerLogs(ctx context.Context, _a1 string, options types.ContainerLogsOptions) (io.ReadCloser, error) { + ret := _m.Called(ctx, _a1, options) + + var r0 io.ReadCloser + if rf, ok := ret.Get(0).(func(context.Context, string, types.ContainerLogsOptions) io.ReadCloser); ok { + r0 = rf(ctx, _a1, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(io.ReadCloser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, types.ContainerLogsOptions) error); ok { + r1 = rf(ctx, _a1, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ContainerRemove provides a mock function with given fields: ctx, containerID, options +func (_m *mockContainerClient) ContainerRemove(ctx context.Context, containerID string, options types.ContainerRemoveOptions) error { + ret := _m.Called(ctx, containerID, options) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, types.ContainerRemoveOptions) error); ok { + r0 = rf(ctx, containerID, options) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ContainerStart provides a mock function with given fields: ctx, containerID, options +func (_m *mockContainerClient) ContainerStart(ctx context.Context, containerID string, options types.ContainerStartOptions) error { + ret := _m.Called(ctx, containerID, options) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, types.ContainerStartOptions) error); ok { + r0 = rf(ctx, containerID, options) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ImageImportBlocking provides a mock function with given fields: ctx, source, ref, options +func (_m *mockContainerClient) ImageImportBlocking(ctx context.Context, source types.ImageImportSource, ref string, options types.ImageImportOptions) error { + ret := _m.Called(ctx, source, ref, options) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, types.ImageImportSource, string, types.ImageImportOptions) error); ok { + r0 = rf(ctx, source, ref, options) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ImageInspectWithRaw provides a mock function with given fields: ctx, imageID +func (_m *mockContainerClient) ImageInspectWithRaw(ctx context.Context, imageID string) (types.ImageInspect, []byte, error) { + ret := _m.Called(ctx, imageID) + + var r0 types.ImageInspect + if rf, ok := ret.Get(0).(func(context.Context, string) types.ImageInspect); ok { + r0 = rf(ctx, imageID) + } else { + r0 = ret.Get(0).(types.ImageInspect) + } + + var r1 []byte + if rf, ok := ret.Get(1).(func(context.Context, string) []byte); ok { + r1 = rf(ctx, imageID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]byte) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, string) error); ok { + r2 = rf(ctx, imageID) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ImagePullBlocking provides a mock function with given fields: ctx, ref, options +func (_m *mockContainerClient) ImagePullBlocking(ctx context.Context, ref string, options types.ImagePullOptions) error { + ret := _m.Called(ctx, ref, options) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, types.ImagePullOptions) error); ok { + r0 = rf(ctx, ref, options) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Info provides a mock function with given fields: ctx +func (_m *mockContainerClient) Info(ctx context.Context) (types.Info, error) { + ret := _m.Called(ctx) + + var r0 types.Info + if rf, ok := ret.Get(0).(func(context.Context) types.Info); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(types.Info) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// LabelContainer provides a mock function with given fields: _a0, containerType, otherLabels +func (_m *mockContainerClient) LabelContainer(_a0 *container.Config, containerType string, otherLabels ...string) { + _va := make([]interface{}, len(otherLabels)) + for _i := range otherLabels { + _va[_i] = otherLabels[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, containerType) + _ca = append(_ca, _va...) + _m.Called(_ca...) +} + +// NetworkDisconnect provides a mock function with given fields: ctx, networkID, containerID, force +func (_m *mockContainerClient) NetworkDisconnect(ctx context.Context, networkID string, containerID string, force bool) error { + ret := _m.Called(ctx, networkID, containerID, force) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, bool) error); ok { + r0 = rf(ctx, networkID, containerID, force) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NetworkList provides a mock function with given fields: ctx, options +func (_m *mockContainerClient) NetworkList(ctx context.Context, options types.NetworkListOptions) ([]types.NetworkResource, error) { + ret := _m.Called(ctx, options) + + var r0 []types.NetworkResource + if rf, ok := ret.Get(0).(func(context.Context, types.NetworkListOptions) []types.NetworkResource); ok { + r0 = rf(ctx, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]types.NetworkResource) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, types.NetworkListOptions) error); ok { + r1 = rf(ctx, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RemoveContainer provides a mock function with given fields: ctx, id +func (_m *mockContainerClient) RemoveContainer(ctx context.Context, id string) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// WaitForContainer provides a mock function with given fields: id +func (_m *mockContainerClient) WaitForContainer(id string) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/executors/docker/internal/volumes/mock_debugLogger.go b/executors/docker/internal/volumes/mock_debugLogger.go new file mode 100644 index 0000000000000000000000000000000000000000..1e9157c69846d76c2cbf2867167f503295847b0e --- /dev/null +++ b/executors/docker/internal/volumes/mock_debugLogger.go @@ -0,0 +1,17 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package volumes + +import mock "github.com/stretchr/testify/mock" + +// mockDebugLogger is an autogenerated mock type for the debugLogger type +type mockDebugLogger struct { + mock.Mock +} + +// Debugln provides a mock function with given fields: args +func (_m *mockDebugLogger) Debugln(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} diff --git a/executors/docker/internal/volumes/utils.go b/executors/docker/internal/volumes/utils.go new file mode 100644 index 0000000000000000000000000000000000000000..139f95d07480c2065510c1020fa1149436bcdc33 --- /dev/null +++ b/executors/docker/internal/volumes/utils.go @@ -0,0 +1,67 @@ +package volumes + +import ( + "crypto/md5" + "fmt" + "path" + "strings" +) + +type debugLogger interface { + Debugln(args ...interface{}) +} + +func IsHostMountedVolume(dir string, volumes ...string) bool { + for _, volume := range volumes { + hostVolume := strings.Split(volume, ":") + + if len(hostVolume) < 2 { + continue + } + + if isParentOf(path.Clean(hostVolume[1]), path.Clean(dir)) { + return true + } + } + return false +} + +func isParentOf(parent string, dir string) bool { + for dir != "/" && dir != "." { + if dir == parent { + return true + } + dir = path.Dir(dir) + } + return false +} + +func hashContainerPath(containerPath string) string { + return fmt.Sprintf("%x", md5.Sum([]byte(containerPath))) +} + +type ErrVolumeAlreadyDefined struct { + containerPath string +} + +func (e *ErrVolumeAlreadyDefined) Error() string { + return fmt.Sprintf("volume for container path %q is already defined", e.containerPath) +} + +func NewErrVolumeAlreadyDefined(containerPath string) *ErrVolumeAlreadyDefined { + return &ErrVolumeAlreadyDefined{ + containerPath: containerPath, + } +} + +type pathList map[string]bool + +func (m pathList) Add(containerPath string) error { + if m[containerPath] { + return NewErrVolumeAlreadyDefined(containerPath) + } + + m[containerPath] = true + + return nil +} diff --git a/executors/docker/internal/volumes/utils_test.go b/executors/docker/internal/volumes/utils_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d8fd21348f433b4edbe4962fce4f0ea0515aa462 --- /dev/null +++ b/executors/docker/internal/volumes/utils_test.go @@ -0,0 +1,73 @@ +package volumes + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsHostMountedVolume(t *testing.T) { + testCases := map[string]struct { + dir string + volumes []string + expectedResult bool + }{ + "empty volumes": { + dir: "/test/to/checked/dir", + volumes: []string{}, + expectedResult: false, + }, + "no host volumes": { + dir: "/test/to/checked/dir", + volumes: []string{"/tests/to"}, + expectedResult: false, + }, + "dir not within volumes": { + dir: "/test/to/checked/dir", + volumes: []string{"/host:/root"}, + expectedResult: false, + }, + "dir within volumes": { + dir: "/test/to/checked/dir", + volumes: []string{"/host:/test/to"}, + expectedResult: true, + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + result := IsHostMountedVolume(testCase.dir, testCase.volumes...) + assert.Equal(t, testCase.expectedResult, result) + }) + } +} + +func TestManagedList_Add(t *testing.T) { + tests := map[string]struct { + path string + expectedError error + }{ + "add non-duplicated path": { + path: "/new/path", + }, + "add duplicated path": { + path: "/duplicate", + expectedError: NewErrVolumeAlreadyDefined("/duplicate"), + }, + "add child path": { + path: "/duplicate/child", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + m := pathList{} + err := m.Add("/duplicate") + require.NoError(t, err) + + err = m.Add(test.path) + assert.Equal(t, test.expectedError, err) + }) + } +} diff --git a/executors/docker/volume_manager_adapter.go b/executors/docker/volume_manager_adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..66bb0bc9c0721ef951fab54488bc06121fec8a91 --- /dev/null +++ b/executors/docker/volume_manager_adapter.go @@ -0,0 +1,75 @@ +package docker + +import ( + "context" + + "github.com/docker/docker/api/types/container" + + "gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/volumes" + docker_helpers "gitlab.com/gitlab-org/gitlab-runner/helpers/docker" + "gitlab.com/gitlab-org/gitlab-runner/helpers/featureflags" +) + +type volumesManagerAdapter struct { + docker_helpers.Client + + e *executor +} + +func (a *volumesManagerAdapter) LabelContainer(container *container.Config, containerType string, otherLabels ...string) { + container.Labels = a.e.getLabels(containerType, otherLabels...) +} + +func (a *volumesManagerAdapter) WaitForContainer(id string) error { + return a.e.waitForContainer(a.e.Context, id) +} + +func (a *volumesManagerAdapter) RemoveContainer(ctx context.Context, id string) error { + return a.e.removeContainer(ctx, id) +} + +func (e *executor) checkOutdatedHelperImage() bool { + return !e.Build.IsFeatureFlagOn(featureflags.DockerHelperImageV2) && e.Config.Docker.HelperImage != "" +} + +var createVolumesManager = func(e *executor) (volumes.Manager, error) { + adapter := &volumesManagerAdapter{ + Client: e.client, + e: e, + } + + helperImage, err := e.getPrebuiltImage() + if err != nil { + return nil, err + } + + ccManager := volumes.NewCacheContainerManager( + e.Context, + &e.BuildLogger, + adapter, + helperImage, + e.checkOutdatedHelperImage(), + ) + + config := volumes.ManagerConfig{ + CacheDir: e.Config.Docker.CacheDir, + BaseContainerPath: e.Build.FullProjectDir(), + UniqueName: e.Build.ProjectUniqueName(), + DisableCache: e.Config.Docker.DisableCache, + } + + volumesManager := volumes.NewManager(&e.BuildLogger, ccManager, config) + + return volumesManager, nil +} + +func (e *executor) createVolumesManager() error { + vm, err := createVolumesManager(e) + if err != nil { + return err + } + + e.volumesManager = vm + + return nil +} diff --git a/executors/executor_abstract.go b/executors/executor_abstract.go index a85e46c3ef56e589e9e8a56ad1f9b3f77757b93f..4659313c21895bc184e969902876b53dfafe1bee 100644 --- a/executors/executor_abstract.go +++ b/executors/executor_abstract.go @@ -57,22 +57,36 @@ func (e *AbstractExecutor) startBuild() error { e.Build.Hostname, _ = os.Hostname() } - // Start actual build - rootDir := e.Config.BuildsDir - if rootDir == "" { - rootDir = e.DefaultBuildsDir + return e.Build.StartBuild( + e.RootDir(), + e.CacheDir(), + e.CustomBuildEnabled(), + e.SharedBuildsDir, + ) +} + +func (e *AbstractExecutor) RootDir() string { + if e.Config.BuildsDir != "" { + return e.Config.BuildsDir } - cacheDir := e.Config.CacheDir - if cacheDir == "" { - cacheDir = e.DefaultCacheDir + + return e.DefaultBuildsDir +} + +func (e *AbstractExecutor) CacheDir() string { + if e.Config.CacheDir != "" { + return e.Config.CacheDir } - customBuildDirEnabled := e.DefaultCustomBuildsDirEnabled + + return e.DefaultCacheDir +} + +func (e *AbstractExecutor) CustomBuildEnabled() bool { if e.Config.CustomBuildDir != nil { - customBuildDirEnabled = e.Config.CustomBuildDir.Enabled + return e.Config.CustomBuildDir.Enabled } - return e.Build.StartBuild(rootDir, cacheDir, - customBuildDirEnabled, e.SharedBuildsDir) + return e.DefaultCustomBuildsDirEnabled } func (e *AbstractExecutor) Shell() *common.ShellScriptInfo {