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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
92
plugins/ps/functions_test.go
Normal file
92
plugins/ps/functions_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
1
tests.mk
1
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
94
tests/unit/ps-general-3.bats
Normal file
94
tests/unit/ps-general-3.bats
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user