diff --git a/docs/deployment/builders/buildpack-management.md b/docs/deployment/builders/buildpack-management.md index 293eb7479..4255960d2 100644 --- a/docs/deployment/builders/buildpack-management.md +++ b/docs/deployment/builders/buildpack-management.md @@ -10,6 +10,7 @@ buildpacks:list # List all buildpacks fo buildpacks:remove # Remove a buildpack set on the app buildpacks:report [] [] # Displays a buildpack report for one or more apps buildpacks:set [--index 1] # Set new app buildpack at a given position defaulting to the first buildpack if no index is specified +buildpacks:set --replace [ ...] # Replace the entire ordered buildpack list with the specified buildpacks ``` ## Usage @@ -86,6 +87,25 @@ If the index specified is larger than the number of buildpacks currently configu dokku buildpacks:set --index 99 node-js-app https://github.com/heroku/heroku-buildpack-ruby.git ``` +### Replacing the entire buildpack list + +To replace the complete ordered buildpack list in a single command, use the `--replace` flag. This is useful for tooling that needs to apply a full buildpack list atomically instead of running `buildpacks:clear` followed by multiple `buildpacks:add` calls. + +```shell +dokku buildpacks:set --replace node-js-app https://github.com/heroku/heroku-buildpack-ruby.git https://github.com/heroku/heroku-buildpack-nodejs.git +``` + +The buildpacks are executed in the order they are specified, and the previous list is discarded. If any specified buildpack is invalid, the existing list is left unchanged. + +A single buildpack may also be specified to replace the entire list with just that buildpack: + +```shell +dokku buildpacks:set --replace node-js-app https://github.com/heroku/heroku-buildpack-ruby.git +``` + +> [!NOTE] +> The `--replace` flag cannot be combined with the `--index` flag. To remove all buildpacks, use the `buildpacks:clear` command instead of `--replace` with no buildpacks. + ### Removing a buildpack > At least one of a buildpack or index must be specified diff --git a/plugins/buildpacks/buildpacks_test.go b/plugins/buildpacks/buildpacks_test.go index 30fe99dae..dea768639 100644 --- a/plugins/buildpacks/buildpacks_test.go +++ b/plugins/buildpacks/buildpacks_test.go @@ -139,3 +139,79 @@ func TestGetBuildpacksAppJSONEmptyArray(t *testing.T) { Expect(err).NotTo(HaveOccurred()) Expect(buildpacks).To(BeEmpty()) } + +func TestSetBuildpackListReplacesEntireList(t *testing.T) { + RegisterTestingT(t) + tmpDir := setupTestEnvironment(t) + defer teardownTestEnvironment(tmpDir) + + propDir := filepath.Join(tmpDir, "config", "buildpacks", "test-app") + Expect(os.MkdirAll(propDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(propDir, "buildpacks"), []byte("https://github.com/heroku/heroku-buildpack-nodejs.git\nhttps://github.com/heroku/heroku-buildpack-python.git\nhttps://github.com/heroku/heroku-buildpack-ruby.git\n"), 0644)).To(Succeed()) + + err := setBuildpackList("test-app", []string{"heroku/ruby", "heroku/nodejs"}, 0) + Expect(err).NotTo(HaveOccurred()) + + buildpacks, err := getBuildpacks("test-app") + Expect(err).NotTo(HaveOccurred()) + Expect(buildpacks).To(HaveLen(2)) + Expect(buildpacks[0]).To(Equal("https://github.com/heroku/heroku-buildpack-ruby.git")) + Expect(buildpacks[1]).To(Equal("https://github.com/heroku/heroku-buildpack-nodejs.git")) +} + +func TestSetBuildpackListSingleBuildpack(t *testing.T) { + RegisterTestingT(t) + tmpDir := setupTestEnvironment(t) + defer teardownTestEnvironment(tmpDir) + + propDir := filepath.Join(tmpDir, "config", "buildpacks", "test-app") + Expect(os.MkdirAll(propDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(propDir, "buildpacks"), []byte("https://github.com/heroku/heroku-buildpack-nodejs.git\nhttps://github.com/heroku/heroku-buildpack-python.git\n"), 0644)).To(Succeed()) + + err := setBuildpackList("test-app", []string{"heroku/ruby"}, 0) + Expect(err).NotTo(HaveOccurred()) + + buildpacks, err := getBuildpacks("test-app") + Expect(err).NotTo(HaveOccurred()) + Expect(buildpacks).To(HaveLen(1)) + Expect(buildpacks[0]).To(Equal("https://github.com/heroku/heroku-buildpack-ruby.git")) +} + +func TestSetBuildpackListInvalidLeavesListUnchanged(t *testing.T) { + RegisterTestingT(t) + tmpDir := setupTestEnvironment(t) + defer teardownTestEnvironment(tmpDir) + + propDir := filepath.Join(tmpDir, "config", "buildpacks", "test-app") + Expect(os.MkdirAll(propDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(propDir, "buildpacks"), []byte("https://github.com/heroku/heroku-buildpack-nodejs.git\n"), 0644)).To(Succeed()) + + err := setBuildpackList("test-app", []string{"heroku/ruby", "nodejs"}, 0) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("Invalid buildpack specified")) + + buildpacks, err := getBuildpacks("test-app") + Expect(err).NotTo(HaveOccurred()) + Expect(buildpacks).To(HaveLen(1)) + Expect(buildpacks[0]).To(Equal("https://github.com/heroku/heroku-buildpack-nodejs.git")) +} + +func TestSetBuildpackListRejectsIndex(t *testing.T) { + RegisterTestingT(t) + tmpDir := setupTestEnvironment(t) + defer teardownTestEnvironment(tmpDir) + + err := setBuildpackList("test-app", []string{"heroku/ruby"}, 1) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("--index")) +} + +func TestSetBuildpackListRejectsEmpty(t *testing.T) { + RegisterTestingT(t) + tmpDir := setupTestEnvironment(t) + defer teardownTestEnvironment(tmpDir) + + err := setBuildpackList("test-app", []string{}, 0) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("buildpacks:clear")) +} diff --git a/plugins/buildpacks/src/commands/commands.go b/plugins/buildpacks/src/commands/commands.go index 51eb30a15..fc5bb5e76 100644 --- a/plugins/buildpacks/src/commands/commands.go +++ b/plugins/buildpacks/src/commands/commands.go @@ -25,6 +25,7 @@ Additional commands:` buildpacks:remove , Remove a buildpack set on the app buildpacks:report [] [], Displays a buildpack report for one or more apps buildpacks:set [--index 1] , Set new app buildpack at a given position defaulting to the first buildpack if no index is specified + buildpacks:set --replace [ ...], Replace the entire ordered buildpack list with the specified buildpacks buildpacks:set-property [--global|] , Set or clear a buildpacks property for an app` ) diff --git a/plugins/buildpacks/src/subcommands/subcommands.go b/plugins/buildpacks/src/subcommands/subcommands.go index 428c0859b..8f110e20b 100644 --- a/plugins/buildpacks/src/subcommands/subcommands.go +++ b/plugins/buildpacks/src/subcommands/subcommands.go @@ -66,10 +66,11 @@ func main() { case "set": args := flag.NewFlagSet("buildpacks:set", flag.ExitOnError) index := args.Int("index", 0, "--index: the 1-based index of the URL in the list of URLs") + replace := args.Bool("replace", false, "--replace: replace the entire ordered buildpack list") args.Parse(os.Args[2:]) appName := args.Arg(0) - buildpack := args.Arg(1) - err = buildpacks.CommandSet(appName, buildpack, *index) + _, buildpackList := common.ShiftString(args.Args()) + err = buildpacks.CommandSet(appName, buildpackList, *index, *replace) case "set-property": args := flag.NewFlagSet("buildpacks:set-property", flag.ExitOnError) global := args.Bool("global", false, "--global: set a global property") diff --git a/plugins/buildpacks/subcommands.go b/plugins/buildpacks/subcommands.go index ffabb5eaa..f57b97c17 100644 --- a/plugins/buildpacks/subcommands.go +++ b/plugins/buildpacks/subcommands.go @@ -162,12 +162,24 @@ func CommandReport(appName string, format string, infoFlag string) error { } // CommandSet implements buildpacks:set -func CommandSet(appName string, buildpack string, index int) error { +func CommandSet(appName string, buildpacks []string, index int, replace bool) error { if err := common.VerifyAppName(appName); err != nil { return err } - buildpack, err := validBuildpackURL(buildpack) + if replace { + return setBuildpackList(appName, buildpacks, index) + } + + if len(buildpacks) > 1 { + return errors.New("Setting multiple buildpacks requires the --replace flag") + } + + if len(buildpacks) == 0 { + return errors.New("Must specify a buildpack") + } + + buildpack, err := validBuildpackURL(buildpacks[0]) if err != nil { return err } @@ -179,6 +191,28 @@ func CommandSet(appName string, buildpack string, index int) error { return common.PropertyListSet("buildpacks", appName, "buildpacks", buildpack, index) } +// setBuildpackList atomically replaces the entire ordered buildpack list +func setBuildpackList(appName string, buildpacks []string, index int) error { + if index != 0 { + return errors.New("The --index flag cannot be used with --replace") + } + + if len(buildpacks) == 0 { + return errors.New("Must specify at least one buildpack, use buildpacks:clear to remove all buildpacks") + } + + validated := make([]string, 0, len(buildpacks)) + for _, buildpack := range buildpacks { + url, err := validBuildpackURL(buildpack) + if err != nil { + return err + } + validated = append(validated, url) + } + + return common.PropertyListWrite("buildpacks", appName, "buildpacks", validated) +} + // CommandSetProperty implements buildpacks:set-property func CommandSetProperty(appName string, property string, value string) error { oldStack := "" diff --git a/tests/unit/buildpacks.bats b/tests/unit/buildpacks.bats index 095884024..e52e700fb 100644 --- a/tests/unit/buildpacks.bats +++ b/tests/unit/buildpacks.bats @@ -184,6 +184,75 @@ teardown() { assert_output "https://github.com/heroku/heroku-buildpack-golang.git https://github.com/heroku/heroku-buildpack-python.git https://github.com/heroku/heroku-buildpack-php.git" } +@test "(buildpacks) buildpacks:set --replace - failure" { + run /bin/bash -c "dokku buildpacks:set --replace $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "buildpacks:clear" + + run /bin/bash -c "dokku buildpacks:set --replace --index 1 $TEST_APP heroku/nodejs" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "--index" + + run /bin/bash -c "dokku buildpacks:set $TEST_APP heroku/nodejs heroku/ruby" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "--replace" +} + +@test "(buildpacks) buildpacks:set --replace - atomicity" { + run /bin/bash -c "dokku buildpacks:add $TEST_APP heroku/nodejs" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku buildpacks:set --replace $TEST_APP heroku/ruby nodejs" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "dokku --quiet buildpacks:list $TEST_APP | xargs" + echo "output: $output" + echo "status: $status" + assert_output "https://github.com/heroku/heroku-buildpack-nodejs.git" +} + +@test "(buildpacks) buildpacks:set --replace - success" { + run /bin/bash -c "dokku buildpacks:add $TEST_APP heroku/nodejs" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku buildpacks:add $TEST_APP heroku/python" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku buildpacks:set --replace $TEST_APP heroku/ruby heroku/golang heroku/php" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku --quiet buildpacks:list $TEST_APP | xargs" + echo "output: $output" + echo "status: $status" + assert_output "https://github.com/heroku/heroku-buildpack-ruby.git https://github.com/heroku/heroku-buildpack-golang.git https://github.com/heroku/heroku-buildpack-php.git" + + run /bin/bash -c "dokku buildpacks:set --replace $TEST_APP heroku/nodejs" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku --quiet buildpacks:list $TEST_APP | xargs" + echo "output: $output" + echo "status: $status" + assert_output "https://github.com/heroku/heroku-buildpack-nodejs.git" +} + @test "(buildpacks) buildpacks:set-property" { run /bin/bash -c "dokku buildpacks:set-property --global stack gliderlabs/herokuish:latest" echo "output: $output"