diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index ec32ad2c2f..dc1ae73db1 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -1797,6 +1797,33 @@ func testAcceptance( }) }) + when("--cache with options for build cache as bind", func() { + var bindCacheDir, cacheFlags string + it.Before(func() { + h.SkipIf(t, !pack.SupportsFeature(invoke.Cache), "") + cacheBindName := fmt.Sprintf("%s-bind", repoName) + bindCacheDir, err := ioutil.TempDir("", cacheBindName) + assert.Nil(err) + cacheFlags = fmt.Sprintf("type=build;format=bind;source=%s", bindCacheDir) + }) + + it("creates image and cache image on the registry", func() { + buildArgs := []string{ + repoName, + "-p", filepath.Join("testdata", "mock_app"), + "--cache", + cacheFlags, + } + + output := pack.RunSuccessfully("build", buildArgs...) + assertions.NewOutputAssertionManager(t, output).ReportsSuccessfulImageBuild(repoName) + + t.Log("checking that bind mount has cache contents") + assert.FileExists(fmt.Sprintf("%s/committed", bindCacheDir)) + defer os.RemoveAll(bindCacheDir) + }) + }) + when("ctrl+c", func() { it("stops the execution", func() { var buf = new(bytes.Buffer) diff --git a/internal/build/lifecycle_execution.go b/internal/build/lifecycle_execution.go index b04eb22ae2..314fa9cf6e 100644 --- a/internal/build/lifecycle_execution.go +++ b/internal/build/lifecycle_execution.go @@ -135,10 +135,16 @@ func (l *LifecycleExecution) Run(ctx context.Context, phaseFactoryCreator PhaseF } buildCache = cache.NewImageCache(cacheImage, l.docker) } else { - buildCache = cache.NewVolumeCache(l.opts.Image, l.opts.Cache.Build, "build", l.docker) + switch l.opts.Cache.Build.Format { + case cache.CacheVolume: + buildCache = cache.NewVolumeCache(l.opts.Image, l.opts.Cache.Build, "build", l.docker) + l.logger.Debugf("Using build cache volume %s", style.Symbol(buildCache.Name())) + case cache.CacheBind: + buildCache = cache.NewBindCache(l.opts.Cache.Build, l.docker) + l.logger.Debugf("Using build cache dir %s", style.Symbol(buildCache.Name())) + } } - l.logger.Debugf("Using build cache volume %s", style.Symbol(buildCache.Name())) if l.opts.ClearCache { if err := buildCache.Clear(ctx); err != nil { return errors.Wrap(err, "clearing build cache") @@ -251,7 +257,7 @@ func (l *LifecycleExecution) Create(ctx context.Context, publish bool, dockerHos case cache.Image: flags = append(flags, "-cache-image", buildCache.Name()) cacheOpts = WithBinds(volumes...) - case cache.Volume: + case cache.Volume, cache.Bind: cacheOpts = WithBinds(append(volumes, fmt.Sprintf("%s:%s", buildCache.Name(), l.mountPaths.cacheDir()))...) } diff --git a/internal/cache/bind_cache.go b/internal/cache/bind_cache.go new file mode 100644 index 0000000000..757db613ea --- /dev/null +++ b/internal/cache/bind_cache.go @@ -0,0 +1,36 @@ +package cache + +import ( + "context" + "os" + + "github.com/docker/docker/client" +) + +type BindCache struct { + docker client.CommonAPIClient + bind string +} + +func NewBindCache(cacheType CacheInfo, dockerClient client.CommonAPIClient) *BindCache { + return &BindCache{ + bind: cacheType.Source, + docker: dockerClient, + } +} + +func (c *BindCache) Name() string { + return c.bind +} + +func (c *BindCache) Clear(ctx context.Context) error { + err := os.RemoveAll(c.bind) + if err != nil { + return err + } + return nil +} + +func (c *BindCache) Type() Type { + return Bind +} diff --git a/internal/cache/cache_opts.go b/internal/cache/cache_opts.go index 4f2c7ef99d..d95c4a8854 100644 --- a/internal/cache/cache_opts.go +++ b/internal/cache/cache_opts.go @@ -3,6 +3,7 @@ package cache import ( "encoding/csv" "fmt" + "path/filepath" "strings" "github.com/pkg/errors" @@ -13,6 +14,7 @@ type CacheInfo struct { Format Format Source string } + type CacheOpts struct { Build CacheInfo Launch CacheInfo @@ -21,6 +23,7 @@ type CacheOpts struct { const ( CacheVolume Format = iota CacheImage + CacheBind ) func (f Format) String() string { @@ -29,6 +32,20 @@ func (f Format) String() string { return "image" case CacheVolume: return "volume" + case CacheBind: + return "bind" + } + return "" +} + +func (c *CacheInfo) SourceName() string { + switch c.Format { + case CacheImage: + fallthrough + case CacheVolume: + return "name" + case CacheBind: + return "source" } return "" } @@ -76,15 +93,19 @@ func (c *CacheOpts) Set(value string) error { cache.Format = CacheImage case "volume": cache.Format = CacheVolume + case "bind": + cache.Format = CacheBind default: return errors.Errorf("invalid cache format '%s'", value) } case "name": cache.Source = value + case "source": + cache.Source = value } } - err = populateMissing(c) + err = sanitize(c) if err != nil { return err } @@ -93,8 +114,16 @@ func (c *CacheOpts) Set(value string) error { func (c *CacheOpts) String() string { var cacheFlag string - cacheFlag = fmt.Sprintf("type=build;format=%s;name=%s;", c.Build.Format.String(), c.Build.Source) - cacheFlag += fmt.Sprintf("type=launch;format=%s;name=%s;", c.Launch.Format.String(), c.Launch.Source) + cacheFlag = fmt.Sprintf("type=build;format=%s;", c.Build.Format.String()) + if c.Build.Source != "" { + cacheFlag += fmt.Sprintf("%s=%s;", c.Build.SourceName(), c.Build.Source) + } + + cacheFlag += fmt.Sprintf("type=launch;format=%s;", c.Launch.Format.String()) + if c.Launch.Source != "" { + cacheFlag += fmt.Sprintf("%s=%s;", c.Launch.SourceName(), c.Launch.Source) + } + return cacheFlag } @@ -102,9 +131,30 @@ func (c *CacheOpts) Type() string { return "cache" } -func populateMissing(c *CacheOpts) error { - if (c.Build.Source == "" && c.Build.Format == CacheImage) || (c.Launch.Source == "" && c.Launch.Format == CacheImage) { - return errors.Errorf("cache 'name' is required") +func sanitize(c *CacheOpts) error { + for _, v := range []CacheInfo{c.Build, c.Launch} { + // volume cache name can be auto-generated + if v.Format != CacheVolume && v.Source == "" { + return errors.Errorf("cache '%s' is required", v.SourceName()) + } + } + + if c.Build.Format == CacheBind || c.Launch.Format == CacheBind { + var ( + resolvedPath string + err error + ) + if c.Build.Format == CacheBind { + if resolvedPath, err = filepath.Abs(c.Build.Source); err != nil { + return errors.Wrap(err, "resolve absolute path") + } + c.Build.Source = filepath.Join(resolvedPath, "build-cache") + } else { + if resolvedPath, err = filepath.Abs(c.Launch.Source); err != nil { + return errors.Wrap(err, "resolve absolute path") + } + c.Launch.Source = filepath.Join(resolvedPath, "launch-cache") + } } return nil } diff --git a/internal/cache/cache_opts_test.go b/internal/cache/cache_opts_test.go index 13499eed17..dd28e1acb7 100644 --- a/internal/cache/cache_opts_test.go +++ b/internal/cache/cache_opts_test.go @@ -1,6 +1,10 @@ package cache import ( + "fmt" + "os" + "runtime" + "strings" "testing" "github.com/heroku/color" @@ -30,12 +34,12 @@ func testCacheOpts(t *testing.T, when spec.G, it spec.S) { { name: "Build cache as Image", input: "type=build;format=image;name=io.test.io/myorg/my-cache:build", - output: "type=build;format=image;name=io.test.io/myorg/my-cache:build;type=launch;format=volume;name=;", + output: "type=build;format=image;name=io.test.io/myorg/my-cache:build;type=launch;format=volume;", }, { name: "Launch cache as Image", input: "type=launch;format=image;name=io.test.io/myorg/my-cache:build", - output: "type=build;format=volume;name=;type=launch;format=image;name=io.test.io/myorg/my-cache:build;", + output: "type=build;format=volume;type=launch;format=image;name=io.test.io/myorg/my-cache:build;", }, } @@ -53,12 +57,12 @@ func testCacheOpts(t *testing.T, when spec.G, it spec.S) { { name: "Build cache as Image missing: type", input: "format=image;name=io.test.io/myorg/my-cache:build", - output: "type=build;format=image;name=io.test.io/myorg/my-cache:build;type=launch;format=volume;name=;", + output: "type=build;format=image;name=io.test.io/myorg/my-cache:build;type=launch;format=volume;", }, { name: "Build cache as Image missing: format", input: "type=build;name=io.test.io/myorg/my-cache:build", - output: "type=build;format=volume;name=io.test.io/myorg/my-cache:build;type=launch;format=volume;name=;", + output: "type=build;format=volume;name=io.test.io/myorg/my-cache:build;type=launch;format=volume;", }, { name: "Build cache as Image missing: name", @@ -69,12 +73,12 @@ func testCacheOpts(t *testing.T, when spec.G, it spec.S) { { name: "Build cache as Image missing: type, format", input: "name=io.test.io/myorg/my-cache:build", - output: "type=build;format=volume;name=io.test.io/myorg/my-cache:build;type=launch;format=volume;name=;", + output: "type=build;format=volume;name=io.test.io/myorg/my-cache:build;type=launch;format=volume;", }, { name: "Build cache as Image missing: format, name", input: "type=build", - output: "type=build;format=volume;name=;type=launch;format=volume;name=;", + output: "type=build;format=volume;type=launch;format=volume;", }, { name: "Build cache as Image missing: type, name", @@ -93,9 +97,6 @@ func testCacheOpts(t *testing.T, when spec.G, it spec.S) { for _, testcase := range successTestCases { var cacheFlags CacheOpts t.Logf("Testing cache type: %s", testcase.name) - if testcase.name == "Everything missing" { - print("i am here") - } err := cacheFlags.Set(testcase.input) if testcase.shouldFail { @@ -151,12 +152,12 @@ func testCacheOpts(t *testing.T, when spec.G, it spec.S) { { name: "Build cache as Volume", input: "type=build;format=volume;name=test-build-volume-cache", - output: "type=build;format=volume;name=test-build-volume-cache;type=launch;format=volume;name=;", + output: "type=build;format=volume;name=test-build-volume-cache;type=launch;format=volume;", }, { name: "Launch cache as Volume", input: "type=launch;format=volume;name=test-launch-volume-cache", - output: "type=build;format=volume;name=;type=launch;format=volume;name=test-launch-volume-cache;", + output: "type=build;format=volume;type=launch;format=volume;name=test-launch-volume-cache;", }, } @@ -174,22 +175,109 @@ func testCacheOpts(t *testing.T, when spec.G, it spec.S) { { name: "Launch cache as Volume missing: format", input: "type=launch;name=test-launch-volume", - output: "type=build;format=volume;name=;type=launch;format=volume;name=test-launch-volume;", + output: "type=build;format=volume;type=launch;format=volume;name=test-launch-volume;", }, { name: "Launch cache as Volume missing: name", input: "type=launch;format=volume", - output: "type=build;format=volume;name=;type=launch;format=volume;name=;", + output: "type=build;format=volume;type=launch;format=volume;", }, { name: "Launch cache as Volume missing: format, name", input: "type=launch", - output: "type=build;format=volume;name=;type=launch;format=volume;name=;", + output: "type=build;format=volume;type=launch;format=volume;", }, { name: "Launch cache as Volume missing: type, name", input: "format=volume", - output: "type=build;format=volume;name=;type=launch;format=volume;name=;", + output: "type=build;format=volume;type=launch;format=volume;", + }, + } + + for _, testcase := range successTestCases { + var cacheFlags CacheOpts + t.Logf("Testing cache type: %s", testcase.name) + err := cacheFlags.Set(testcase.input) + + if testcase.shouldFail { + h.AssertError(t, err, testcase.output) + } else { + h.AssertNil(t, err) + output := cacheFlags.String() + h.AssertEq(t, testcase.output, output) + } + } + }) + }) + + when("bind cache format options are passed", func() { + it("with complete options", func() { + var testcases []CacheOptTestCase + homeDir, err := os.UserHomeDir() + h.AssertNil(t, err) + cwd, err := os.Getwd() + h.AssertNil(t, err) + + if runtime.GOOS != "windows" { + testcases = []CacheOptTestCase{ + { + name: "Build cache as bind", + input: fmt.Sprintf("type=build;format=bind;source=%s/test-bind-build-cache", homeDir), + output: fmt.Sprintf("type=build;format=bind;source=%s/test-bind-build-cache/build-cache;type=launch;format=volume;", homeDir), + }, + { + name: "Build cache as bind with relative path", + input: "type=build;format=bind;source=./test-bind-build-cache-relative", + output: fmt.Sprintf("type=build;format=bind;source=%s/test-bind-build-cache-relative/build-cache;type=launch;format=volume;", cwd), + }, + { + name: "Launch cache as bind", + input: fmt.Sprintf("type=launch;format=bind;source=%s/test-bind-volume-cache", homeDir), + output: fmt.Sprintf("type=build;format=volume;type=launch;format=bind;source=%s/test-bind-volume-cache/launch-cache;", homeDir), + }, + } + } else { + testcases = []CacheOptTestCase{ + { + name: "Build cache as bind", + input: fmt.Sprintf("type=build;format=bind;source=%s\\test-bind-build-cache", homeDir), + output: fmt.Sprintf("type=build;format=bind;source=%s\\test-bind-build-cache\\build-cache;type=launch;format=volume;", homeDir), + }, + { + name: "Build cache as bind with relative path", + input: "type=build;format=bind;source=.\\test-bind-build-cache-relative", + output: fmt.Sprintf("type=build;format=bind;source=%s\\test-bind-build-cache-relative\\build-cache;type=launch;format=volume;", cwd), + }, + { + name: "Launch cache as bind", + input: fmt.Sprintf("type=launch;format=bind;source=%s\\test-bind-volume-cache", homeDir), + output: fmt.Sprintf("type=build;format=volume;type=launch;format=bind;source=%s\\test-bind-volume-cache\\launch-cache;", homeDir), + }, + } + } + + for _, testcase := range testcases { + var cacheFlags CacheOpts + t.Logf("Testing cache type: %s", testcase.name) + err := cacheFlags.Set(testcase.input) + h.AssertNil(t, err) + h.AssertEq(t, strings.ToLower(testcase.output), strings.ToLower(cacheFlags.String())) + } + }) + + it("with missing options", func() { + successTestCases := []CacheOptTestCase{ + { + name: "Launch cache as bind missing: source", + input: "type=launch;format=bind", + output: "cache 'source' is required", + shouldFail: true, + }, + { + name: "Launch cache as Volume missing: type, source", + input: "format=bind", + output: "cache 'source' is required", + shouldFail: true, }, } diff --git a/internal/cache/consts.go b/internal/cache/consts.go index 80ae0ef1ce..8a1098519b 100644 --- a/internal/cache/consts.go +++ b/internal/cache/consts.go @@ -3,6 +3,7 @@ package cache const ( Image Type = iota Volume + Bind ) type Type int diff --git a/internal/commands/build.go b/internal/commands/build.go index b07c2f0eb1..0f2aea08c2 100644 --- a/internal/commands/build.go +++ b/internal/commands/build.go @@ -203,7 +203,13 @@ func buildCommandFlags(cmd *cobra.Command, buildFlags *BuildFlags, cfg config.Co cmd.Flags().StringVarP(&buildFlags.AppPath, "path", "p", "", "Path to app dir or zip-formatted file (defaults to current working directory)") cmd.Flags().StringSliceVarP(&buildFlags.Buildpacks, "buildpack", "b", nil, "Buildpack to use. One of:\n a buildpack by id and version in the form of '@',\n path to a buildpack directory (not supported on Windows),\n path/URL to a buildpack .tar or .tgz file, or\n a packaged buildpack image name in the form of '/[:]'"+stringSliceHelp("buildpack")) cmd.Flags().StringVarP(&buildFlags.Builder, "builder", "B", cfg.DefaultBuilder, "Builder image") - cmd.Flags().Var(&buildFlags.Cache, "cache", "Cache options used to define cache techniques for build process.\n- Cache as image: type=;format=image;name=") + cmd.Flags().Var(&buildFlags.Cache, "cache", + `Cache options used to define cache techniques for build process. +- Cache as bind: type=;format=bind;source=; +- Cache as image: type=;format=image;name=; +- Cache as volume: type=;format=volume;[name=;] + - If no name is provided, a random name will be generated. +`) cmd.Flags().StringVar(&buildFlags.CacheImage, "cache-image", "", `Cache build layers in remote registry. Requires --publish`) cmd.Flags().BoolVar(&buildFlags.ClearCache, "clear-cache", false, "Clear image's associated cache before building") cmd.Flags().StringVar(&buildFlags.DateTime, "creation-time", "", "Desired create time in the output image config. Accepted values are Unix timestamps (e.g., '1641013200'), or 'now'. Platform API version must be at least 0.9 to use this feature.") diff --git a/internal/commands/build_test.go b/internal/commands/build_test.go index c647c17112..47bb9b7dcd 100644 --- a/internal/commands/build_test.go +++ b/internal/commands/build_test.go @@ -358,7 +358,7 @@ func testBuildCommand(t *testing.T, when spec.G, it spec.S) { when("--publish is used", func() { it("succeeds", func() { mockClient.EXPECT(). - Build(gomock.Any(), EqBuildOptionsWithCacheFlags("type=build;format=image;name=myorg/myimage:cache;type=launch;format=volume;name=;")). + Build(gomock.Any(), EqBuildOptionsWithCacheFlags("type=build;format=image;name=myorg/myimage:cache;type=launch;format=volume;")). Return(nil) command.SetArgs([]string{"--builder", "my-builder", "image", "--cache", "type=build;format=image;name=myorg/myimage:cache", "--publish"}) @@ -375,7 +375,7 @@ func testBuildCommand(t *testing.T, when spec.G, it spec.S) { when("'type=launch;format=image' is used", func() { it("warns", func() { mockClient.EXPECT(). - Build(gomock.Any(), EqBuildOptionsWithCacheFlags("type=build;format=volume;name=;type=launch;format=image;name=myorg/myimage:cache;")). + Build(gomock.Any(), EqBuildOptionsWithCacheFlags("type=build;format=volume;type=launch;format=image;name=myorg/myimage:cache;")). Return(nil) command.SetArgs([]string{"--builder", "my-builder", "image", "--cache", "type=launch;format=image;name=myorg/myimage:cache", "--publish"})