From db45f8608b33f6cdabfe0b42df970ba6d45fd328 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 2 Sep 2026 14:55:16 -0400 Subject: [PATCH] feat: add --clear and --replace flags to ps:scale The `ps:scale` command merges the specified process types into the existing formation, so replacing an entire formation from the CLI required reading the current scale, diffing it, and issuing a second call to zero out each unlisted process type. The `--replace` flag treats the specified process types as the whole formation and sets the process count for every unspecified process type to zero, while the `--clear` flag resets the formation to the scale a newly created app has. The two flags cannot be combined, `--replace` requires at least one process type, and `--clear` accepts none. Process types cleared this way are now scaled down as part of the same command rather than being left running with a stale formation. --- docs/development/plugin-triggers.md | 2 +- docs/processes/process-management.md | 46 ++++++++--- plugins/ps/functions.go | 47 ++++++++++++ plugins/ps/functions_test.go | 92 ++++++++++++++++++++++ plugins/ps/src/commands/commands.go | 2 + plugins/ps/src/subcommands/subcommands.go | 11 ++- plugins/ps/subcommands.go | 65 +++++++++++++--- tests.mk | 1 + tests/unit/ps-general-1.bats | 52 +++++++++++++ tests/unit/ps-general-3.bats | 94 +++++++++++++++++++++++ 10 files changed, 391 insertions(+), 21 deletions(-) create mode 100644 plugins/ps/functions_test.go create mode 100644 tests/unit/ps-general-3.bats diff --git a/docs/development/plugin-triggers.md b/docs/development/plugin-triggers.md index c9aff33df..247239ed8 100644 --- a/docs/development/plugin-triggers.md +++ b/docs/development/plugin-triggers.md @@ -2396,7 +2396,7 @@ set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x ### `ps-set-scale` -- Description: Sets the scale for an app based on a specified formation (process-type=quantity). Any unspecified process types will be left as is. +- Description: Sets the scale for an app based on a specified formation (process-type=quantity). Any unspecified process types will be left as is, or have their process count set to zero when `$CLEAR_EXISTING` is `true`. - Invoked by: - Arguments: `$APP $SKIP_DEPLOY $CLEAR_EXISTING [$PROCESS_TUPLE...]` - Example: diff --git a/docs/processes/process-management.md b/docs/processes/process-management.md index 80e219291..1a69b49f9 100644 --- a/docs/processes/process-management.md +++ b/docs/processes/process-management.md @@ -4,15 +4,17 @@ > New as of 0.3.14, Enhanced in 0.7.0 ``` -ps:inspect # Displays a sanitized version of docker inspect for an app -ps:rebuild [--parallel count] [--all|] # Rebuilds an app from source -ps:report [] [] # Displays a process report for one or more apps -ps:restart [--parallel count] [--all|] [] # Restart an app -ps:restore [] # Start previously running apps e.g. after reboot -ps:scale [--skip-deploy] [--format stdout|json] [=...] # Get/Set how many instances of a given process to run -ps:set # Set or clear a ps property for an app -ps:start [--parallel count] [--all|] # Start an app -ps:stop [--parallel count] [--all|] # Stop an app +ps:inspect # Displays a sanitized version of docker inspect for an app +ps:rebuild [--parallel count] [--all|] # Rebuilds an app from source +ps:report [] [] # Displays a process report for one or more apps +ps:restart [--parallel count] [--all|] [] # Restart an app +ps:restore [] # Start previously running apps e.g. after reboot +ps:scale [--skip-deploy] [--format stdout|json] [=...] # Get/Set how many instances of a given process to run +ps:scale --replace [--skip-deploy] = [=...] # Replace the formation and scale unspecified process types to zero +ps:scale --clear [--skip-deploy] # Reset the formation to the default scale +ps:set # Set or clear a ps property for an app +ps:start [--parallel count] [--all|] # Start an app +ps:stop [--parallel count] [--all|] # Stop an app ``` ## Usage @@ -252,6 +254,32 @@ If desired, the corresponding deploy will be skipped by using the `--skip-deploy dokku ps:scale --skip-deploy node-js-app web=1 ``` +#### Replacing the formation + +By default, `ps:scale` merges the specified process types into the existing formation, leaving any unspecified process type at its current count. The `--replace` flag instead treats the specified process types as the entire formation, setting the process count for every unspecified process type to zero: + +```shell +dokku ps:scale --replace node-js-app web=1 +``` + +An app scaled to `web=2 worker=3` before the above command will be scaled to `web=1 worker=0` after it, with the `worker` containers stopped as part of the same command. + +> [!NOTE] +> At least one process type must be specified when using the `--replace` flag. Use the `--clear` flag to reset the formation instead. + +#### Clearing the formation + +The `--clear` flag resets the formation to the scale a newly created app has, a single `web` process, with the process count for every other process type set to zero: + +```shell +dokku ps:scale --clear node-js-app +``` + +Apps with a `Procfile` that does not specify a `web` process type will have the process count for every process type set to zero. + +> [!NOTE] +> Process types cannot be specified when using the `--clear` flag, nor can it be combined with the `--replace` flag. + #### Manually managing process scaling > Using a `formation` key in an `app.json` file with _any_ `quantity` specified disables the ability to use `ps:scale` for scaling. All processes not specified in the `app.json` will have their process count set to zero. diff --git a/plugins/ps/functions.go b/plugins/ps/functions.go index a32e0b38d..6b48c1a49 100644 --- a/plugins/ps/functions.go +++ b/plugins/ps/functions.go @@ -14,11 +14,54 @@ import ( dockeroptions "github.com/dokku/dokku/plugins/docker-options" ) +// appendClearedFormations appends a zero quantity formation for every existing +// process type that was not specified, ensuring cleared process types are both +// persisted and scaled down instead of being silently dropped +func appendClearedFormations(formations FormationSlice, existingFormations FormationSlice) FormationSlice { + specifiedProcessTypes := map[string]bool{} + for _, formation := range formations { + specifiedProcessTypes[formation.ProcessType] = true + } + + for _, existingFormation := range existingFormations { + if specifiedProcessTypes[existingFormation.ProcessType] { + continue + } + + specifiedProcessTypes[existingFormation.ProcessType] = true + formations = append(formations, &Formation{ + ProcessType: existingFormation.ProcessType, + Quantity: 0, + }) + } + + return formations +} + func canScaleApp(appName string) bool { canScale := common.PropertyGetDefault("ps", appName, "can-scale", "true") return common.ToBool(canScale) } +// defaultProcessTuples returns the process tuples an app is created with, scaling +// the web process to one when it is valid for the app and nothing otherwise +func defaultProcessTuples(appName string) ([]string, error) { + if !hasProcfile(appName) { + return []string{"web=1"}, nil + } + + validProcessTypes, err := processesInProcfile(getProcessSpecificProcfilePath(appName)) + if err != nil { + return []string{}, err + } + + if !validProcessTypes["web"] { + return []string{}, nil + } + + return []string{"web=1"}, nil +} + func getProcfileCommand(appName string, procfilePath string, processType string, port int) (string, error) { if !common.FileExists(procfilePath) { return "", errors.New("No procfile found") @@ -308,6 +351,10 @@ func scaleSet(input scaleSetInput) error { return err } + if input.clearExisting { + formations = appendClearedFormations(formations, existingFormations) + } + if err := updateScale(input.appName, input.clearExisting, formations); err != nil { return err } diff --git a/plugins/ps/functions_test.go b/plugins/ps/functions_test.go new file mode 100644 index 000000000..793660743 --- /dev/null +++ b/plugins/ps/functions_test.go @@ -0,0 +1,92 @@ +package ps + +import ( + "os" + "testing" +) + +// assertFormations fails the test when the specified formations do not match +// the expected process type and quantity pairs, in order +func assertFormations(t *testing.T, formations FormationSlice, expected []Formation) { + t.Helper() + + if len(formations) != len(expected) { + t.Fatalf("expected %d formations, got %d", len(expected), len(formations)) + } + + for i, formation := range formations { + if formation.ProcessType != expected[i].ProcessType || formation.Quantity != expected[i].Quantity { + t.Fatalf("expected %s=%d at index %d, got %s=%d", expected[i].ProcessType, expected[i].Quantity, i, formation.ProcessType, formation.Quantity) + } + } +} + +func TestAppendClearedFormations(t *testing.T) { + testCases := []struct { + name string + processTuples []string + existingTuple []string + expected []Formation + }{ + { + name: "no existing formations", + processTuples: []string{"web=1"}, + existingTuple: []string{}, + expected: []Formation{{ProcessType: "web", Quantity: 1}}, + }, + { + name: "zeroes out unspecified process types", + processTuples: []string{"web=1"}, + existingTuple: []string{"web=2", "worker=3"}, + expected: []Formation{{ProcessType: "web", Quantity: 1}, {ProcessType: "worker", Quantity: 0}}, + }, + { + name: "does not overwrite specified process types", + processTuples: []string{"web=1", "worker=2"}, + existingTuple: []string{"web=4", "worker=5"}, + expected: []Formation{{ProcessType: "web", Quantity: 1}, {ProcessType: "worker", Quantity: 2}}, + }, + { + name: "zeroes out every process type when none are specified", + processTuples: []string{}, + existingTuple: []string{"web=2", "worker=3"}, + expected: []Formation{{ProcessType: "web", Quantity: 0}, {ProcessType: "worker", Quantity: 0}}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + formations, err := parseProcessTuples(testCase.processTuples) + if err != nil { + t.Fatalf("unexpected error parsing process tuples: %v", err) + } + + existingFormations, err := parseProcessTuples(testCase.existingTuple) + if err != nil { + t.Fatalf("unexpected error parsing existing process tuples: %v", err) + } + + assertFormations(t, appendClearedFormations(formations, existingFormations), testCase.expected) + }) + } +} + +func TestDefaultProcessTuplesWithoutProcfile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "ps-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + t.Setenv("DOKKU_LIB_ROOT", tmpDir) + t.Setenv("DOKKU_PID", "") + + processTuples, err := defaultProcessTuples("test-app") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(processTuples) != 1 || processTuples[0] != "web=1" { + t.Fatalf("expected [web=1], got %v", processTuples) + } +} diff --git a/plugins/ps/src/commands/commands.go b/plugins/ps/src/commands/commands.go index 6063bfcf5..49e43dd32 100644 --- a/plugins/ps/src/commands/commands.go +++ b/plugins/ps/src/commands/commands.go @@ -24,6 +24,8 @@ Additional commands:` ps:restart [--parallel count] [--all|] [], Restart an app ps:restore [], Start previously running apps e.g. after reboot ps:scale [--skip-deploy] [--format stdout|json] [=...], Get/Set how many instances of a given process to run + ps:scale --replace [--skip-deploy] = [=...], Replace the formation and scale unspecified process types to zero + ps:scale --clear [--skip-deploy] , Reset the formation to the default scale ps:set , Set or clear a ps property for an app ps:start [--parallel count] [--all|], Start an app ps:stop [--parallel count] [--all|], Stop an app diff --git a/plugins/ps/src/subcommands/subcommands.go b/plugins/ps/src/subcommands/subcommands.go index 91e41c5cb..b9aa39229 100644 --- a/plugins/ps/src/subcommands/subcommands.go +++ b/plugins/ps/src/subcommands/subcommands.go @@ -65,11 +65,20 @@ func main() { case "scale": args := flag.NewFlagSet("ps:scale", flag.ExitOnError) skipDeploy := args.Bool("skip-deploy", false, "--skip-deploy: skip deploy of the app") + clearFormation := args.Bool("clear", false, "--clear: reset the formation to the default scale") + replace := args.Bool("replace", false, "--replace: replace the formation and scale unspecified process types to zero") format := args.String("format", "stdout", "format: [ stdout | json ]") args.Parse(os.Args[2:]) appName := args.Arg(0) _, processTuples := common.ShiftString(args.Args()) - err = ps.CommandScale(appName, *skipDeploy, *format, processTuples) + err = ps.CommandScale(ps.CommandScaleInput{ + AppName: appName, + Clear: *clearFormation, + Format: *format, + ProcessTuples: processTuples, + Replace: *replace, + SkipDeploy: *skipDeploy, + }) case "set": args := flag.NewFlagSet("ps:set", flag.ExitOnError) global := args.Bool("global", false, "--global: set a global property") diff --git a/plugins/ps/subcommands.go b/plugins/ps/subcommands.go index a1bfaf2e2..6c726158a 100644 --- a/plugins/ps/subcommands.go +++ b/plugins/ps/subcommands.go @@ -157,25 +157,70 @@ func CommandRetire(appName string) error { return err } +// CommandScaleInput is the input for the CommandScale function +type CommandScaleInput struct { + // AppName is the name of the app to scale + AppName string + + // Clear is a flag to reset the formation to the default scale + Clear bool + + // Format is the format to display the formation in + Format string + + // ProcessTuples is a list of process tuples to scale + ProcessTuples []string + + // Replace is a flag to replace the formation with the specified process tuples + Replace bool + + // SkipDeploy is a flag to skip the deploy phase + SkipDeploy bool +} + // CommandScale gets or sets how many instances of a given process to run -func CommandScale(appName string, skipDeploy bool, format string, processTuples []string) error { - if err := common.VerifyAppName(appName); err != nil { +func CommandScale(input CommandScaleInput) error { + if err := common.VerifyAppName(input.AppName); err != nil { return err } - if len(processTuples) == 0 { - return scaleReport(appName, format) + if input.Clear && input.Replace { + return errors.New("The --clear and --replace flags cannot be specified together") } - if !canScaleApp(appName) { - return fmt.Errorf("App %s contains an app.json file with a formations key and cannot be manually scaled", appName) + if input.Clear && len(input.ProcessTuples) > 0 { + return errors.New("Process types cannot be specified when using the --clear flag, use --replace to set the entire formation") + } + + if input.Replace && len(input.ProcessTuples) == 0 { + return errors.New("Must specify at least one process type when using the --replace flag, use --clear to reset the formation") + } + + if !input.Clear && len(input.ProcessTuples) == 0 { + return scaleReport(input.AppName, input.Format) + } + + if !canScaleApp(input.AppName) { + return fmt.Errorf("App %s contains an app.json file with a formations key and cannot be manually scaled", input.AppName) + } + + processTuples := input.ProcessTuples + if input.Clear { + var err error + processTuples, err = defaultProcessTuples(input.AppName) + if err != nil { + return err + } + + common.LogInfo1(fmt.Sprintf("Resetting %s processes to the default scale", input.AppName)) + } else { + common.LogInfo1(fmt.Sprintf("Scaling %s processes: %s", input.AppName, strings.Join(processTuples, " "))) } - common.LogInfo1(fmt.Sprintf("Scaling %s processes: %s", appName, strings.Join(processTuples, " "))) return scaleSet(scaleSetInput{ - appName: appName, - skipDeploy: skipDeploy, - clearExisting: false, + appName: input.AppName, + skipDeploy: input.SkipDeploy, + clearExisting: input.Clear || input.Replace, processTuples: processTuples, deployOnlyChanged: true, }) diff --git a/tests.mk b/tests.mk index bc6463c49..1548c8f52 100644 --- a/tests.mk +++ b/tests.mk @@ -185,6 +185,7 @@ go-tests: @$(MAKE) go-test-plugin PLUGIN_NAME=docker-options @$(MAKE) go-test-plugin PLUGIN_NAME=logs @$(MAKE) go-test-plugin PLUGIN_NAME=network + @$(MAKE) go-test-plugin PLUGIN_NAME=ps @$(MAKE) go-test-plugin PLUGIN_NAME=buildpacks @$(MAKE) go-test-plugin PLUGIN_NAME=scheduler-k3s @$(MAKE) go-test-plugin PLUGIN_NAME=storage diff --git a/tests/unit/ps-general-1.bats b/tests/unit/ps-general-1.bats index 20a00858d..1f17444bf 100644 --- a/tests/unit/ps-general-1.bats +++ b/tests/unit/ps-general-1.bats @@ -305,6 +305,58 @@ worker=1" >/var/lib/dokku/config/ps/$TEST_APP/scale assert_output "[]" } +@test "(ps:scale) --replace" { + echo "web=4 +worker=1" >/var/lib/dokku/config/ps/$TEST_APP/scale + + run /bin/bash -c "dokku ps:scale --replace $TEST_APP web=2" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku --quiet ps:scale $TEST_APP" + output=$(echo "$output" | tr -s " ") + echo "output: ($output)" + echo "status: $status" + assert_output $'web: 2\nworker: 0' +} + +@test "(ps:scale) --clear" { + echo "web=4 +worker=1" >/var/lib/dokku/config/ps/$TEST_APP/scale + + run /bin/bash -c "dokku ps:scale --clear $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku --quiet ps:scale $TEST_APP" + output=$(echo "$output" | tr -s " ") + echo "output: ($output)" + echo "status: $status" + assert_output $'web: 1\nworker: 0' +} + +@test "(ps:scale) --clear and --replace argument validation" { + run /bin/bash -c "dokku ps:scale --replace $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "Must specify at least one process type when using the --replace flag" + + run /bin/bash -c "dokku ps:scale --clear $TEST_APP web=1" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "Process types cannot be specified when using the --clear flag" + + run /bin/bash -c "dokku ps:scale --clear --replace $TEST_APP web=1" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "The --clear and --replace flags cannot be specified together" +} + @test "(ps) handle windows newlines in procfile" { run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP procfile_line_endings_to_windows echo "output: $output" diff --git a/tests/unit/ps-general-3.bats b/tests/unit/ps-general-3.bats new file mode 100644 index 000000000..63984274b --- /dev/null +++ b/tests/unit/ps-general-3.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + create_app +} + +teardown() { + destroy_app + global_teardown +} + +@test "(ps:scale) --replace scales down unspecified processes" { + run /bin/bash -c "dokku builder-herokuish:set $TEST_APP allowed true" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale $TEST_APP worker=1" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "docker ps --filter label=com.dokku.app-name=$TEST_APP --filter label=com.dokku.process-type=worker --format '{{.Names}}' | wc -l" + output=$(echo "$output" | tr -d " ") + echo "output: ($output)" + echo "status: $status" + assert_output "1" + + run /bin/bash -c "dokku ps:scale --replace $TEST_APP web=1" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku --quiet ps:scale $TEST_APP" + output=$(echo "$output" | tr -s " ") + echo "output: ($output)" + echo "status: $status" + assert_output $'cron: 0\ncustom: 0\nrelease: 0\ntask: 0\nweb: 1\nworker: 0' + + sleep 2 + + run /bin/bash -c "dokku ps:retire" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "docker ps --filter label=com.dokku.app-name=$TEST_APP --filter label=com.dokku.process-type=worker --format '{{.Names}}' | wc -l" + output=$(echo "$output" | tr -d " ") + echo "output: ($output)" + echo "status: $status" + assert_output "0" +} + +@test "(ps:scale) --clear without a web process type" { + run /bin/bash -c "dokku ps:scale $TEST_APP web=0 console=0" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python-console-only + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:scale --skip-deploy $TEST_APP console=2" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku --quiet ps:scale $TEST_APP" + output=$(echo "$output" | tr -s " ") + echo "output: ($output)" + echo "status: $status" + assert_output "console: 2" + + run /bin/bash -c "dokku ps:scale --clear $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku --quiet ps:scale $TEST_APP" + output=$(echo "$output" | tr -s " ") + echo "output: ($output)" + echo "status: $status" + assert_output "console: 0" +}