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.
This commit is contained in:
Jose Diaz-Gonzalez
2026-09-02 14:55:16 -04:00
parent 63351209f8
commit db45f8608b
10 changed files with 391 additions and 21 deletions

View File

@@ -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:

View File

@@ -4,15 +4,17 @@
> New as of 0.3.14, Enhanced in 0.7.0
```
ps:inspect <app> # Displays a sanitized version of docker inspect for an app
ps:rebuild [--parallel count] [--all|<app>] # Rebuilds an app from source
ps:report [<app>] [<flag>] # Displays a process report for one or more apps
ps:restart [--parallel count] [--all|<app>] [<process-name>] # Restart an app
ps:restore [<app>] # Start previously running apps e.g. after reboot
ps:scale [--skip-deploy] [--format stdout|json] <app> [<proc>=<count>...] # Get/Set how many instances of a given process to run
ps:set <app> <key> <value> # Set or clear a ps property for an app
ps:start [--parallel count] [--all|<app>] # Start an app
ps:stop [--parallel count] [--all|<app>] # Stop an app
ps:inspect <app> # Displays a sanitized version of docker inspect for an app
ps:rebuild [--parallel count] [--all|<app>] # Rebuilds an app from source
ps:report [<app>] [<flag>] # Displays a process report for one or more apps
ps:restart [--parallel count] [--all|<app>] [<process-name>] # Restart an app
ps:restore [<app>] # Start previously running apps e.g. after reboot
ps:scale [--skip-deploy] [--format stdout|json] <app> [<proc>=<count>...] # Get/Set how many instances of a given process to run
ps:scale --replace [--skip-deploy] <app> <proc>=<count> [<proc>=<count>...] # Replace the formation and scale unspecified process types to zero
ps:scale --clear [--skip-deploy] <app> # Reset the formation to the default scale
ps:set <app> <key> <value> # Set or clear a ps property for an app
ps:start [--parallel count] [--all|<app>] # Start an app
ps:stop [--parallel count] [--all|<app>] # 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.

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -24,6 +24,8 @@ Additional commands:`
ps:restart [--parallel count] [--all|<app>] [<process-name>], Restart an app
ps:restore [<app>], Start previously running apps e.g. after reboot
ps:scale [--skip-deploy] [--format stdout|json] <app> [<proc>=<count>...], Get/Set how many instances of a given process to run
ps:scale --replace [--skip-deploy] <app> <proc>=<count> [<proc>=<count>...], Replace the formation and scale unspecified process types to zero
ps:scale --clear [--skip-deploy] <app>, Reset the formation to the default scale
ps:set <app> <key> <value>, Set or clear a ps property for an app
ps:start [--parallel count] [--all|<app>], Start an app
ps:stop [--parallel count] [--all|<app>], Stop an app

View File

@@ -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")

View File

@@ -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,
})

View File

@@ -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

View File

@@ -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"

View File

@@ -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"
}