From 0f20fa6ae3731df94834e3cfe8646a183fbc5e6e Mon Sep 17 00:00:00 2001 From: Timothy Hitchener Date: Wed, 27 Nov 2019 12:30:36 -0500 Subject: [PATCH] move executable.go into its own package, - port over cnb library from node-engine rewrite - update tests a bit to remove os.Args bug [#169918669] Co-authored-by: Daniel Thornton --- .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/packit.iml | 8 + .idea/vcs.xml | 6 + .idea/workspace.xml | 89 +++++ build.go | 134 +++++++ build_test.go | 518 ++++++++++++++++++++++++++++ detect.go | 75 ++++ detect_test.go | 179 ++++++++++ environment.go | 33 ++ environment_test.go | 143 ++++++++ executable.go => exec/executable.go | 2 +- exec/executable_test.go | 268 ++++++++++++++ exec/init_test.go | 14 + executable_test.go | 135 -------- fakes/exit_handler.go | 24 ++ go.mod | 1 + go.sum | 3 + init_test.go | 90 +++-- internal/environment_writer.go | 29 ++ internal/environment_writer_test.go | 82 +++++ internal/exit_handler.go | 60 ++++ internal/exit_handler_test.go | 54 +++ internal/init_test.go | 74 ++++ internal/toml_writer.go | 23 ++ internal/toml_writer_test.go | 68 ++++ layers.go | 58 ++++ layers_test.go | 90 +++++ option.go | 37 ++ 29 files changed, 2147 insertions(+), 164 deletions(-) create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/packit.iml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 build.go create mode 100644 build_test.go create mode 100644 detect.go create mode 100644 detect_test.go create mode 100644 environment.go create mode 100644 environment_test.go rename executable.go => exec/executable.go (98%) create mode 100644 exec/executable_test.go create mode 100644 exec/init_test.go delete mode 100644 executable_test.go create mode 100644 fakes/exit_handler.go create mode 100644 internal/environment_writer.go create mode 100644 internal/environment_writer_test.go create mode 100644 internal/exit_handler.go create mode 100644 internal/exit_handler_test.go create mode 100644 internal/init_test.go create mode 100644 internal/toml_writer.go create mode 100644 internal/toml_writer_test.go create mode 100644 layers.go create mode 100644 layers_test.go create mode 100644 option.go diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..28a804d8 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..7d5f011f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/packit.iml b/.idea/packit.iml new file mode 100644 index 00000000..c956989b --- /dev/null +++ b/.idea/packit.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 00000000..fea9a9b5 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + direct + + \ No newline at end of file diff --git a/build.go b/build.go new file mode 100644 index 00000000..4ade018e --- /dev/null +++ b/build.go @@ -0,0 +1,134 @@ +package packit + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" + "github.com/cloudfoundry/packit/internal" +) + +type BuildFunc func(BuildContext) (BuildResult, error) + +type BuildContext struct { + CNBPath string + Stack string + WorkingDir string + Plan BuildpackPlan + Layers Layers +} + +type BuildResult struct { + Plan BuildpackPlan + Layers []Layer + Processes []Process +} + +type Process struct { + Type string `toml:"type"` + Command string `toml:"command"` + Args []string `toml:"args"` + Direct bool `toml:"direct"` +} + +type BuildpackPlanEntry struct { + Name string `toml:"name"` + Version string `toml:"version"` + Metadata map[string]interface{} `toml:"metadata"` +} + +type BuildpackPlan struct { + Entries []BuildpackPlanEntry `toml:"entries"` +} + +func Build(f BuildFunc, options ...Option) { + config := Config{ + exitHandler: internal.NewExitHandler(), + args: os.Args, + tomlWriter: internal.NewTOMLWriter(), + envWriter: internal.NewEnvironmentWriter(), + } + + for _, option := range options { + config = option(config) + } + + var ( + layersPath = config.args[1] + planPath = config.args[3] + ) + + pwd, err := os.Getwd() + if err != nil { + config.exitHandler.Error(err) + return + } + + var plan BuildpackPlan + _, err = toml.DecodeFile(planPath, &plan) + if err != nil { + config.exitHandler.Error(err) + return + } + + result, err := f(BuildContext{ + CNBPath: filepath.Clean(strings.TrimSuffix(config.args[0], filepath.Join("bin", "build"))), + Stack: os.Getenv("CNB_STACK_ID"), + WorkingDir: pwd, + Plan: plan, + Layers: Layers{ + Path: layersPath, + }, + }) + if err != nil { + config.exitHandler.Error(err) + return + } + + err = config.tomlWriter.Write(planPath, result.Plan) + if err != nil { + config.exitHandler.Error(err) + return + } + + for _, layer := range result.Layers { + err = config.tomlWriter.Write(filepath.Join(layersPath, fmt.Sprintf("%s.toml", layer.Name)), layer) + if err != nil { + config.exitHandler.Error(err) + return + } + + err = config.envWriter.Write(filepath.Join(layer.Path, "env"), layer.SharedEnv) + if err != nil { + config.exitHandler.Error(err) + return + } + + err = config.envWriter.Write(filepath.Join(layer.Path, "env.launch"), layer.LaunchEnv) + if err != nil { + config.exitHandler.Error(err) + return + } + + err = config.envWriter.Write(filepath.Join(layer.Path, "env.build"), layer.BuildEnv) + if err != nil { + config.exitHandler.Error(err) + return + } + } + + if len(result.Processes) > 0 { + var launch struct { + Processes []Process `toml:"processes"` + } + launch.Processes = result.Processes + + err = config.tomlWriter.Write(filepath.Join(layersPath, "launch.toml"), launch) + if err != nil { + config.exitHandler.Error(err) + return + } + } +} diff --git a/build_test.go b/build_test.go new file mode 100644 index 00000000..ff6cac43 --- /dev/null +++ b/build_test.go @@ -0,0 +1,518 @@ +package packit_test + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/cloudfoundry/packit" + "github.com/cloudfoundry/packit/fakes" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func testBuild(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + workingDir string + tmpDir string + layersDir string + planPath string + exitHandler *fakes.ExitHandler + ) + + it.Before(func() { + var err error + workingDir, err = os.Getwd() + Expect(err).NotTo(HaveOccurred()) + + tmpDir, err = ioutil.TempDir("", "working-dir") + Expect(err).NotTo(HaveOccurred()) + + tmpDir, err = filepath.EvalSymlinks(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + Expect(os.Chdir(tmpDir)).To(Succeed()) + + layersDir, err = ioutil.TempDir("", "layers") + Expect(err).NotTo(HaveOccurred()) + + file, err := ioutil.TempFile("", "plan.toml") + Expect(err).NotTo(HaveOccurred()) + defer file.Close() + + _, err = file.WriteString(` +[[entries]] +name = "some-entry" +version = "some-version" + +[entries.metadata] +some-key = "some-value" +`) + Expect(err).NotTo(HaveOccurred()) + + planPath = file.Name() + + Expect(os.Setenv("CNB_STACK_ID", "some-stack")).To(Succeed()) + + exitHandler = &fakes.ExitHandler{} + }) + + it.After(func() { + Expect(os.Unsetenv("CNB_STACK_ID")).To(Succeed()) + + Expect(os.Chdir(workingDir)).To(Succeed()) + Expect(os.RemoveAll(tmpDir)).To(Succeed()) + Expect(os.RemoveAll(layersDir)).To(Succeed()) + }) + + it("provides the build context to the given BuildFunc", func() { + var context packit.BuildContext + + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + context = ctx + + return packit.BuildResult{}, nil + }, packit.WithArgs([]string{"/cnbs/some-cnb/bin/build", layersDir, "", planPath})) + + Expect(context).To(Equal(packit.BuildContext{ + CNBPath: "/cnbs/some-cnb", + Stack: "some-stack", + WorkingDir: tmpDir, + Plan: packit.BuildpackPlan{ + Entries: []packit.BuildpackPlanEntry{ + { + Name: "some-entry", + Version: "some-version", + Metadata: map[string]interface{}{ + "some-key": "some-value", + }, + }, + }, + }, + Layers: packit.Layers{ + Path: layersDir, + }, + })) + }) + + it("updates the buildpack plan.toml with any changes", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + ctx.Plan.Entries[0].Metadata["other-key"] = "other-value" + + return packit.BuildResult{ + Plan: ctx.Plan, + }, nil + }, packit.WithArgs([]string{"", "", "", planPath})) + + contents, err := ioutil.ReadFile(planPath) + Expect(err).NotTo(HaveOccurred()) + + Expect(string(contents)).To(MatchTOML(` +[[entries]] +name = "some-entry" +version = "some-version" + +[entries.metadata] +some-key = "some-value" +other-key = "other-value" +`)) + }) + + it("persists layer metadata", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + layerPath := filepath.Join(ctx.Layers.Path, "some-layer") + Expect(os.MkdirAll(layerPath, os.ModePerm)).To(Succeed()) + + return packit.BuildResult{ + Layers: []packit.Layer{ + packit.Layer{ + Path: layerPath, + Name: "some-layer", + Build: true, + Launch: true, + Cache: true, + Metadata: map[string]interface{}{ + "some-key": "some-value", + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath})) + + contents, err := ioutil.ReadFile(filepath.Join(layersDir, "some-layer.toml")) + Expect(err).NotTo(HaveOccurred()) + + Expect(string(contents)).To(MatchTOML(` +launch = true +build = true +cache = true + +[metadata] +some-key = "some-value" +`)) + }) + + it("persists a launch.toml", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Processes: []packit.Process{ + { + Type: "some-type", + Command: "some-command", + Args: []string{"some-arg"}, + Direct: true, + }, + }, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath})) + + contents, err := ioutil.ReadFile(filepath.Join(layersDir, "launch.toml")) + Expect(err).NotTo(HaveOccurred()) + + Expect(string(contents)).To(MatchTOML(` +[[processes]] +type = "some-type" +command = "some-command" +args = ["some-arg"] +direct = true +`)) + }) + + context("when there are no processes in the result", func() { + it("does not persist a launch.toml", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{}, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath})) + + Expect(filepath.Join(layersDir, "launch.toml")).NotTo(BeARegularFile()) + }) + }) + + context("persists env vars", func() { + context("writes to shared env folder", func() { + it("writes env vars into env directory", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{ + { + Path: filepath.Join(ctx.Layers.Path, "some-layer"), + SharedEnv: packit.Environment{ + "SOME_VAR.append": "append-value", + "SOME_VAR.default": "default-value", + "SOME_VAR.delim": "delim-value", + "SOME_VAR.prepend": "prepend-value", + "SOME_VAR.override": "override-value", + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath})) + + for _, modifier := range []string{"append", "default", "delim", "prepend", "override"} { + contents, err := ioutil.ReadFile(filepath.Join(layersDir, "some-layer", "env", fmt.Sprintf("SOME_VAR.%s", modifier))) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).To(Equal(fmt.Sprintf("%s-value", modifier))) + } + }) + }) + + context("writes to launch folder", func() { + it("writes env vars into env.launch directory", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{ + { + Path: filepath.Join(ctx.Layers.Path, "some-layer"), + LaunchEnv: packit.Environment{ + "SOME_VAR.append": "append-value", + "SOME_VAR.default": "default-value", + "SOME_VAR.delim": "delim-value", + "SOME_VAR.prepend": "prepend-value", + "SOME_VAR.override": "override-value", + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath})) + + for _, modifier := range []string{"append", "default", "delim", "prepend", "override"} { + contents, err := ioutil.ReadFile(filepath.Join(layersDir, "some-layer", "env.launch", fmt.Sprintf("SOME_VAR.%s", modifier))) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).To(Equal(fmt.Sprintf("%s-value", modifier))) + } + }) + }) + + context("writes to build folder", func() { + it("writes env vars into env.build directory", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{ + { + Path: filepath.Join(ctx.Layers.Path, "some-layer"), + BuildEnv: packit.Environment{ + "SOME_VAR.append": "append-value", + "SOME_VAR.default": "default-value", + "SOME_VAR.delim": "delim-value", + "SOME_VAR.prepend": "prepend-value", + "SOME_VAR.override": "override-value", + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath})) + + for _, modifier := range []string{"append", "default", "delim", "prepend", "override"} { + contents, err := ioutil.ReadFile(filepath.Join(layersDir, "some-layer", "env.build", fmt.Sprintf("SOME_VAR.%s", modifier))) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).To(Equal(fmt.Sprintf("%s-value", modifier))) + } + }) + }) + }) + + context("failure cases", func() { + context("when the buildpack plan.toml is malformed", func() { + it.Before(func() { + err := ioutil.WriteFile(planPath, []byte("%%%"), 0644) + Expect(err).NotTo(HaveOccurred()) + }) + + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{}, nil + }, packit.WithArgs([]string{"", "", "", planPath}), packit.WithExitHandler(exitHandler)) + + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("bare keys cannot contain '%'"))) + }) + }) + + context("when the build func returns an error", func() { + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{}, errors.New("build failed") + }, packit.WithArgs([]string{"", "", "", planPath}), packit.WithExitHandler(exitHandler)) + + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError("build failed")) + }) + }) + + context("when the buildpack plan.toml cannot be written", func() { + it.Before(func() { + Expect(os.Chmod(planPath, 0444)).To(Succeed()) + }) + + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{}, nil + }, packit.WithArgs([]string{"", "", "", planPath}), packit.WithExitHandler(exitHandler)) + + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("when the layer.toml file cannot be written", func() { + it.Before(func() { + Expect(os.Chmod(layersDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(layersDir, os.ModePerm)).To(Succeed()) + }) + + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{ + packit.Layer{ + Path: filepath.Join(layersDir, "some-layer"), + Name: "some-layer", + }, + }, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath}), packit.WithExitHandler(exitHandler)) + + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("when the launch.toml file cannot be written", func() { + it.Before(func() { + _, err := os.OpenFile(filepath.Join(layersDir, "launch.toml"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0000) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(os.Chmod(filepath.Join(layersDir, "launch.toml"), os.ModePerm)).To(Succeed()) + }) + + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Processes: []packit.Process{{}}, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath}), packit.WithExitHandler(exitHandler)) + + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("when the env dir cannot be created", func() { + var envDir string + it.Before(func() { + var err error + envDir, err = ioutil.TempDir("", "environment") + Expect(err).NotTo(HaveOccurred()) + + Expect(os.Chmod(envDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(envDir, os.ModePerm)).To(Succeed()) + }) + + context("SharedEnv", func() { + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{ + { + Path: envDir, + SharedEnv: packit.Environment{ + "SOME_VAR.override": "some-value", + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath}), packit.WithExitHandler(exitHandler)) + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("BuildEnv", func() { + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{ + { + Path: envDir, + BuildEnv: packit.Environment{ + "SOME_VAR.override": "some-value", + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath}), packit.WithExitHandler(exitHandler)) + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("LaunchEnv", func() { + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{ + { + Path: envDir, + LaunchEnv: packit.Environment{ + "SOME_VAR.override": "some-value", + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath}), packit.WithExitHandler(exitHandler)) + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + }) + + context("when the env file cannot be created", func() { + context("SharedEnv", func() { + var envDir string + it.Before(func() { + envDir = filepath.Join(layersDir, "some-layer", "env") + Expect(os.MkdirAll(envDir, os.ModePerm)).To(Succeed()) + Expect(os.Chmod(envDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(envDir, os.ModePerm)).To(Succeed()) + }) + + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{{ + Path: filepath.Join(layersDir, "some-layer"), + SharedEnv: packit.Environment{ + "SOME_VAR.override": "some-value", + }, + }}, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath}), packit.WithExitHandler(exitHandler)) + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("BuildEnv", func() { + var envDir string + it.Before(func() { + envDir = filepath.Join(layersDir, "some-layer", "env.build") + Expect(os.MkdirAll(envDir, os.ModePerm)).To(Succeed()) + Expect(os.Chmod(envDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(envDir, os.ModePerm)).To(Succeed()) + }) + + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{{ + Path: filepath.Join(layersDir, "some-layer"), + BuildEnv: packit.Environment{ + "SOME_VAR.override": "some-value", + }, + }}, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath}), packit.WithExitHandler(exitHandler)) + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("LaunchEnv", func() { + var envDir string + it.Before(func() { + envDir = filepath.Join(layersDir, "some-layer", "env.launch") + Expect(os.MkdirAll(envDir, os.ModePerm)).To(Succeed()) + Expect(os.Chmod(envDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(envDir, os.ModePerm)).To(Succeed()) + }) + + it("calls the exit handler", func() { + packit.Build(func(ctx packit.BuildContext) (packit.BuildResult, error) { + return packit.BuildResult{ + Layers: []packit.Layer{{ + Path: filepath.Join(layersDir, "some-layer"), + LaunchEnv: packit.Environment{ + "SOME_VAR.override": "some-value", + }, + }}, + }, nil + }, packit.WithArgs([]string{"", layersDir, "", planPath}), packit.WithExitHandler(exitHandler)) + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + }) + }) +} diff --git a/detect.go b/detect.go new file mode 100644 index 00000000..495d144b --- /dev/null +++ b/detect.go @@ -0,0 +1,75 @@ +package packit + +import ( + "fmt" + "os" + + "github.com/BurntSushi/toml" + "github.com/cloudfoundry/packit/internal" +) + +type BuildPlanProvision struct { + Name string `toml:"name"` +} + +type BuildPlanRequirement struct { + Name string `toml:"name"` + Version string `toml:"version"` + Metadata interface{} `toml:"metadata"` +} + +type BuildPlan struct { + Provides []BuildPlanProvision `toml:"provides"` + Requires []BuildPlanRequirement `toml:"requires"` +} + +type DetectContext struct { + WorkingDir string +} + +type DetectResult struct { + Plan BuildPlan +} + +type DetectFunc func(DetectContext) (DetectResult, error) + +func Detect(f DetectFunc, options ...Option) { + config := Config{ + exitHandler: internal.NewExitHandler(), + args: os.Args, + } + + for _, option := range options { + config = option(config) + } + + dir, err := os.Getwd() + if err != nil { + config.exitHandler.Error(err) + return + } + + result, err := f(DetectContext{ + WorkingDir: dir, + }) + if err != nil { + config.exitHandler.Error(err) + return + } + + fmt.Println("CONFIG ARGS") + fmt.Printf("%+v\n", config.args) + + file, err := os.OpenFile(config.args[2], os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) + if err != nil { + config.exitHandler.Error(err) + return + } + defer file.Close() + + err = toml.NewEncoder(file).Encode(result.Plan) + if err != nil { + config.exitHandler.Error(err) + return + } +} diff --git a/detect_test.go b/detect_test.go new file mode 100644 index 00000000..45f9f462 --- /dev/null +++ b/detect_test.go @@ -0,0 +1,179 @@ +package packit_test + +import ( + "errors" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/cloudfoundry/packit" + "github.com/cloudfoundry/packit/fakes" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func testDetect(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + workingDir string + tmpDir string + exitHandler *fakes.ExitHandler + ) + + it.Before(func() { + var err error + workingDir, err = os.Getwd() + Expect(err).NotTo(HaveOccurred()) + + tmpDir, err = ioutil.TempDir("", "") + Expect(err).NotTo(HaveOccurred()) + + tmpDir, err = filepath.EvalSymlinks(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + Expect(os.Chdir(tmpDir)).To(Succeed()) + + exitHandler = &fakes.ExitHandler{} + }) + + it.After(func() { + Expect(os.Chdir(workingDir)).To(Succeed()) + Expect(os.RemoveAll(tmpDir)).To(Succeed()) + }) + + context("when providing the detect context to the given DetectFunc", func() { + var oldArgs []string + + // Test depends on os.Args having at least 3 elements + it.Before(func() { + filePath := filepath.Join(os.TempDir(), "buildpack.toml") + oldArgs = os.Args + os.Args = append(os.Args[:2], append([]string{filePath, filePath}, os.Args[2:]...)...) + }) + + it.After(func() { + os.Args = oldArgs + }) + + it("succeeds", func() { + var context packit.DetectContext + + packit.Detect(func(ctx packit.DetectContext) (packit.DetectResult, error) { + context = ctx + + return packit.DetectResult{}, nil + }) + + Expect(context).To(Equal(packit.DetectContext{ + WorkingDir: tmpDir, + })) + }) + }) + + it("writes out the buildplan.toml", func() { + path := filepath.Join(tmpDir, "buildplan.toml") + + packit.Detect(func(packit.DetectContext) (packit.DetectResult, error) { + return packit.DetectResult{ + Plan: packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + {Name: "some-provision"}, + }, + Requires: []packit.BuildPlanRequirement{ + { + Name: "some-requirement", + Version: "some-version", + Metadata: map[string]string{ + "some-key": "some-value", + }, + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", "", path})) + + contents, err := ioutil.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + + Expect(string(contents)).To(MatchTOML(` +[[provides]] +name = "some-provision" + +[[requires]] +name = "some-requirement" +version = "some-version" + +[requires.metadata] +some-key = "some-value" +`)) + }) + + context("when the DetectFunc returns an error", func() { + it("calls the ExitHandler with that error", func() { + packit.Detect(func(ctx packit.DetectContext) (packit.DetectResult, error) { + return packit.DetectResult{}, errors.New("failed to detect") + }, packit.WithExitHandler(exitHandler)) + + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError("failed to detect")) + }) + }) + + context("failure cases", func() { + context("when the buildplan.toml cannot be opened", func() { + it("returns an error", func() { + path := filepath.Join(tmpDir, "buildplan.toml") + _, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0000) + Expect(err).NotTo(HaveOccurred()) + + packit.Detect(func(packit.DetectContext) (packit.DetectResult, error) { + return packit.DetectResult{ + Plan: packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + {Name: "some-provision"}, + }, + Requires: []packit.BuildPlanRequirement{ + { + Name: "some-requirement", + Version: "some-version", + Metadata: map[string]string{ + "some-key": "some-value", + }, + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", "", path}), packit.WithExitHandler(exitHandler)) + + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("when the buildplan.toml cannot be encoded", func() { + it("returns an error", func() { + path := filepath.Join(tmpDir, "buildplan.toml") + + packit.Detect(func(packit.DetectContext) (packit.DetectResult, error) { + return packit.DetectResult{ + Plan: packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + {Name: "some-provision"}, + }, + Requires: []packit.BuildPlanRequirement{ + { + Name: "some-requirement", + Version: "some-version", + Metadata: map[int]int{}, + }, + }, + }, + }, nil + }, packit.WithArgs([]string{"", "", path}), packit.WithExitHandler(exitHandler)) + + Expect(exitHandler.ErrorCall.Receives.Error).To(MatchError(ContainSubstring("cannot encode a map with non-string key type"))) + }) + }) + }) +} diff --git a/environment.go b/environment.go new file mode 100644 index 00000000..4715898a --- /dev/null +++ b/environment.go @@ -0,0 +1,33 @@ +package packit + +type Environment map[string]string + +func NewEnvironment() Environment { + return Environment{} +} + +func (e Environment) Override(name, value string) { + e[name+".override"] = value +} + +func (e Environment) Prepend(name, value, delim string) { + e[name+".prepend"] = value + + delete(e, name+".delim") + if delim != "" { + e[name+".delim"] = delim + } +} + +func (e Environment) Append(name, value, delim string) { + e[name+".append"] = value + + delete(e, name+".delim") + if delim != "" { + e[name+".delim"] = delim + } +} + +func (e Environment) Default(name, value string) { + e[name+".default"] = value +} diff --git a/environment_test.go b/environment_test.go new file mode 100644 index 00000000..bd60ae8f --- /dev/null +++ b/environment_test.go @@ -0,0 +1,143 @@ +package packit_test + +import ( + "testing" + + "github.com/cloudfoundry/packit" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func testEnvironment(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + environment = packit.NewEnvironment() + ) + + context("Override", func() { + it("modifies the environment object with override values", func() { + environment.Override("SOME_NAME", "some-value") + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.override": "some-value", + })) + }) + + context("when called multiple times", func() { + it("overwrites the previous invocation", func() { + environment.Override("SOME_NAME", "some-value") + environment.Override("SOME_NAME", "some-other-value") + + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.override": "some-other-value", + })) + }) + }) + }) + + context("Prepend", func() { + it("modifies the environment object with prepend values", func() { + environment.Prepend("SOME_NAME", "some-value", "|") + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.prepend": "some-value", + "SOME_NAME.delim": "|", + })) + }) + + context("when called multiple times", func() { + it("overwrites the previous invocation", func() { + environment.Prepend("SOME_NAME", "some-value", "|") + environment.Prepend("SOME_NAME", "other-value", "&") + + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.prepend": "other-value", + "SOME_NAME.delim": "&", + })) + }) + + context("when the delimiter is empty", func() { + it("does not include a .delim variable", func() { + environment.Prepend("SOME_NAME", "some-value", "|") + environment.Prepend("SOME_NAME", "other-value", "") + + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.prepend": "other-value", + })) + }) + }) + }) + + context("when the delimiter is empty", func() { + it("does not include a .delim variable", func() { + environment.Prepend("SOME_NAME", "some-value", "") + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.prepend": "some-value", + })) + }) + }) + }) + + context("Append", func() { + it("modifies an existing environment var with append values", func() { + environment.Append("SOME_NAME", "some-value", ";") + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.append": "some-value", + "SOME_NAME.delim": ";", + })) + }) + + context("when called multiple times", func() { + it("overwrites the previous invocation", func() { + environment.Append("SOME_NAME", "some-value", ";") + environment.Append("SOME_NAME", "other-value", "&") + + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.append": "other-value", + "SOME_NAME.delim": "&", + })) + }) + + context("when the delimiter is empty", func() { + it("does not include a .delim variable", func() { + environment.Append("SOME_NAME", "some-value", ";") + environment.Append("SOME_NAME", "other-value", "") + + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.append": "other-value", + })) + }) + }) + }) + + context("when the delimiter is empty", func() { + it("does not include a .delim variable", func() { + environment.Append("SOME_NAME", "some-value", "") + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.append": "some-value", + })) + }) + }) + }) + + context("Default", func() { + it("modifies the environment object with default values", func() { + environment.Default("SOME_NAME", "some-default-value") + + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.default": "some-default-value", + })) + }) + + context("when called multiple times", func() { + it("overwrites the previous invocation", func() { + environment.Default("SOME_NAME", "some-default-value") + environment.Default("SOME_NAME", "other-default-value") + + Expect(environment).To(Equal(packit.Environment{ + "SOME_NAME.default": "other-default-value", + })) + }) + }) + }) +} diff --git a/executable.go b/exec/executable.go similarity index 98% rename from executable.go rename to exec/executable.go index a759254e..7c075845 100644 --- a/executable.go +++ b/exec/executable.go @@ -1,4 +1,4 @@ -package packit +package exec import ( "bytes" diff --git a/exec/executable_test.go b/exec/executable_test.go new file mode 100644 index 00000000..248b78e0 --- /dev/null +++ b/exec/executable_test.go @@ -0,0 +1,268 @@ +package exec_test + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/sclevine/spec" + + "code.cloudfoundry.org/lager" + "github.com/cloudfoundry/packit/exec" + "github.com/onsi/gomega/gexec" + + . "github.com/onsi/gomega" +) + +func testExec(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + fakeCLI string + existingPath string + tmpDir string + executable exec.Executable + ) + + it.Before(func() { + var err error + tmpDir, err = ioutil.TempDir("", "cnb2cf-executable") + Expect(err).NotTo(HaveOccurred()) + + tmpDir, err = filepath.EvalSymlinks(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + logger := lager.NewLogger("cutlass") + + executable = exec.NewExecutable("some-executable", logger) + + fakeCLI, err = gexec.Build("github.com/cloudfoundry/packit/fakes/some-executable") + Expect(err).NotTo(HaveOccurred()) + existingPath = os.Getenv("PATH") + os.Setenv("PATH", filepath.Dir(fakeCLI)) + + }) + + it.After(func() { + os.Setenv("PATH", existingPath) + gexec.CleanupBuildArtifacts() + Expect(os.RemoveAll(tmpDir)).To(Succeed()) + }) + + context("Execute", func() { + it("executes the given arguments against the executable", func() { + stdout, stderr, err := executable.Execute(exec.Execution{ + Args: []string{"something"}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stdout).To(ContainSubstring("Output on stdout")) + Expect(stderr).To(ContainSubstring("Output on stderr")) + + Expect(stdout).To(ContainSubstring(fmt.Sprintf("Arguments: [%s something]", fakeCLI))) + }) + + context("when given a execution directory", func() { + it("executes within that directory", func() { + stdout, _, err := executable.Execute(exec.Execution{ + Dir: tmpDir, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stdout).To(ContainSubstring(fmt.Sprintf("PWD=%s", tmpDir))) + }) + }) + + context("when given an execution environment", func() { + it("executes with that environment", func() { + stdout, _, err := executable.Execute(exec.Execution{ + Env: []string{"SOME_KEY=some-value"}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stdout).To(ContainSubstring("SOME_KEY=some-value")) + }) + }) + + context("when given a writer for stdout and stderr", func() { + it("pipes stdout to that writer", func() { + stdOutBuffer := bytes.NewBuffer(nil) + stdErrBuffer := bytes.NewBuffer(nil) + + stdout, stderr, err := executable.Execute(exec.Execution{ + Stdout: stdOutBuffer, + Stderr: stdErrBuffer, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(stdOutBuffer.String()).To(ContainSubstring("Output on stdout")) + Expect(stdOutBuffer.String()).To(Equal(stdout)) + + Expect(stdErrBuffer.String()).To(ContainSubstring("Output on stderr")) + Expect(stdErrBuffer.String()).To(Equal(stderr)) + }) + }) + + context("failure cases", func() { + context("when the executable cannot be found on the path", func() { + it.Before(func() { + Expect(os.Unsetenv("PATH")).To(Succeed()) + }) + + it.Focus("returns an error", func() { + _, _, err := executable.Execute(exec.Execution{}) + Expect(err).To(MatchError("exec: \"some-executable\": executable file not found in $PATH")) + }) + }) + + context("when the executable errors", func() { + var ( + errorCLI string + path string + ) + + it.Before(func() { + Expect(os.Setenv("PATH", existingPath)).To(Succeed()) + + var err error + errorCLI, err = gexec.Build("github.com/cloudfoundry/packit/fakes/some-executable", "-ldflags", "-X main.fail=true") + Expect(err).NotTo(HaveOccurred()) + + path = os.Getenv("PATH") + Expect(os.Setenv("PATH", filepath.Dir(errorCLI))).To(Succeed()) + }) + + it.After(func() { + Expect(os.Setenv("PATH", path)).To(Succeed()) + }) + + it("executes the given arguments against the executable", func() { + stdout, stderr, err := executable.Execute(exec.Execution{ + Args: []string{"something"}, + }) + Expect(err).To(MatchError("exit status 1")) + Expect(stdout).To(ContainSubstring("Error on stdout")) + Expect(stderr).To(ContainSubstring("Error on stderr")) + }) + }) + }) + }) + +} + +//var _ = Describe("Executable", func() { +// var ( +// executable packit.Executable +// tmpDir string +// ) +// +// BeforeEach(func() { +// var err error +// tmpDir, err = ioutil.TempDir("", "cnb2cf-executable") +// Expect(err).NotTo(HaveOccurred()) +// +// tmpDir, err = filepath.EvalSymlinks(tmpDir) +// Expect(err).NotTo(HaveOccurred()) +// +// logger := lager.NewLogger("cutlass") +// +// executable = packit.NewExecutable("some-executable", logger) +// }) +// +// AfterEach(func() { +// Expect(os.RemoveAll(tmpDir)).To(Succeed()) +// }) +// +// Describe("Execute", func() { +// It("executes the given arguments against the executable", func() { +// stdout, stderr, err := executable.Execute(packit.Execution{ +// Args: []string{"something"}, +// }) +// Expect(err).NotTo(HaveOccurred()) +// Expect(stdout).To(ContainSubstring("Output on stdout")) +// Expect(stderr).To(ContainSubstring("Output on stderr")) +// +// Expect(stdout).To(ContainSubstring(fmt.Sprintf("Arguments: [%s something]", fakeCLI))) +// }) +// +// Context("when given a execution directory", func() { +// It("executes within that directory", func() { +// stdout, _, err := executable.Execute(packit.Execution{ +// Dir: tmpDir, +// }) +// Expect(err).NotTo(HaveOccurred()) +// Expect(stdout).To(ContainSubstring(fmt.Sprintf("PWD=%s", tmpDir))) +// }) +// }) +// +// Context("when given an execution environment", func() { +// It("executes with that environment", func() { +// stdout, _, err := executable.Execute(packit.Execution{ +// Env: []string{"SOME_KEY=some-value"}, +// }) +// Expect(err).NotTo(HaveOccurred()) +// Expect(stdout).To(ContainSubstring("SOME_KEY=some-value")) +// }) +// }) +// +// Context("when given a writer for stdout and stderr", func() { +// It("pipes stdout to that writer", func() { +// stdOutBuffer := bytes.NewBuffer(nil) +// stdErrBuffer := bytes.NewBuffer(nil) +// +// stdout, stderr, err := executable.Execute(packit.Execution{ +// Stdout: stdOutBuffer, +// Stderr: stdErrBuffer, +// }) +// Expect(err).NotTo(HaveOccurred()) +// Expect(stdOutBuffer.String()).To(ContainSubstring("Output on stdout")) +// Expect(stdOutBuffer.String()).To(Equal(stdout)) +// +// Expect(stdErrBuffer.String()).To(ContainSubstring("Output on stderr")) +// Expect(stdErrBuffer.String()).To(Equal(stderr)) +// }) +// }) +// +// Context("failure cases", func() { +// Context("when the executable cannot be found on the path", func() { +// BeforeEach(func() { +// os.Unsetenv("PATH") +// }) +// +// It("returns an error", func() { +// _, _, err := executable.Execute(packit.Execution{}) +// Expect(err).To(MatchError("exec: \"some-executable\": executable file not found in $PATH")) +// }) +// }) +// +// Context("when the executable errors", func() { +// var ( +// errorCLI string +// path string +// ) +// +// BeforeEach(func() { +// os.Setenv("PATH", existingPath) +// +// var err error +// errorCLI, err = gexec.Build("github.com/cloudfoundry/packit/fakes/some-executable", "-ldflags", "-X main.fail=true") +// Expect(err).NotTo(HaveOccurred()) +// +// path = os.Getenv("PATH") +// os.Setenv("PATH", filepath.Dir(errorCLI)) +// }) +// +// AfterEach(func() { +// os.Setenv("PATH", path) +// }) +// +// It("executes the given arguments against the executable", func() { +// stdout, stderr, err := executable.Execute(packit.Execution{ +// Args: []string{"something"}, +// }) +// Expect(err).To(MatchError("exit status 1")) +// Expect(stdout).To(ContainSubstring("Error on stdout")) +// Expect(stderr).To(ContainSubstring("Error on stderr")) +// }) +// }) +// }) +// }) +//}) diff --git a/exec/init_test.go b/exec/init_test.go new file mode 100644 index 00000000..d59f54f2 --- /dev/null +++ b/exec/init_test.go @@ -0,0 +1,14 @@ +package exec_test + +import ( + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestUnitExec(t *testing.T) { + suite := spec.New("packit/exec", spec.Report(report.Terminal{})) + suite("Exec", testExec) + suite.Run(t) +} diff --git a/executable_test.go b/executable_test.go deleted file mode 100644 index bfeb87c2..00000000 --- a/executable_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package packit_test - -import ( - "bytes" - "fmt" - "io/ioutil" - "os" - "path/filepath" - - "code.cloudfoundry.org/lager" - "github.com/cloudfoundry/packit" - "github.com/onsi/gomega/gexec" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" -) - -var _ = Describe("Executable", func() { - var ( - executable packit.Executable - tmpDir string - ) - - BeforeEach(func() { - var err error - tmpDir, err = ioutil.TempDir("", "cnb2cf-executable") - Expect(err).NotTo(HaveOccurred()) - - tmpDir, err = filepath.EvalSymlinks(tmpDir) - Expect(err).NotTo(HaveOccurred()) - - logger := lager.NewLogger("cutlass") - - executable = packit.NewExecutable("some-executable", logger) - }) - - AfterEach(func() { - Expect(os.RemoveAll(tmpDir)).To(Succeed()) - }) - - Describe("Execute", func() { - It("executes the given arguments against the executable", func() { - stdout, stderr, err := executable.Execute(packit.Execution{ - Args: []string{"something"}, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(stdout).To(ContainSubstring("Output on stdout")) - Expect(stderr).To(ContainSubstring("Output on stderr")) - - Expect(stdout).To(ContainSubstring(fmt.Sprintf("Arguments: [%s something]", fakeCLI))) - }) - - Context("when given a execution directory", func() { - It("executes within that directory", func() { - stdout, _, err := executable.Execute(packit.Execution{ - Dir: tmpDir, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(stdout).To(ContainSubstring(fmt.Sprintf("PWD=%s", tmpDir))) - }) - }) - - Context("when given an execution environment", func() { - It("executes with that environment", func() { - stdout, _, err := executable.Execute(packit.Execution{ - Env: []string{"SOME_KEY=some-value"}, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(stdout).To(ContainSubstring("SOME_KEY=some-value")) - }) - }) - - Context("when given a writer for stdout and stderr", func() { - It("pipes stdout to that writer", func() { - stdOutBuffer := bytes.NewBuffer(nil) - stdErrBuffer := bytes.NewBuffer(nil) - - stdout, stderr, err := executable.Execute(packit.Execution{ - Stdout: stdOutBuffer, - Stderr: stdErrBuffer, - }) - Expect(err).NotTo(HaveOccurred()) - Expect(stdOutBuffer.String()).To(ContainSubstring("Output on stdout")) - Expect(stdOutBuffer.String()).To(Equal(stdout)) - - Expect(stdErrBuffer.String()).To(ContainSubstring("Output on stderr")) - Expect(stdErrBuffer.String()).To(Equal(stderr)) - }) - }) - - Context("failure cases", func() { - Context("when the executable cannot be found on the path", func() { - BeforeEach(func() { - os.Unsetenv("PATH") - }) - - It("returns an error", func() { - _, _, err := executable.Execute(packit.Execution{}) - Expect(err).To(MatchError("exec: \"some-executable\": executable file not found in $PATH")) - }) - }) - - Context("when the executable errors", func() { - var ( - errorCLI string - path string - ) - - BeforeEach(func() { - os.Setenv("PATH", existingPath) - - var err error - errorCLI, err = gexec.Build("github.com/cloudfoundry/packit/fakes/some-executable", "-ldflags", "-X main.fail=true") - Expect(err).NotTo(HaveOccurred()) - - path = os.Getenv("PATH") - os.Setenv("PATH", filepath.Dir(errorCLI)) - }) - - AfterEach(func() { - os.Setenv("PATH", path) - }) - - It("executes the given arguments against the executable", func() { - stdout, stderr, err := executable.Execute(packit.Execution{ - Args: []string{"something"}, - }) - Expect(err).To(MatchError("exit status 1")) - Expect(stdout).To(ContainSubstring("Error on stdout")) - Expect(stderr).To(ContainSubstring("Error on stderr")) - }) - }) - }) - }) -}) diff --git a/fakes/exit_handler.go b/fakes/exit_handler.go new file mode 100644 index 00000000..37e1d233 --- /dev/null +++ b/fakes/exit_handler.go @@ -0,0 +1,24 @@ +package fakes + +import "sync" + +type ExitHandler struct { + ErrorCall struct { + sync.Mutex + CallCount int + Receives struct { + Error error + } + Stub func(error) + } +} + +func (f *ExitHandler) Error(param1 error) { + f.ErrorCall.Lock() + defer f.ErrorCall.Unlock() + f.ErrorCall.CallCount++ + f.ErrorCall.Receives.Error = param1 + if f.ErrorCall.Stub != nil { + f.ErrorCall.Stub(param1) + } +} diff --git a/go.mod b/go.mod index 8aecde88..c93b806e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.12 require ( code.cloudfoundry.org/lager v2.0.0+incompatible + github.com/BurntSushi/toml v0.3.1 github.com/cheggaaa/pb v2.0.7+incompatible github.com/mattn/go-colorable v0.1.4 // indirect github.com/mattn/go-isatty v0.0.10 // indirect diff --git a/go.sum b/go.sum index 845f4626..50b4dc22 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,10 @@ code.cloudfoundry.org/lager v2.0.0+incompatible h1:WZwDKDB2PLd/oL+USK4b4aEjUymIej9My2nUQ9oWEwQ= code.cloudfoundry.org/lager v2.0.0+incompatible/go.mod h1:O2sS7gKP3HM2iemG+EnwvyNQK7pTSC6Foi4QiMp9sSk= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/cheggaaa/pb v2.0.7+incompatible h1:gLKifR1UkZ/kLkda5gC0K6c8g+jU2sINPtBeOiNlMhU= github.com/cheggaaa/pb v2.0.7+incompatible/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= +github.com/cloudfoundry/node-engine-cnb v0.0.109 h1:fFgEfl8nayo4C7GnunSCT7jXm8iPkoPchizoshW77ko= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= diff --git a/init_test.go b/init_test.go index 3565d60c..a2446a6f 100644 --- a/init_test.go +++ b/init_test.go @@ -1,41 +1,75 @@ package packit_test import ( - "os" - "path/filepath" + "fmt" + "reflect" "testing" - "github.com/onsi/gomega/gexec" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + "github.com/BurntSushi/toml" + "github.com/onsi/gomega/types" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" ) -func TestPackit(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "packit") +func TestUnitPackit(t *testing.T) { + suite := spec.New("packit", spec.Report(report.Terminal{})) + suite("Build", testBuild) + suite("Detect", testDetect) + suite("Environment", testEnvironment) + suite("Layers", testLayers) + suite.Run(t) } -var ( - fakeCLI string - existingPath string -) +func MatchTOML(expected interface{}) types.GomegaMatcher { + return &matchTOML{ + expected: expected, + } +} + +type matchTOML struct { + expected interface{} +} + +func (matcher *matchTOML) Match(actual interface{}) (success bool, err error) { + var e, a string -var _ = BeforeSuite(func() { - var err error - fakeCLI, err = gexec.Build("github.com/cloudfoundry/packit/fakes/some-executable") - Expect(err).NotTo(HaveOccurred()) -}) + switch eType := matcher.expected.(type) { + case string: + e = eType + case []byte: + e = string(eType) + default: + return false, fmt.Errorf("expected value must be []byte or string, received %T", matcher.expected) + } -var _ = AfterSuite(func() { - gexec.CleanupBuildArtifacts() -}) + switch aType := actual.(type) { + case string: + a = aType + case []byte: + a = string(aType) + default: + return false, fmt.Errorf("actual value must be []byte or string, received %T", matcher.expected) + } -var _ = BeforeEach(func() { - existingPath = os.Getenv("PATH") - os.Setenv("PATH", filepath.Dir(fakeCLI)) -}) + var eValue map[string]interface{} + _, err = toml.Decode(e, &eValue) + if err != nil { + return false, err + } -var _ = AfterEach(func() { - os.Setenv("PATH", existingPath) -}) + var aValue map[string]interface{} + _, err = toml.Decode(a, &aValue) + if err != nil { + return false, err + } + + return reflect.DeepEqual(eValue, aValue), nil +} + +func (matcher *matchTOML) FailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("Expected\n%s\nto match the TOML representation of\n%s", actual, matcher.expected) +} + +func (matcher *matchTOML) NegatedFailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("Expected\n%s\nnot to match the TOML representation of\n%s", actual, matcher.expected) +} diff --git a/internal/environment_writer.go b/internal/environment_writer.go new file mode 100644 index 00000000..abdd2018 --- /dev/null +++ b/internal/environment_writer.go @@ -0,0 +1,29 @@ +package internal + +import ( + "io/ioutil" + "os" + "path/filepath" +) + +type EnvironmentWriter struct{} + +func NewEnvironmentWriter() EnvironmentWriter { + return EnvironmentWriter{} +} + +func (w EnvironmentWriter) Write(dir string, env map[string]string) error { + err := os.MkdirAll(dir, os.ModePerm) + if err != nil { + return err + } + + for key, value := range env { + err := ioutil.WriteFile(filepath.Join(dir, key), []byte(value), 0644) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/environment_writer_test.go b/internal/environment_writer_test.go new file mode 100644 index 00000000..58cc9678 --- /dev/null +++ b/internal/environment_writer_test.go @@ -0,0 +1,82 @@ +package internal_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/cloudfoundry/packit/internal" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func testEnvironmentWriter(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + tmpDir string + writer internal.EnvironmentWriter + ) + + it.Before(func() { + var err error + tmpDir, err = ioutil.TempDir("", "env-vars") + Expect(err).NotTo(HaveOccurred()) + + Expect(os.RemoveAll(tmpDir)).To(Succeed()) + + writer = internal.NewEnvironmentWriter() + }) + + it.After(func() { + Expect(os.RemoveAll(tmpDir)).To(Succeed()) + }) + + it("writes the given environment to a directory", func() { + err := writer.Write(tmpDir, map[string]string{ + "some-name": "some-content", + "other-name": "other-content", + }) + Expect(err).NotTo(HaveOccurred()) + + content, err := ioutil.ReadFile(filepath.Join(tmpDir, "some-name")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal("some-content")) + + content, err = ioutil.ReadFile(filepath.Join(tmpDir, "other-name")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal("other-content")) + }) + + context("failure cases", func() { + context("when the directory cannot be created", func() { + it.Before(func() { + Expect(os.MkdirAll(tmpDir, 0000)).To(Succeed()) + }) + + it("returns an error", func() { + err := writer.Write(filepath.Join(tmpDir, "sub-dir"), map[string]string{ + "some-name": "some-content", + "other-name": "other-content", + }) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("when the env file cannot be created", func() { + it.Before(func() { + Expect(os.MkdirAll(tmpDir, 0000)).To(Succeed()) + }) + + it("returns an error", func() { + err := writer.Write(tmpDir, map[string]string{ + "some-name": "some-content", + "other-name": "other-content", + }) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + }) +} diff --git a/internal/exit_handler.go b/internal/exit_handler.go new file mode 100644 index 00000000..7403fde9 --- /dev/null +++ b/internal/exit_handler.go @@ -0,0 +1,60 @@ +package internal + +import ( + "errors" + "fmt" + "io" + "os" +) + +var Fail = errors.New("failed") + +type Option func(handler ExitHandler) ExitHandler + +func WithExitHandlerStderr(stderr io.Writer) Option { + return func(handler ExitHandler) ExitHandler { + handler.stderr = stderr + return handler + } +} + +func WithExitHandlerExitFunc(e func(int)) Option { + return func(handler ExitHandler) ExitHandler { + handler.exitFunc = e + return handler + } +} + +type ExitHandler struct { + stderr io.Writer + exitFunc func(int) +} + +func NewExitHandler(options ...Option) ExitHandler { + handler := ExitHandler{ + stderr: os.Stderr, + exitFunc: os.Exit, + } + + for _, option := range options { + handler = option(handler) + } + + return handler +} + +func (h ExitHandler) Error(err error) { + fmt.Fprintln(h.stderr, err) + + var code int + switch err { + case Fail: + code = 100 + case nil: + code = 0 + default: + code = 1 + } + + h.exitFunc(code) +} diff --git a/internal/exit_handler_test.go b/internal/exit_handler_test.go new file mode 100644 index 00000000..fc876b1c --- /dev/null +++ b/internal/exit_handler_test.go @@ -0,0 +1,54 @@ +package internal_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/cloudfoundry/packit/internal" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func testExitHandler(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + exitCode int + stderr *bytes.Buffer + handler internal.ExitHandler + ) + + it.Before(func() { + stderr = bytes.NewBuffer([]byte{}) + + handler = internal.NewExitHandler(internal.WithExitHandlerStderr(stderr), internal.WithExitHandlerExitFunc(func(c int) { exitCode = c })) + }) + + it("prints the error message and exits with the right error code", func() { + handler.Error(errors.New("some-error-message")) + Expect(stderr).To(ContainSubstring("some-error-message")) + }) + + context("when the error is nil", func() { + it("exits with code 0", func() { + handler.Error(nil) + Expect(exitCode).To(Equal(0)) + }) + }) + + context("when the error is non-nil", func() { + it("exits with code 1", func() { + handler.Error(errors.New("failed")) + Expect(exitCode).To(Equal(1)) + }) + }) + + context("when the error is exit.Fail", func() { + it("exits with code 1", func() { + handler.Error(internal.Fail) + Expect(exitCode).To(Equal(100)) + }) + }) +} diff --git a/internal/init_test.go b/internal/init_test.go new file mode 100644 index 00000000..99dbc74f --- /dev/null +++ b/internal/init_test.go @@ -0,0 +1,74 @@ +package internal_test + +import ( + "fmt" + "reflect" + "testing" + + "github.com/BurntSushi/toml" + "github.com/onsi/gomega/types" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestUnitInternal(t *testing.T) { + suite := spec.New("packit/internal", spec.Report(report.Terminal{})) + suite("EnvironmentWriter", testEnvironmentWriter) + suite("ExitHandler", testExitHandler) + suite("TOMLWriter", testTOMLWriter) + suite.Run(t) +} + +func MatchTOML(expected interface{}) types.GomegaMatcher { + return &matchTOML{ + expected: expected, + } +} + +type matchTOML struct { + expected interface{} +} + +func (matcher *matchTOML) Match(actual interface{}) (success bool, err error) { + var e, a string + + switch eType := matcher.expected.(type) { + case string: + e = eType + case []byte: + e = string(eType) + default: + return false, fmt.Errorf("expected value must be []byte or string, received %T", matcher.expected) + } + + switch aType := actual.(type) { + case string: + a = aType + case []byte: + a = string(aType) + default: + return false, fmt.Errorf("actual value must be []byte or string, received %T", matcher.expected) + } + + var eValue map[string]interface{} + _, err = toml.Decode(e, &eValue) + if err != nil { + return false, err + } + + var aValue map[string]interface{} + _, err = toml.Decode(a, &aValue) + if err != nil { + return false, err + } + + return reflect.DeepEqual(eValue, aValue), nil +} + +func (matcher *matchTOML) FailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("Expected\n%s\nto match the TOML representation of\n%s", actual, matcher.expected) +} + +func (matcher *matchTOML) NegatedFailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("Expected\n%s\nnot to match the TOML representation of\n%s", actual, matcher.expected) +} diff --git a/internal/toml_writer.go b/internal/toml_writer.go new file mode 100644 index 00000000..041e2831 --- /dev/null +++ b/internal/toml_writer.go @@ -0,0 +1,23 @@ +package internal + +import ( + "os" + + "github.com/BurntSushi/toml" +) + +type TOMLWriter struct{} + +func NewTOMLWriter() TOMLWriter { + return TOMLWriter{} +} + +func (tw TOMLWriter) Write(path string, value interface{}) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer file.Close() + + return toml.NewEncoder(file).Encode(value) +} diff --git a/internal/toml_writer_test.go b/internal/toml_writer_test.go new file mode 100644 index 00000000..1fbbefe0 --- /dev/null +++ b/internal/toml_writer_test.go @@ -0,0 +1,68 @@ +package internal_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/cloudfoundry/packit/internal" + + . "github.com/onsi/gomega" + "github.com/sclevine/spec" +) + +func testTOMLWriter(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + tmpDir string + path string + tomlWriter internal.TOMLWriter + ) + it.Before(func() { + var err error + tmpDir, err = ioutil.TempDir("", "tomlWriter") + Expect(err).ToNot(HaveOccurred()) + + path = filepath.Join(tmpDir, "writer.toml") + }) + + it("writes the contents of a given object out to a .toml file", func() { + err := tomlWriter.Write(path, map[string]string{ + "some-field": "some-value", + "other-field": "other-value", + }) + Expect(err).ToNot(HaveOccurred()) + + tomlFileContents, err := ioutil.ReadFile(path) + Expect(err).ToNot(HaveOccurred()) + Expect(string(tomlFileContents)).To(MatchTOML(` +some-field = "some-value" +other-field = "other-value"`)) + }) + + context("failure cases", func() { + context("the .toml file cannot be created", func() { + it.Before(func() { + Expect(os.RemoveAll(tmpDir)).To(Succeed()) + }) + + it("returns an error", func() { + err := tomlWriter.Write(path, map[string]string{ + "some-field": "some-value", + "other-field": "other-value", + }) + Expect(err).To(MatchError(ContainSubstring("no such file or directory"))) + }) + }) + + context("the TOML data is invalid", func() { + + it("returns an error", func() { + err := tomlWriter.Write(path, map[int]int{1: 100}) + Expect(err).To(MatchError(ContainSubstring("cannot encode a map with non-string key type"))) + }) + }) + }) +} diff --git a/layers.go b/layers.go new file mode 100644 index 00000000..b55018e4 --- /dev/null +++ b/layers.go @@ -0,0 +1,58 @@ +package packit + +import ( + "os" + "path/filepath" +) + +type LayerType uint8 + +const ( + BuildLayer LayerType = iota + CacheLayer + LaunchLayer +) + +type Layer struct { + Path string `toml:"-"` + Name string `toml:"-"` + Build bool `toml:"build"` + Launch bool `toml:"launch"` + Cache bool `toml:"cache"` + SharedEnv Environment `toml:"-"` + BuildEnv Environment `toml:"-"` + LaunchEnv Environment `toml:"-"` + Metadata map[string]interface{} `toml:"metadata"` +} + +type Layers struct { + Path string +} + +func (l Layers) Get(name string, layerTypes ...LayerType) (Layer, error) { + layer := Layer{ + Path: filepath.Join(l.Path, name), + Name: name, + SharedEnv: NewEnvironment(), + BuildEnv: NewEnvironment(), + LaunchEnv: NewEnvironment(), + } + + for _, layerType := range layerTypes { + switch layerType { + case BuildLayer: + layer.Build = true + case CacheLayer: + layer.Cache = true + case LaunchLayer: + layer.Launch = true + } + } + + err := os.MkdirAll(layer.Path, os.ModePerm) + if err != nil { + return Layer{}, err + } + + return layer, nil +} diff --git a/layers_test.go b/layers_test.go new file mode 100644 index 00000000..a5480ec3 --- /dev/null +++ b/layers_test.go @@ -0,0 +1,90 @@ +package packit_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/cloudfoundry/packit" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func testLayers(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + layersDir string + layers packit.Layers + ) + + it.Before(func() { + var err error + layersDir, err = ioutil.TempDir("", "layers") + Expect(err).NotTo(HaveOccurred()) + + layers = packit.Layers{ + Path: layersDir, + } + }) + + it.After(func() { + Expect(os.RemoveAll(layersDir)).To(Succeed()) + }) + + context("Get", func() { + it("returns a layer with the given name", func() { + layer, err := layers.Get("some-layer") + Expect(err).NotTo(HaveOccurred()) + Expect(layer).To(Equal(packit.Layer{ + Name: "some-layer", + Path: filepath.Join(layersDir, "some-layer"), + SharedEnv: packit.NewEnvironment(), + BuildEnv: packit.NewEnvironment(), + LaunchEnv: packit.NewEnvironment(), + })) + }) + + context("when given flags", func() { + it("applies those flags to the layer", func() { + layer, err := layers.Get("some-layer", packit.LaunchLayer, packit.BuildLayer, packit.CacheLayer) + Expect(err).NotTo(HaveOccurred()) + Expect(layer).To(Equal(packit.Layer{ + Name: "some-layer", + Path: filepath.Join(layersDir, "some-layer"), + Launch: true, + Build: true, + Cache: true, + SharedEnv: packit.NewEnvironment(), + BuildEnv: packit.NewEnvironment(), + LaunchEnv: packit.NewEnvironment(), + })) + }) + }) + + it("creates a sub-directory with the given name", func() { + _, err := layers.Get("some-layer") + Expect(err).NotTo(HaveOccurred()) + Expect(filepath.Join(layersDir, "some-layer")).To(BeADirectory()) + }) + + context("failure cases", func() { + context("when the layers directory cannot be written to", func() { + it.Before(func() { + Expect(os.Chmod(layersDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(layersDir, os.ModePerm)).To(Succeed()) + }) + + it("returns an error", func() { + _, err := layers.Get("some-layer") + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + }) + }) +} diff --git a/option.go b/option.go new file mode 100644 index 00000000..932311b7 --- /dev/null +++ b/option.go @@ -0,0 +1,37 @@ +package packit + +type Config struct { + exitHandler ExitHandler + args []string + tomlWriter TOMLWriter + envWriter EnvironmentWriter +} + +type Option func(config Config) Config + +//go:generate faux --interface ExitHandler --output fakes/exit_handler.go +type ExitHandler interface { + Error(error) +} + +type TOMLWriter interface { + Write(path string, value interface{}) error +} + +type EnvironmentWriter interface { + Write(dir string, env map[string]string) error +} + +func WithExitHandler(exitHandler ExitHandler) Option { + return func(config Config) Config { + config.exitHandler = exitHandler + return config + } +} + +func WithArgs(args []string) Option { + return func(config Config) Config { + config.args = args + return config + } +}