diff --git a/README.md b/README.md index f30e69e4..f22dbe8c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,16 @@ imports its own sub-packages. BP_GO_BUILD_IMPORT_PATH= example.com/some-app ``` +### `BP_GO_WORK_USE` +The `BP_GO_WORK_USE` variable allows you to initialise a workspace file and add +modules to it. This is helpful for building submodules which use relative +replace directives and `go.work` is not checked in. Usually, this is set +together with `BP_GO_TARGETS`. + +```shell +BP_GO_WORK_USE=./cmd/controller:./cmd/webhook +``` + ### `BP_KEEP_FILES` The `BP_KEEP_FILES` variable allows to you to specity a path list of files (including file globs) that you would like to appear in the workspace of the diff --git a/build.go b/build.go index 52fa68c3..723e259c 100644 --- a/build.go +++ b/build.go @@ -76,12 +76,13 @@ func Build( } config := GoBuildConfiguration{ - Workspace: path, - Output: filepath.Join(targetsLayer.Path, "bin"), - GoPath: goPath, - GoCache: goCacheLayer.Path, - Flags: configuration.Flags, - Targets: configuration.Targets, + Workspace: path, + Output: filepath.Join(targetsLayer.Path, "bin"), + GoPath: goPath, + GoCache: goCacheLayer.Path, + Flags: configuration.Flags, + Targets: configuration.Targets, + WorkspaceUseModules: configuration.WorkspaceUseModules, } if isStaticStack(context.Stack) && !containsFlag(config.Flags, "-buildmode") { diff --git a/build_configuration_parser.go b/build_configuration_parser.go index 154d760f..40eb3342 100644 --- a/build_configuration_parser.go +++ b/build_configuration_parser.go @@ -17,9 +17,10 @@ type TargetManager interface { } type BuildConfiguration struct { - Targets []string - Flags []string - ImportPath string + Targets []string + Flags []string + ImportPath string + WorkspaceUseModules []string } type BuildConfigurationParser struct { @@ -67,6 +68,10 @@ func (p BuildConfigurationParser) Parse(buildpackVersion, workingDir string) (Bu buildConfiguration.ImportPath = val } + if val, ok := os.LookupEnv("BP_GO_WORK_USE"); ok { + buildConfiguration.WorkspaceUseModules = filepath.SplitList(val) + } + return buildConfiguration, nil } diff --git a/build_configuration_parser_test.go b/build_configuration_parser_test.go index a1650b8f..0cf0a2bb 100644 --- a/build_configuration_parser_test.go +++ b/build_configuration_parser_test.go @@ -195,6 +195,27 @@ func testBuildConfigurationParser(t *testing.T, context spec.G, it spec.S) { }) }) + context("when BP_GO_WORK_USE is set", func() { + it.Before(func() { + os.Setenv("BP_GO_WORK_USE", "./some/module1:./some/module2") + }) + + it.After(func() { + os.Unsetenv("BP_GO_WORK_USE") + }) + + it("uses the values in the env var", func() { + configuration, err := parser.Parse("1.2.3", workingDir) + Expect(err).NotTo(HaveOccurred()) + Expect(configuration).To(Equal(gobuild.BuildConfiguration{ + Targets: []string{"."}, + WorkspaceUseModules: []string{"./some/module1", "./some/module2"}, + })) + + Expect(targetManager.GenerateDefaultsCall.Receives.WorkingDir).To(Equal(workingDir)) + }) + }) + context("failure cases", func() { context("when the working directory contains a buildpack.yml", func() { it.Before(func() { diff --git a/go_build_process.go b/go_build_process.go index 96afc3ed..2cd91302 100644 --- a/go_build_process.go +++ b/go_build_process.go @@ -23,13 +23,14 @@ type Executable interface { } type GoBuildConfiguration struct { - Workspace string - Output string - GoPath string - GoCache string - Targets []string - Flags []string - DisableCGO bool + Workspace string + Output string + GoPath string + GoCache string + Targets []string + Flags []string + DisableCGO bool + WorkspaceUseModules []string } type GoBuildProcess struct { @@ -75,6 +76,44 @@ func (p GoBuildProcess) Execute(config GoBuildConfiguration) ([]string, error) { env = append(env, "CGO_ENABLED=0") } + if len(config.WorkspaceUseModules) > 0 { + // go work init + workInitArgs := []string{"work", "init"} + p.logs.Subprocess("Running '%s'", strings.Join(append([]string{"go"}, workInitArgs...), " ")) + + duration, err := p.clock.Measure(func() error { + return p.executable.Execute(pexec.Execution{ + Args: workInitArgs, + Dir: config.Workspace, + Env: env, + Stdout: p.logs.ActionWriter, + Stderr: p.logs.ActionWriter, + }) + }) + if err != nil { + p.logs.Action("Failed after %s", duration.Round(time.Millisecond)) + return nil, fmt.Errorf("failed to execute '%s': %w", workInitArgs, err) + } + + // go work use + workUseArgs := append([]string{"work", "use"}, config.WorkspaceUseModules...) + p.logs.Subprocess("Running '%s'", strings.Join(append([]string{"go"}, workUseArgs...), " ")) + + duration, err = p.clock.Measure(func() error { + return p.executable.Execute(pexec.Execution{ + Args: workUseArgs, + Dir: config.Workspace, + Env: env, + Stdout: p.logs.ActionWriter, + Stderr: p.logs.ActionWriter, + }) + }) + if err != nil { + p.logs.Action("Failed after %s", duration.Round(time.Millisecond)) + return nil, fmt.Errorf("failed to execute '%s': %w", workUseArgs, err) + } + } + printedArgs := []string{"go"} for _, arg := range args { printedArgs = append(printedArgs, formatArg(arg)) diff --git a/go_build_process_test.go b/go_build_process_test.go index d5ede1c4..eaaedcd6 100644 --- a/go_build_process_test.go +++ b/go_build_process_test.go @@ -199,6 +199,66 @@ func testGoBuildProcess(t *testing.T, context spec.G, it spec.S) { }) }) + context("when workspaces should be used", func() { + it.Before(func() { + Expect(os.WriteFile(filepath.Join(workspacePath, "go.mod"), nil, 0644)).To(Succeed()) + Expect(os.Mkdir(filepath.Join(workspacePath, "vendor"), os.ModePerm)).To(Succeed()) + }) + + it("inits and uses the workspaces before executing the go build process", func() { + binaries, err := buildProcess.Execute(gobuild.GoBuildConfiguration{ + Workspace: workspacePath, + Output: filepath.Join(layerPath, "bin"), + GoCache: goCache, + Targets: []string{"."}, + WorkspaceUseModules: []string{"./some/module1", "./some/module2"}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(binaries).To(Equal([]string{ + filepath.Join(layerPath, "bin", "some-dir"), + })) + + Expect(filepath.Join(layerPath, "bin")).To(BeADirectory()) + + Expect(executions[0].Args).To(Equal([]string{ + "work", + "init", + })) + + Expect(executions[1].Args).To(Equal([]string{ + "work", + "use", + "./some/module1", + "./some/module2", + })) + + Expect(executions[2].Args).To(Equal([]string{ + "build", + "-o", filepath.Join(layerPath, "bin"), + "-buildmode", "pie", + "-trimpath", + ".", + })) + + Expect(executions[3].Args).To(Equal([]string{ + "list", + "--json", + ".", + })) + + Expect(executable.ExecuteCall.Receives.Execution.Dir).To(Equal(workspacePath)) + Expect(executable.ExecuteCall.Receives.Execution.Env).To(ContainElement(fmt.Sprintf("GOCACHE=%s", goCache))) + + Expect(logs).To(ContainLines( + " Executing build process", + " Running 'go work init'", + " Running 'go work use ./some/module1 ./some/module2'", + fmt.Sprintf(` Running 'go build -o %s -buildmode pie -trimpath .'`, filepath.Join(layerPath, "bin")), + " Completed in 0s", + )) + }) + }) + context("when the GOPATH is empty", func() { it.Before(func() { Expect(os.WriteFile(filepath.Join(workspacePath, "go.mod"), nil, 0644)).To(Succeed()) @@ -244,6 +304,72 @@ func testGoBuildProcess(t *testing.T, context spec.G, it spec.S) { }) }) + context("when workspaces should be used", func() { + context("when the executable fails go work init", func() { + it.Before(func() { + executable.ExecuteCall.Stub = func(execution pexec.Execution) error { + if execution.Args[0] == "work" && execution.Args[1] == "init" { + fmt.Fprintln(execution.Stdout, "work init error stdout") + fmt.Fprintln(execution.Stderr, "work init error stderr") + return errors.New("command failed") + } + + return nil + } + }) + + it("returns an error", func() { + _, err := buildProcess.Execute(gobuild.GoBuildConfiguration{ + Workspace: workspacePath, + Output: filepath.Join(layerPath, "bin"), + GoPath: goPath, + GoCache: goCache, + Targets: []string{"."}, + WorkspaceUseModules: []string{"./some/module1", "./some/module2"}, + }) + Expect(err).To(MatchError("failed to execute '[work init]': command failed")) + + Expect(logs).To(ContainLines( + " work init error stdout", + " work init error stderr", + " Failed after 1s", + )) + }) + }) + + context("when the executable fails go work use", func() { + it.Before(func() { + executable.ExecuteCall.Stub = func(execution pexec.Execution) error { + if execution.Args[0] == "work" && execution.Args[1] == "use" { + fmt.Fprintln(execution.Stdout, "work use error stdout") + fmt.Fprintln(execution.Stderr, "work use error stderr") + return errors.New("command failed") + } + + return nil + } + }) + + it("returns an error", func() { + _, err := buildProcess.Execute(gobuild.GoBuildConfiguration{ + Workspace: workspacePath, + Output: filepath.Join(layerPath, "bin"), + GoPath: goPath, + GoCache: goCache, + Targets: []string{"."}, + WorkspaceUseModules: []string{"./some/module1", "./some/module2"}, + }) + Expect(err).To(MatchError("failed to execute '[work use ./some/module1 ./some/module2]': command failed")) + + Expect(logs).To(ContainLines( + " work use error stdout", + " work use error stderr", + " Failed after 0s", + )) + }) + }) + }) + context("when the executable fails go build", func() { it.Before(func() { executable.ExecuteCall.Stub = func(execution pexec.Execution) error { diff --git a/integration/init_test.go b/integration/init_test.go index 5d9f5ab3..f104f058 100644 --- a/integration/init_test.go +++ b/integration/init_test.go @@ -113,6 +113,7 @@ func TestIntegration(t *testing.T) { suite("Rebuild", testRebuild) suite("Targets", testTargets) suite("Vendor", testVendor) + suite("WorkUse", testWorkUse) if builder.BuilderName != "paketobuildpacks/builder-jammy-buildpackless-static" { suite("BuildFlags", testBuildFlags) } diff --git a/integration/testdata/work_use/.gitignore b/integration/testdata/work_use/.gitignore new file mode 100644 index 00000000..ff86dbb0 --- /dev/null +++ b/integration/testdata/work_use/.gitignore @@ -0,0 +1 @@ +go.work* diff --git a/integration/testdata/work_use/cmd/cli/go.mod b/integration/testdata/work_use/cmd/cli/go.mod new file mode 100644 index 00000000..0e7b013f --- /dev/null +++ b/integration/testdata/work_use/cmd/cli/go.mod @@ -0,0 +1,7 @@ +module github.com/paketo-buildpacks/go-build/integration/testdata/work_use/binary + +go 1.16 + +replace github.com/paketo-buildpacks/go-build/integration/testdata/work_use => ../../ + +require github.com/paketo-buildpacks/go-build/integration/testdata/work_use v0.0.0-00010101000000-000000000000 diff --git a/integration/testdata/work_use/cmd/cli/go.sum b/integration/testdata/work_use/cmd/cli/go.sum new file mode 100644 index 00000000..12f59d78 --- /dev/null +++ b/integration/testdata/work_use/cmd/cli/go.sum @@ -0,0 +1,4 @@ +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= diff --git a/integration/testdata/work_use/cmd/cli/main.go b/integration/testdata/work_use/cmd/cli/main.go new file mode 100644 index 00000000..54fb5f15 --- /dev/null +++ b/integration/testdata/work_use/cmd/cli/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + + "github.com/paketo-buildpacks/go-build/integration/testdata/work_use/find" +) + +func main() { + pattern := "buildpacks" + data := []string{"paketo", "buildpacks"} + fmt.Printf("found: %d", len(find.Fuzzy(pattern, data...))) +} diff --git a/integration/testdata/work_use/find/find.go b/integration/testdata/work_use/find/find.go new file mode 100644 index 00000000..601c200d --- /dev/null +++ b/integration/testdata/work_use/find/find.go @@ -0,0 +1,13 @@ +package find + +import ( + "github.com/sahilm/fuzzy" +) + +func Fuzzy(pattern string, data ...string) []string { + var matches []string + for _, match := range fuzzy.Find(pattern, data) { + matches = append(matches, match.Str) + } + return matches +} diff --git a/integration/testdata/work_use/go.mod b/integration/testdata/work_use/go.mod new file mode 100644 index 00000000..14df7994 --- /dev/null +++ b/integration/testdata/work_use/go.mod @@ -0,0 +1,8 @@ +module github.com/paketo-buildpacks/go-build/integration/testdata/work_use + +go 1.16 + +require ( + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/sahilm/fuzzy v0.1.0 +) diff --git a/integration/testdata/work_use/go.sum b/integration/testdata/work_use/go.sum new file mode 100644 index 00000000..12f59d78 --- /dev/null +++ b/integration/testdata/work_use/go.sum @@ -0,0 +1,4 @@ +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= diff --git a/integration/work_use_test.go b/integration/work_use_test.go new file mode 100644 index 00000000..b70b4f29 --- /dev/null +++ b/integration/work_use_test.go @@ -0,0 +1,109 @@ +package integration_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/paketo-buildpacks/occam" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" + . "github.com/paketo-buildpacks/occam/matchers" +) + +func testWorkUse(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + pack occam.Pack + docker occam.Docker + ) + + it.Before(func() { + pack = occam.NewPack().WithVerbose().WithNoColor() + docker = occam.NewDocker() + }) + + context("when building an app with a relative replace directive", func() { + var ( + image occam.Image + container occam.Container + + name string + source string + sbomDir string + ) + + it.Before(func() { + var err error + name, err = occam.RandomName() + Expect(err).NotTo(HaveOccurred()) + + sbomDir, err = os.MkdirTemp("", "sbom") + Expect(err).NotTo(HaveOccurred()) + Expect(os.Chmod(sbomDir, os.ModePerm)).To(Succeed()) + }) + + it.After(func() { + Expect(docker.Container.Remove.Execute(container.ID)).To(Succeed()) + Expect(docker.Volume.Remove.Execute(occam.CacheVolumeNames(name))).To(Succeed()) + Expect(docker.Image.Remove.Execute(image.ID)).To(Succeed()) + Expect(os.RemoveAll(source)).To(Succeed()) + Expect(os.RemoveAll(sbomDir)).To(Succeed()) + }) + + it("builds successfully and includes SBOM with modules for built binaries", func() { + var err error + source, err = occam.Source(filepath.Join("testdata", "work_use")) + Expect(err).NotTo(HaveOccurred()) + + var logs fmt.Stringer + image, logs, err = pack.Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.GoDist.Online, + settings.Buildpacks.GoBuild.Online, + ). + WithEnv(map[string]string{ + "BP_GO_WORK_USE": "./cmd/cli", + }). + WithSBOMOutputDir(sbomDir). + Execute(name, source) + Expect(err).ToNot(HaveOccurred(), logs.String) + + container, err = docker.Container.Run.Execute(image.ID) + Expect(err).NotTo(HaveOccurred()) + + Expect(logs).To(ContainLines( + MatchRegexp(fmt.Sprintf(`%s \d+\.\d+\.\d+`, settings.Buildpack.Name)), + " Executing build process", + " Running 'go work init'", + " Running 'go work use ./cmd/cli'", + MatchRegexp(fmt.Sprintf(` Running 'go build -o /layers/%s/targets/bin -buildmode ([^\s]+) -trimpath \./cmd/cli'`, strings.ReplaceAll(settings.Buildpack.ID, "/", "_"))), + " go: downloading github.com/sahilm/fuzzy v0.1.0", + MatchRegexp(` Completed in ([0-9]*(\.[0-9]*)?[a-z]+)+`), + )) + Expect(logs).To(ContainLines( + fmt.Sprintf(" Generating SBOM for /layers/%s/targets/bin", strings.ReplaceAll(settings.Buildpack.ID, "/", "_")), + MatchRegexp(` Completed in ([0-9]*(\.[0-9]*)?[a-z]+)+`), + )) + Expect(logs).To(ContainLines( + " Assigning launch processes:", + fmt.Sprintf(" binary (default): /layers/%s/targets/bin/binary", strings.ReplaceAll(settings.Buildpack.ID, "/", "_")), + )) + + // check that all required SBOM files are present + Expect(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(settings.Buildpack.ID, "/", "_"), "targets", "sbom.cdx.json")).To(BeARegularFile()) + Expect(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(settings.Buildpack.ID, "/", "_"), "targets", "sbom.spdx.json")).To(BeARegularFile()) + Expect(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(settings.Buildpack.ID, "/", "_"), "targets", "sbom.syft.json")).To(BeARegularFile()) + + // check an SBOM file to make sure it contains entries for built binaries + contents, err := os.ReadFile(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(settings.Buildpack.ID, "/", "_"), "targets", "sbom.syft.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).To(ContainSubstring(`"name": "github.com/sahilm/fuzzy"`)) + }) + }) +}