From 5786ede821bf10c5c20200f20db93a9e09701681 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 02:00:22 -0400 Subject: [PATCH 1/5] feat: implement ps scale task for managing dokku process scaling Adds PsScaleTask that manages process scaling for dokku applications, compatible with the upstream ansible-dokku dokku_ps_scale module. Supports idempotent scaling with skip_deploy option. Integration tests deploy dokku/smoke-test-app:dockerfile and verify container counts via docker inspect. Service link test enhanced with docker exec verification of REDIS_URL inside running containers. --- docs/dokku_ps_scale.md | 25 +++++ tasks/integration_test.go | 215 ++++++++++++++++++++++++++++++++++++- tasks/main_test.go | 50 +++++++++ tasks/ps_scale_task.go | 196 +++++++++++++++++++++++++++++++++ tasks/task_execute_test.go | 35 +++++- 5 files changed, 519 insertions(+), 2 deletions(-) create mode 100644 docs/dokku_ps_scale.md create mode 100644 tasks/ps_scale_task.go diff --git a/docs/dokku_ps_scale.md b/docs/dokku_ps_scale.md new file mode 100644 index 0000000..8a6b033 --- /dev/null +++ b/docs/dokku_ps_scale.md @@ -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 +``` diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 0e5cf7d..644b3a1 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -699,7 +699,7 @@ func TestIntegrationGitFromImage(t *testing.T) { task := GitFromImageTask{ App: appName, - Image: "nginx:latest", + Image: "dokku/smoke-test-app:dockerfile", State: StateDeployed, } result := task.Execute() @@ -976,6 +976,167 @@ 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 + countResult, 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 list containers: %v", err) + } + initialContainers := strings.Split(strings.TrimSpace(countResult.StdoutContents()), "\n") + if len(initialContainers) != 1 || initialContainers[0] == "" { + t.Fatalf("expected 1 initial web container, got %d", len(initialContainers)) + } + + // 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") + } + + // verify 2 web containers via docker ps + countResult, 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 list containers after scale: %v", err) + } + scaledContainers := strings.Split(strings.TrimSpace(countResult.StdoutContents()), "\n") + if len(scaledContainers) != 2 { + t.Errorf("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") + } + + // verify 1 web container after scale down + countResult, 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 list containers after scale down: %v", err) + } + finalContainers := strings.Split(strings.TrimSpace(countResult.StdoutContents()), "\n") + if len(finalContainers) != 1 || finalContainers[0] == "" { + t.Errorf("expected 1 web container after scale down, got %d", len(finalContainers)) + } +} + +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) @@ -1170,6 +1331,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 { diff --git a/tasks/main_test.go b/tasks/main_test.go index 16886e3..0f75d07 100644 --- a/tasks/main_test.go +++ b/tasks/main_test.go @@ -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", @@ -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: diff --git a/tasks/ps_scale_task.go b/tasks/ps_scale_task.go new file mode 100644 index 0000000..dd98369 --- /dev/null +++ b/tasks/ps_scale_task.go @@ -0,0 +1,196 @@ +package tasks + +import ( + "fmt" + "omakase/subprocess" + "strconv" + "strings" + + yaml "gopkg.in/yaml.v3" +) + +// PsScaleTask manages the process scale for a given dokku application +type PsScaleTask struct { + // App is the name of the app + App string `required:"true" yaml:"app"` + + // Scale is a map of process types to quantities + Scale map[string]int `required:"true" yaml:"scale"` + + // SkipDeploy skips the corresponding deploy + SkipDeploy bool `yaml:"skip_deploy" default:"false"` + + // State is the desired state of the process scale + State State `required:"false" yaml:"state,omitempty" default:"present" options:"present"` +} + +// PsScaleTaskExample contains an example of a PsScaleTask +type PsScaleTaskExample struct { + // Name is the task name holding the PsScaleTask description + Name string `yaml:"-"` + + // PsScaleTask is the PsScaleTask configuration + PsScaleTask PsScaleTask `yaml:"dokku_ps_scale"` +} + +// DesiredState returns the desired state of the process scale +func (t PsScaleTask) DesiredState() State { + return t.State +} + +// Doc returns the docblock for the ps scale task +func (t PsScaleTask) Doc() string { + return "Manages the process scale for a given dokku application" +} + +// Examples returns the examples for the ps scale task +func (t PsScaleTask) Examples() ([]Doc, error) { + examples := []PsScaleTaskExample{ + { + Name: "Scale web and worker processes", + PsScaleTask: PsScaleTask{ + App: "hello-world", + Scale: map[string]int{ + "web": 2, + "worker": 1, + }, + }, + }, + { + Name: "Scale web and worker processes without deploy", + PsScaleTask: PsScaleTask{ + App: "hello-world", + SkipDeploy: true, + Scale: map[string]int{ + "web": 4, + "worker": 4, + }, + }, + }, + } + + var output []Doc + for _, example := range examples { + b, err := yaml.Marshal(example) + if err != nil { + return nil, err + } + + output = append(output, Doc{ + Name: example.Name, + Codeblock: string(b), + }) + } + + return output, nil +} + +// Execute sets the process scale for a given dokku application +func (t PsScaleTask) Execute() TaskOutputState { + if t.State == StatePresent { + if t.Scale == nil || len(t.Scale) == 0 { + return TaskOutputState{ + Error: fmt.Errorf("scale must be specified when state is present"), + } + } + } + + funcMap := map[State]func(PsScaleTask) TaskOutputState{ + "present": setPsScale, + } + + fn, ok := funcMap[t.State] + if !ok { + return TaskOutputState{ + Error: fmt.Errorf("invalid state: %s", t.State), + } + } + return fn(t) +} + +// getPsScale retrieves the current process scale for a given dokku application +func getPsScale(app string) (map[string]int, error) { + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{"--quiet", "ps:scale", app}, + }) + if err != nil { + return nil, err + } + + scale := map[string]int{} + for _, line := range strings.Split(result.StdoutContents(), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + qty, err := strconv.Atoi(fields[1]) + if err != nil { + continue + } + scale[fields[0]] = qty + } + return scale, nil +} + +// setPsScale sets the process scale for a given dokku application +func setPsScale(t PsScaleTask) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: "absent", + } + + existing, err := getPsScale(t.App) + if err != nil { + state.Error = err + state.Message = err.Error() + return state + } + + var proctypesToScale []string + for proctype, qty := range t.Scale { + if existingQty, ok := existing[proctype]; ok && existingQty == qty { + continue + } + proctypesToScale = append(proctypesToScale, fmt.Sprintf("%s=%d", proctype, qty)) + } + + if len(proctypesToScale) == 0 { + state.State = "present" + return state + } + + args := []string{ + "ps:scale", + } + + if t.SkipDeploy { + args = append(args, "--skip-deploy") + } + + args = append(args, t.App) + args = append(args, proctypesToScale...) + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: args, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = "present" + return state +} + +// init registers the PsScaleTask with the task registry +func init() { + RegisterTask(&PsScaleTask{}) +} diff --git a/tasks/task_execute_test.go b/tasks/task_execute_test.go index 6065ca1..243a5cd 100644 --- a/tasks/task_execute_test.go +++ b/tasks/task_execute_test.go @@ -179,6 +179,37 @@ func TestStorageEnsureAbsentStateReturnsError(t *testing.T) { } } +func TestPsScaleTaskInvalidState(t *testing.T) { + task := PsScaleTask{App: "test-app", Scale: map[string]int{"web": 1}, State: "invalid"} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute with invalid state should return an error") + } +} + +func TestPsScaleTaskDesiredState(t *testing.T) { + task := PsScaleTask{App: "test-app", Scale: map[string]int{"web": 1}, State: StatePresent} + if task.DesiredState() != StatePresent { + t.Errorf("expected state 'present', got '%s'", task.DesiredState()) + } +} + +func TestPsScaleTaskEmptyScale(t *testing.T) { + task := PsScaleTask{App: "test-app", Scale: map[string]int{}, State: StatePresent} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute with empty scale and state=present should return an error") + } +} + +func TestPsScaleTaskNilScale(t *testing.T) { + task := PsScaleTask{App: "test-app", State: StatePresent} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute with nil scale and state=present should return an error") + } +} + func TestResourceLimitTaskInvalidState(t *testing.T) { task := ResourceLimitTask{ App: "test-app", @@ -332,6 +363,7 @@ func TestAllTasksDesiredState(t *testing.T) { {"NetworkPropertyTask absent", &NetworkPropertyTask{App: "test", Property: "bind-all-interfaces", State: StateAbsent}, StateAbsent}, {"PortsTask present", &PortsTask{App: "test", State: StatePresent}, StatePresent}, {"PortsTask absent", &PortsTask{App: "test", State: StateAbsent}, StateAbsent}, + {"PsScaleTask present", &PsScaleTask{App: "test", Scale: map[string]int{"web": 1}, State: StatePresent}, StatePresent}, {"ResourceLimitTask present", &ResourceLimitTask{App: "test", Resources: map[string]string{"cpu": "100"}, State: StatePresent}, StatePresent}, {"ResourceLimitTask absent", &ResourceLimitTask{App: "test", State: StateAbsent}, StateAbsent}, {"ResourceReserveTask present", &ResourceReserveTask{App: "test", Resources: map[string]string{"cpu": "100"}, State: StatePresent}, StatePresent}, @@ -531,7 +563,7 @@ func TestAllTasksExamplesReturnNoError(t *testing.T) { } func TestRegisteredTaskCount(t *testing.T) { - expected := 16 + expected := 17 if got := len(RegisteredTasks); got != expected { t.Errorf("expected %d registered tasks, got %d", expected, got) } @@ -551,6 +583,7 @@ func TestTaskDocStrings(t *testing.T) { {&GitSyncTask{}, "Syncs a git repository to a dokku application"}, {&NetworkPropertyTask{}, "Manages the network property for a given dokku application"}, {&PortsTask{}, "Manages the ports for a given dokku application"}, + {&PsScaleTask{}, "Manages the process scale for a given dokku application"}, {&ResourceLimitTask{}, "Manages the resource limits for a given dokku application"}, {&ResourceReserveTask{}, "Manages the resource reservations for a given dokku application"}, {&ServiceCreateTask{}, "Creates or destroys a dokku service"}, From 094f7f1517b5f04a896507f179765dda5cb0d753 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 02:22:35 -0400 Subject: [PATCH 2/5] fix: parse colon-separated ps:scale output and cleanup old containers in tests getPsScale() now strips whitespace and splits on colons, matching the upstream ansible module's parsing of dokku ps:scale output format. Integration tests run dokku cleanup before verifying container counts to remove old containers from previous deploy generations. --- tasks/integration_test.go | 79 +++++++++++++++++++++++++++++---------- tasks/ps_scale_task.go | 13 ++++--- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 644b3a1..39551d1 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -116,6 +116,30 @@ func skipIfDockerLinkUnsupportedT(t *testing.T) { } } +// dokkuCleanup runs dokku cleanup to remove old containers from previous deploys +func dokkuCleanup() { + subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{"cleanup"}, + }) +} + +// getRunningContainers returns the IDs of running containers matching the given app and process type +func getRunningContainers(appName, processType string) ([]string, error) { + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "docker", + Args: []string{"ps", "-q", "--filter", fmt.Sprintf("label=com.dokku.app-name=%s", appName), "--filter", fmt.Sprintf("label=com.dokku.process-type=%s", processType)}, + }) + if err != nil { + return nil, err + } + output := strings.TrimSpace(result.StdoutContents()) + if output == "" { + return nil, nil + } + return strings.Split(output, "\n"), nil +} + func TestIntegrationAppCreateAndDestroy(t *testing.T) { skipIfNoDokkuT(t) @@ -998,18 +1022,27 @@ func TestIntegrationPsScale(t *testing.T) { } // verify initial web container count is 1 via docker ps - countResult, 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}}"}, - }) + dokkuCleanup() + initialContainers, err := getRunningContainers(appName, "web") if err != nil { t.Fatalf("failed to list containers: %v", err) } - initialContainers := strings.Split(strings.TrimSpace(countResult.StdoutContents()), "\n") - if len(initialContainers) != 1 || initialContainers[0] == "" { + 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, @@ -1027,17 +1060,14 @@ func TestIntegrationPsScale(t *testing.T) { t.Error("expected changed=true for scaling up") } - // verify 2 web containers via docker ps - countResult, 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}}"}, - }) + // clean up old containers and verify 2 web containers via docker ps + dokkuCleanup() + scaledContainers, err := getRunningContainers(appName, "web") if err != nil { t.Fatalf("failed to list containers after scale: %v", err) } - scaledContainers := strings.Split(strings.TrimSpace(countResult.StdoutContents()), "\n") if len(scaledContainers) != 2 { - t.Errorf("expected 2 web containers after scaling, got %d", len(scaledContainers)) + t.Fatalf("expected 2 web containers after scaling, got %d", len(scaledContainers)) } // verify each container is running via docker inspect @@ -1077,17 +1107,26 @@ func TestIntegrationPsScale(t *testing.T) { t.Error("expected changed=true for scaling down") } - // verify 1 web container after scale down - countResult, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{ + // clean up old containers and verify 1 web container after scale down + dokkuCleanup() + finalContainers, err := getRunningContainers(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{"ps", "--filter", fmt.Sprintf("label=com.dokku.app-name=%s", appName), "--filter", "label=com.dokku.process-type=web", "--format", "{{.ID}}"}, + Args: []string{"inspect", "--format", "{{.State.Running}}", finalContainers[0]}, }) if err != nil { - t.Fatalf("failed to list containers after scale down: %v", err) + t.Fatalf("failed to inspect final container: %v", err) } - finalContainers := strings.Split(strings.TrimSpace(countResult.StdoutContents()), "\n") - if len(finalContainers) != 1 || finalContainers[0] == "" { - t.Errorf("expected 1 web container after scale down, got %d", len(finalContainers)) + if strings.TrimSpace(inspectResult.StdoutContents()) != "true" { + t.Errorf("expected final container to be running") } } diff --git a/tasks/ps_scale_task.go b/tasks/ps_scale_task.go index dd98369..fea4fc5 100644 --- a/tasks/ps_scale_task.go +++ b/tasks/ps_scale_task.go @@ -120,19 +120,20 @@ func getPsScale(app string) (map[string]int, error) { scale := map[string]int{} for _, line := range strings.Split(result.StdoutContents(), "\n") { - line = strings.TrimSpace(line) - if line == "" { + // strip all whitespace from the line, matching the upstream ansible module + line = strings.Join(strings.Fields(line), "") + if !strings.Contains(line, ":") { continue } - fields := strings.Fields(line) - if len(fields) != 2 { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { continue } - qty, err := strconv.Atoi(fields[1]) + qty, err := strconv.Atoi(parts[1]) if err != nil { continue } - scale[fields[0]] = qty + scale[parts[0]] = qty } return scale, nil } From 8705f636df26bdff66213bf8ebf254cfb97e4588 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 02:47:48 -0400 Subject: [PATCH 3/5] fix: filter retired containers by name pattern in integration tests Dokku renames old containers with a timestamp suffix (app.web.1.TIMESTAMP) and interim containers use an .upcoming- suffix during deploys. These renamed containers may still be running during the wait-to-retire period. Replace dokkuCleanup/getRunningContainers with getActiveContainers that filters docker ps results by container name, only including containers with exactly 3 dot-separated segments (app.process.N) which are the active deployment containers. --- tasks/integration_test.go | 45 +++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 39551d1..5dd22a6 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -116,19 +116,14 @@ func skipIfDockerLinkUnsupportedT(t *testing.T) { } } -// dokkuCleanup runs dokku cleanup to remove old containers from previous deploys -func dokkuCleanup() { - subprocess.CallExecCommand(subprocess.ExecCommandInput{ - Command: "dokku", - Args: []string{"cleanup"}, - }) -} - -// getRunningContainers returns the IDs of running containers matching the given app and process type -func getRunningContainers(appName, processType string) ([]string, error) { +// getActiveContainers returns the IDs of running containers matching the given +// app and process type, excluding retired containers (those renamed with a +// timestamp suffix like "app.web.1.1640787924") and interim containers (those +// with an ".upcoming-" suffix created during deployment). +func getActiveContainers(appName, processType string) ([]string, error) { result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ Command: "docker", - Args: []string{"ps", "-q", "--filter", fmt.Sprintf("label=com.dokku.app-name=%s", appName), "--filter", fmt.Sprintf("label=com.dokku.process-type=%s", processType)}, + Args: []string{"ps", "--filter", fmt.Sprintf("label=com.dokku.app-name=%s", appName), "--filter", fmt.Sprintf("label=com.dokku.process-type=%s", processType), "--format", "{{.ID}}\t{{.Names}}"}, }) if err != nil { return nil, err @@ -137,7 +132,24 @@ func getRunningContainers(appName, processType string) ([]string, error) { if output == "" { return nil, nil } - return strings.Split(output, "\n"), nil + var ids []string + for _, line := range strings.Split(output, "\n") { + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + continue + } + containerID := parts[0] + containerName := parts[1] + // skip retired containers (app.process.N.TIMESTAMP) and + // interim containers (app.process.N.upcoming-RANDOM) + // active containers have exactly 3 dot-separated segments: app.process.N + segments := strings.Split(containerName, ".") + if len(segments) != 3 { + continue + } + ids = append(ids, containerID) + } + return ids, nil } func TestIntegrationAppCreateAndDestroy(t *testing.T) { @@ -1022,8 +1034,7 @@ func TestIntegrationPsScale(t *testing.T) { } // verify initial web container count is 1 via docker ps - dokkuCleanup() - initialContainers, err := getRunningContainers(appName, "web") + initialContainers, err := getActiveContainers(appName, "web") if err != nil { t.Fatalf("failed to list containers: %v", err) } @@ -1061,8 +1072,7 @@ func TestIntegrationPsScale(t *testing.T) { } // clean up old containers and verify 2 web containers via docker ps - dokkuCleanup() - scaledContainers, err := getRunningContainers(appName, "web") + scaledContainers, err := getActiveContainers(appName, "web") if err != nil { t.Fatalf("failed to list containers after scale: %v", err) } @@ -1108,8 +1118,7 @@ func TestIntegrationPsScale(t *testing.T) { } // clean up old containers and verify 1 web container after scale down - dokkuCleanup() - finalContainers, err := getRunningContainers(appName, "web") + finalContainers, err := getActiveContainers(appName, "web") if err != nil { t.Fatalf("failed to list containers after scale down: %v", err) } From 1b216d438f59dc019f42950c05277045f3895e7a Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 03:20:35 -0400 Subject: [PATCH 4/5] fix: call dokku ps:retire and cleanup before verifying container counts Old containers from previous deploys may still be running during the wait-to-retire period (default 60s). Force-retire and cleanup before each container count check to ensure accurate results. --- tasks/integration_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 5dd22a6..174c1ae 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -116,6 +116,19 @@ func skipIfDockerLinkUnsupportedT(t *testing.T) { } } +// retireAndCleanupContainers forces dokku to retire old containers from +// previous deploys and then cleans up stopped containers +func retireAndCleanupContainers() { + subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{"ps:retire"}, + }) + subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{"cleanup"}, + }) +} + // getActiveContainers returns the IDs of running containers matching the given // app and process type, excluding retired containers (those renamed with a // timestamp suffix like "app.web.1.1640787924") and interim containers (those @@ -1034,6 +1047,7 @@ func TestIntegrationPsScale(t *testing.T) { } // verify initial web container count is 1 via docker ps + retireAndCleanupContainers() initialContainers, err := getActiveContainers(appName, "web") if err != nil { t.Fatalf("failed to list containers: %v", err) @@ -1072,6 +1086,7 @@ func TestIntegrationPsScale(t *testing.T) { } // clean up old containers and verify 2 web containers via docker ps + retireAndCleanupContainers() scaledContainers, err := getActiveContainers(appName, "web") if err != nil { t.Fatalf("failed to list containers after scale: %v", err) @@ -1118,6 +1133,7 @@ func TestIntegrationPsScale(t *testing.T) { } // clean up old containers and verify 1 web container after scale down + retireAndCleanupContainers() finalContainers, err := getActiveContainers(appName, "web") if err != nil { t.Fatalf("failed to list containers after scale down: %v", err) From 65f0e6b87695eda2abc627cc7a95a233b7bf64e7 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 03:37:49 -0400 Subject: [PATCH 5/5] fix: use dokku CONTAINER files for authoritative container ID lookup Read container IDs from dokku's internal CONTAINER.process.N files instead of filtering docker ps output. These files are the authoritative source for current deployment containers and are properly updated when scaling down (stale files are removed during deploy). This avoids issues with orphaned containers from previous deployments that may still be running but are no longer part of the current formation. --- tasks/integration_test.go | 61 ++++++++++++--------------------------- 1 file changed, 19 insertions(+), 42 deletions(-) diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 174c1ae..ec2aa27 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -116,51 +116,31 @@ func skipIfDockerLinkUnsupportedT(t *testing.T) { } } -// retireAndCleanupContainers forces dokku to retire old containers from -// previous deploys and then cleans up stopped containers -func retireAndCleanupContainers() { - subprocess.CallExecCommand(subprocess.ExecCommandInput{ - Command: "dokku", - Args: []string{"ps:retire"}, - }) - subprocess.CallExecCommand(subprocess.ExecCommandInput{ - Command: "dokku", - Args: []string{"cleanup"}, - }) -} - -// getActiveContainers returns the IDs of running containers matching the given -// app and process type, excluding retired containers (those renamed with a -// timestamp suffix like "app.web.1.1640787924") and interim containers (those -// with an ".upcoming-" suffix created during deployment). -func getActiveContainers(appName, processType string) ([]string, error) { - result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ - Command: "docker", - Args: []string{"ps", "--filter", fmt.Sprintf("label=com.dokku.app-name=%s", appName), "--filter", fmt.Sprintf("label=com.dokku.process-type=%s", processType), "--format", "{{.ID}}\t{{.Names}}"}, - }) +// 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 } - output := strings.TrimSpace(result.StdoutContents()) - if output == "" { + count, ok := scale[processType] + if !ok || count == 0 { return nil, nil } var ids []string - for _, line := range strings.Split(output, "\n") { - parts := strings.SplitN(line, "\t", 2) - if len(parts) != 2 { + 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 } - containerID := parts[0] - containerName := parts[1] - // skip retired containers (app.process.N.TIMESTAMP) and - // interim containers (app.process.N.upcoming-RANDOM) - // active containers have exactly 3 dot-separated segments: app.process.N - segments := strings.Split(containerName, ".") - if len(segments) != 3 { - continue + id := strings.TrimSpace(result.StdoutContents()) + if id != "" { + ids = append(ids, id) } - ids = append(ids, containerID) } return ids, nil } @@ -1047,8 +1027,7 @@ func TestIntegrationPsScale(t *testing.T) { } // verify initial web container count is 1 via docker ps - retireAndCleanupContainers() - initialContainers, err := getActiveContainers(appName, "web") + initialContainers, err := getCurrentContainerIDs(appName, "web") if err != nil { t.Fatalf("failed to list containers: %v", err) } @@ -1086,8 +1065,7 @@ func TestIntegrationPsScale(t *testing.T) { } // clean up old containers and verify 2 web containers via docker ps - retireAndCleanupContainers() - scaledContainers, err := getActiveContainers(appName, "web") + scaledContainers, err := getCurrentContainerIDs(appName, "web") if err != nil { t.Fatalf("failed to list containers after scale: %v", err) } @@ -1133,8 +1111,7 @@ func TestIntegrationPsScale(t *testing.T) { } // clean up old containers and verify 1 web container after scale down - retireAndCleanupContainers() - finalContainers, err := getActiveContainers(appName, "web") + finalContainers, err := getCurrentContainerIDs(appName, "web") if err != nil { t.Fatalf("failed to list containers after scale down: %v", err) }