Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/dokku_ps_scale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# dokku_ps_scale

Manages the process scale for a given dokku application

## Scale web and worker processes

```yaml
dokku_ps_scale:
app: hello-world
scale:
web: 2
worker: 1
skip_deploy: false
```

## Scale web and worker processes without deploy

```yaml
dokku_ps_scale:
app: hello-world
scale:
web: 4
worker: 4
skip_deploy: true
```
256 changes: 255 additions & 1 deletion tasks/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,35 @@ func skipIfDockerLinkUnsupportedT(t *testing.T) {
}
}

// getCurrentContainerIDs reads the container IDs from dokku's internal
// CONTAINER files (e.g., /home/dokku/APP/CONTAINER.web.1) which are the
// authoritative source for the current deployment's containers.
func getCurrentContainerIDs(appName, processType string) ([]string, error) {
scale, err := getPsScale(appName)
if err != nil {
return nil, err
}
count, ok := scale[processType]
if !ok || count == 0 {
return nil, nil
}
var ids []string
for i := 1; i <= count; i++ {
result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "cat",
Args: []string{fmt.Sprintf("/home/dokku/%s/CONTAINER.%s.%d", appName, processType, i)},
})
if err != nil {
continue
}
id := strings.TrimSpace(result.StdoutContents())
if id != "" {
ids = append(ids, id)
}
}
return ids, nil
}

func TestIntegrationAppCreateAndDestroy(t *testing.T) {
skipIfNoDokkuT(t)

Expand Down Expand Up @@ -699,7 +728,7 @@ func TestIntegrationGitFromImage(t *testing.T) {

task := GitFromImageTask{
App: appName,
Image: "nginx:latest",
Image: "dokku/smoke-test-app:dockerfile",
State: StateDeployed,
}
result := task.Execute()
Expand Down Expand Up @@ -976,6 +1005,179 @@ func TestIntegrationResourceReserveProcessType(t *testing.T) {
}
}

func TestIntegrationPsScale(t *testing.T) {
skipIfNoDokkuT(t)

appName := "omakase-test-psscale"

// ensure clean state
destroyApp(appName)
createApp(appName)
defer destroyApp(appName)

// deploy the smoke test app so we have running containers to scale
deployTask := GitFromImageTask{
App: appName,
Image: "dokku/smoke-test-app:dockerfile",
State: StateDeployed,
}
deployResult := deployTask.Execute()
if deployResult.Error != nil {
t.Fatalf("failed to deploy app: %v", deployResult.Error)
}

// verify initial web container count is 1 via docker ps
initialContainers, err := getCurrentContainerIDs(appName, "web")
if err != nil {
t.Fatalf("failed to list containers: %v", err)
}
if len(initialContainers) != 1 {
t.Fatalf("expected 1 initial web container, got %d", len(initialContainers))
}

// verify the initial container is running via docker inspect
inspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"inspect", "--format", "{{.State.Running}}", initialContainers[0]},
})
if err != nil {
t.Fatalf("failed to inspect initial container: %v", err)
}
if strings.TrimSpace(inspectResult.StdoutContents()) != "true" {
t.Errorf("expected initial container to be running")
}

// scale web to 2
scaleTask := PsScaleTask{
App: appName,
Scale: map[string]int{"web": 2},
State: StatePresent,
}
result := scaleTask.Execute()
if result.Error != nil {
t.Fatalf("failed to scale app: %v", result.Error)
}
if result.State != StatePresent {
t.Errorf("expected state 'present', got '%s'", result.State)
}
if !result.Changed {
t.Error("expected changed=true for scaling up")
}

// clean up old containers and verify 2 web containers via docker ps
scaledContainers, err := getCurrentContainerIDs(appName, "web")
if err != nil {
t.Fatalf("failed to list containers after scale: %v", err)
}
if len(scaledContainers) != 2 {
t.Fatalf("expected 2 web containers after scaling, got %d", len(scaledContainers))
}

// verify each container is running via docker inspect
for _, containerID := range scaledContainers {
inspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"inspect", "--format", "{{.State.Running}}", containerID},
})
if err != nil {
t.Fatalf("failed to inspect container %s: %v", containerID, err)
}
if strings.TrimSpace(inspectResult.StdoutContents()) != "true" {
t.Errorf("expected container %s to be running", containerID)
}
}

// scaling again should be idempotent
result = scaleTask.Execute()
if result.Error != nil {
t.Fatalf("idempotent scale failed: %v", result.Error)
}
if result.Changed {
t.Error("expected changed=false for unchanged scale")
}

// scale back to 1
scaleDownTask := PsScaleTask{
App: appName,
Scale: map[string]int{"web": 1},
State: StatePresent,
}
result = scaleDownTask.Execute()
if result.Error != nil {
t.Fatalf("failed to scale down: %v", result.Error)
}
if !result.Changed {
t.Error("expected changed=true for scaling down")
}

// clean up old containers and verify 1 web container after scale down
finalContainers, err := getCurrentContainerIDs(appName, "web")
if err != nil {
t.Fatalf("failed to list containers after scale down: %v", err)
}
if len(finalContainers) != 1 {
t.Fatalf("expected 1 web container after scale down, got %d", len(finalContainers))
}

// verify the final container is running via docker inspect
inspectResult, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"inspect", "--format", "{{.State.Running}}", finalContainers[0]},
})
if err != nil {
t.Fatalf("failed to inspect final container: %v", err)
}
if strings.TrimSpace(inspectResult.StdoutContents()) != "true" {
t.Errorf("expected final container to be running")
}
}

func TestIntegrationPsScaleSkipDeploy(t *testing.T) {
skipIfNoDokkuT(t)

appName := "omakase-test-psscale-sd"

destroyApp(appName)
createApp(appName)
defer destroyApp(appName)

// scale with skip_deploy on an undeployed app
scaleTask := PsScaleTask{
App: appName,
Scale: map[string]int{"web": 2, "worker": 1},
SkipDeploy: true,
State: StatePresent,
}
result := scaleTask.Execute()
if result.Error != nil {
t.Fatalf("failed to scale with skip_deploy: %v", result.Error)
}
if !result.Changed {
t.Error("expected changed=true for initial scale")
}

// verify the scale was set correctly
scale, err := getPsScale(appName)
if err != nil {
t.Fatalf("failed to get ps scale: %v", err)
}
if scale["web"] != 2 {
t.Errorf("expected web=2, got web=%d", scale["web"])
}
if scale["worker"] != 1 {
t.Errorf("expected worker=1, got worker=%d", scale["worker"])
}

// idempotent
result = scaleTask.Execute()
if result.Error != nil {
t.Fatalf("idempotent scale failed: %v", result.Error)
}
if result.Changed {
t.Error("expected changed=false for unchanged scale")
}
}

func TestIntegrationMultiTaskWorkflow(t *testing.T) {
skipIfNoDokkuT(t)

Expand Down Expand Up @@ -1170,6 +1372,58 @@ func TestIntegrationServiceLinkAndUnlink(t *testing.T) {
t.Error("expected service container to have a hostname set")
}

// deploy the smoke test app so we can verify the link inside a running container
deployTask := GitFromImageTask{
App: appName,
Image: "dokku/smoke-test-app:dockerfile",
State: StateDeployed,
}
deployResult := deployTask.Execute()
if deployResult.Error != nil {
t.Fatalf("failed to deploy app: %v", deployResult.Error)
}

// find the running app container
appContainerResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"ps", "--filter", fmt.Sprintf("label=com.dokku.app-name=%s", appName), "--filter", "label=com.dokku.process-type=web", "--format", "{{.ID}}"},
})
if err != nil {
t.Fatalf("failed to find app container: %v", err)
}
appContainerID := strings.TrimSpace(appContainerResult.StdoutContents())
if appContainerID == "" {
t.Fatal("expected at least one running app container after deploy")
}
// take the first container if multiple lines
appContainerIDs := strings.Split(appContainerID, "\n")
appContainerID = appContainerIDs[0]

// verify the app container is running via docker inspect
appInspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"inspect", "--format", "{{.State.Running}}", appContainerID},
})
if err != nil {
t.Fatalf("failed to inspect app container: %v", err)
}
if strings.TrimSpace(appInspectResult.StdoutContents()) != "true" {
t.Error("expected app container to be running")
}

// verify REDIS_URL is present inside the running container via docker exec
execResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"exec", appContainerID, "env"},
})
if err != nil {
t.Fatalf("failed to exec env in app container: %v", err)
}
envOutput := execResult.StdoutContents()
if !strings.Contains(envOutput, "REDIS_URL=redis://") {
t.Error("expected REDIS_URL=redis://... to be present in app container environment")
}

// linking again should be idempotent
result = linkTask.Execute()
if result.Error != nil {
Expand Down
50 changes: 50 additions & 0 deletions tasks/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ func TestRegisteredTasksExist(t *testing.T) {
"dokku_network_property",
"dokku_ports",
"dokku_proxy_toggle",
"dokku_ps_scale",
"dokku_resource_limit",
"dokku_resource_reserve",
"dokku_service_create",
Expand Down Expand Up @@ -753,6 +754,55 @@ func TestGetTasksServiceLinkTaskParsedCorrectly(t *testing.T) {
}
}

func TestGetTasksPsScaleTaskParsedCorrectly(t *testing.T) {
data := []byte(`---
- tasks:
- name: scale processes
dokku_ps_scale:
app: test-app
scale:
web: 2
worker: 1
skip_deploy: true
`)
context := map[string]interface{}{}

tasks, err := GetTasks(data, context)
if err != nil {
t.Fatalf("GetTasks failed: %v", err)
}

task := tasks.Get("scale processes")
if task == nil {
t.Fatal("task 'scale processes' not found")
}

psTask, ok := task.(*PsScaleTask)
if !ok {
pt, ok2 := task.(PsScaleTask)
if !ok2 {
t.Fatalf("task is not a PsScaleTask (type is %T)", task)
}
psTask = &pt
}

if psTask.App != "test-app" {
t.Errorf("App = %q, want %q", psTask.App, "test-app")
}
if len(psTask.Scale) != 2 {
t.Fatalf("expected 2 scale entries, got %d", len(psTask.Scale))
}
if psTask.Scale["web"] != 2 {
t.Errorf("Scale[web] = %d, want %d", psTask.Scale["web"], 2)
}
if psTask.Scale["worker"] != 1 {
t.Errorf("Scale[worker] = %d, want %d", psTask.Scale["worker"], 1)
}
if !psTask.SkipDeploy {
t.Error("SkipDeploy = false, want true (YAML value should be preserved)")
}
}

func TestGetTasksServiceLinkWithTemplateContext(t *testing.T) {
data := []byte(`---
- tasks:
Expand Down
Loading
Loading