Skip to content

Commit 13d806d

Browse files
authored
Merge pull request #121 from dokku/11-implement-ps-scale-task
feat: implement ps scale task
2 parents b00ffb6 + 65f0e6b commit 13d806d

5 files changed

Lines changed: 561 additions & 2 deletions

File tree

docs/dokku_ps_scale.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# dokku_ps_scale
2+
3+
Manages the process scale for a given dokku application
4+
5+
## Scale web and worker processes
6+
7+
```yaml
8+
dokku_ps_scale:
9+
app: hello-world
10+
scale:
11+
web: 2
12+
worker: 1
13+
skip_deploy: false
14+
```
15+
16+
## Scale web and worker processes without deploy
17+
18+
```yaml
19+
dokku_ps_scale:
20+
app: hello-world
21+
scale:
22+
web: 4
23+
worker: 4
24+
skip_deploy: true
25+
```

tasks/integration_test.go

Lines changed: 255 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,35 @@ func skipIfDockerLinkUnsupportedT(t *testing.T) {
116116
}
117117
}
118118

119+
// getCurrentContainerIDs reads the container IDs from dokku's internal
120+
// CONTAINER files (e.g., /home/dokku/APP/CONTAINER.web.1) which are the
121+
// authoritative source for the current deployment's containers.
122+
func getCurrentContainerIDs(appName, processType string) ([]string, error) {
123+
scale, err := getPsScale(appName)
124+
if err != nil {
125+
return nil, err
126+
}
127+
count, ok := scale[processType]
128+
if !ok || count == 0 {
129+
return nil, nil
130+
}
131+
var ids []string
132+
for i := 1; i <= count; i++ {
133+
result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
134+
Command: "cat",
135+
Args: []string{fmt.Sprintf("/home/dokku/%s/CONTAINER.%s.%d", appName, processType, i)},
136+
})
137+
if err != nil {
138+
continue
139+
}
140+
id := strings.TrimSpace(result.StdoutContents())
141+
if id != "" {
142+
ids = append(ids, id)
143+
}
144+
}
145+
return ids, nil
146+
}
147+
119148
func TestIntegrationAppCreateAndDestroy(t *testing.T) {
120149
skipIfNoDokkuT(t)
121150

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

700729
task := GitFromImageTask{
701730
App: appName,
702-
Image: "nginx:latest",
731+
Image: "dokku/smoke-test-app:dockerfile",
703732
State: StateDeployed,
704733
}
705734
result := task.Execute()
@@ -976,6 +1005,179 @@ func TestIntegrationResourceReserveProcessType(t *testing.T) {
9761005
}
9771006
}
9781007

1008+
func TestIntegrationPsScale(t *testing.T) {
1009+
skipIfNoDokkuT(t)
1010+
1011+
appName := "omakase-test-psscale"
1012+
1013+
// ensure clean state
1014+
destroyApp(appName)
1015+
createApp(appName)
1016+
defer destroyApp(appName)
1017+
1018+
// deploy the smoke test app so we have running containers to scale
1019+
deployTask := GitFromImageTask{
1020+
App: appName,
1021+
Image: "dokku/smoke-test-app:dockerfile",
1022+
State: StateDeployed,
1023+
}
1024+
deployResult := deployTask.Execute()
1025+
if deployResult.Error != nil {
1026+
t.Fatalf("failed to deploy app: %v", deployResult.Error)
1027+
}
1028+
1029+
// verify initial web container count is 1 via docker ps
1030+
initialContainers, err := getCurrentContainerIDs(appName, "web")
1031+
if err != nil {
1032+
t.Fatalf("failed to list containers: %v", err)
1033+
}
1034+
if len(initialContainers) != 1 {
1035+
t.Fatalf("expected 1 initial web container, got %d", len(initialContainers))
1036+
}
1037+
1038+
// verify the initial container is running via docker inspect
1039+
inspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
1040+
Command: "docker",
1041+
Args: []string{"inspect", "--format", "{{.State.Running}}", initialContainers[0]},
1042+
})
1043+
if err != nil {
1044+
t.Fatalf("failed to inspect initial container: %v", err)
1045+
}
1046+
if strings.TrimSpace(inspectResult.StdoutContents()) != "true" {
1047+
t.Errorf("expected initial container to be running")
1048+
}
1049+
1050+
// scale web to 2
1051+
scaleTask := PsScaleTask{
1052+
App: appName,
1053+
Scale: map[string]int{"web": 2},
1054+
State: StatePresent,
1055+
}
1056+
result := scaleTask.Execute()
1057+
if result.Error != nil {
1058+
t.Fatalf("failed to scale app: %v", result.Error)
1059+
}
1060+
if result.State != StatePresent {
1061+
t.Errorf("expected state 'present', got '%s'", result.State)
1062+
}
1063+
if !result.Changed {
1064+
t.Error("expected changed=true for scaling up")
1065+
}
1066+
1067+
// clean up old containers and verify 2 web containers via docker ps
1068+
scaledContainers, err := getCurrentContainerIDs(appName, "web")
1069+
if err != nil {
1070+
t.Fatalf("failed to list containers after scale: %v", err)
1071+
}
1072+
if len(scaledContainers) != 2 {
1073+
t.Fatalf("expected 2 web containers after scaling, got %d", len(scaledContainers))
1074+
}
1075+
1076+
// verify each container is running via docker inspect
1077+
for _, containerID := range scaledContainers {
1078+
inspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
1079+
Command: "docker",
1080+
Args: []string{"inspect", "--format", "{{.State.Running}}", containerID},
1081+
})
1082+
if err != nil {
1083+
t.Fatalf("failed to inspect container %s: %v", containerID, err)
1084+
}
1085+
if strings.TrimSpace(inspectResult.StdoutContents()) != "true" {
1086+
t.Errorf("expected container %s to be running", containerID)
1087+
}
1088+
}
1089+
1090+
// scaling again should be idempotent
1091+
result = scaleTask.Execute()
1092+
if result.Error != nil {
1093+
t.Fatalf("idempotent scale failed: %v", result.Error)
1094+
}
1095+
if result.Changed {
1096+
t.Error("expected changed=false for unchanged scale")
1097+
}
1098+
1099+
// scale back to 1
1100+
scaleDownTask := PsScaleTask{
1101+
App: appName,
1102+
Scale: map[string]int{"web": 1},
1103+
State: StatePresent,
1104+
}
1105+
result = scaleDownTask.Execute()
1106+
if result.Error != nil {
1107+
t.Fatalf("failed to scale down: %v", result.Error)
1108+
}
1109+
if !result.Changed {
1110+
t.Error("expected changed=true for scaling down")
1111+
}
1112+
1113+
// clean up old containers and verify 1 web container after scale down
1114+
finalContainers, err := getCurrentContainerIDs(appName, "web")
1115+
if err != nil {
1116+
t.Fatalf("failed to list containers after scale down: %v", err)
1117+
}
1118+
if len(finalContainers) != 1 {
1119+
t.Fatalf("expected 1 web container after scale down, got %d", len(finalContainers))
1120+
}
1121+
1122+
// verify the final container is running via docker inspect
1123+
inspectResult, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{
1124+
Command: "docker",
1125+
Args: []string{"inspect", "--format", "{{.State.Running}}", finalContainers[0]},
1126+
})
1127+
if err != nil {
1128+
t.Fatalf("failed to inspect final container: %v", err)
1129+
}
1130+
if strings.TrimSpace(inspectResult.StdoutContents()) != "true" {
1131+
t.Errorf("expected final container to be running")
1132+
}
1133+
}
1134+
1135+
func TestIntegrationPsScaleSkipDeploy(t *testing.T) {
1136+
skipIfNoDokkuT(t)
1137+
1138+
appName := "omakase-test-psscale-sd"
1139+
1140+
destroyApp(appName)
1141+
createApp(appName)
1142+
defer destroyApp(appName)
1143+
1144+
// scale with skip_deploy on an undeployed app
1145+
scaleTask := PsScaleTask{
1146+
App: appName,
1147+
Scale: map[string]int{"web": 2, "worker": 1},
1148+
SkipDeploy: true,
1149+
State: StatePresent,
1150+
}
1151+
result := scaleTask.Execute()
1152+
if result.Error != nil {
1153+
t.Fatalf("failed to scale with skip_deploy: %v", result.Error)
1154+
}
1155+
if !result.Changed {
1156+
t.Error("expected changed=true for initial scale")
1157+
}
1158+
1159+
// verify the scale was set correctly
1160+
scale, err := getPsScale(appName)
1161+
if err != nil {
1162+
t.Fatalf("failed to get ps scale: %v", err)
1163+
}
1164+
if scale["web"] != 2 {
1165+
t.Errorf("expected web=2, got web=%d", scale["web"])
1166+
}
1167+
if scale["worker"] != 1 {
1168+
t.Errorf("expected worker=1, got worker=%d", scale["worker"])
1169+
}
1170+
1171+
// idempotent
1172+
result = scaleTask.Execute()
1173+
if result.Error != nil {
1174+
t.Fatalf("idempotent scale failed: %v", result.Error)
1175+
}
1176+
if result.Changed {
1177+
t.Error("expected changed=false for unchanged scale")
1178+
}
1179+
}
1180+
9791181
func TestIntegrationMultiTaskWorkflow(t *testing.T) {
9801182
skipIfNoDokkuT(t)
9811183

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

1375+
// deploy the smoke test app so we can verify the link inside a running container
1376+
deployTask := GitFromImageTask{
1377+
App: appName,
1378+
Image: "dokku/smoke-test-app:dockerfile",
1379+
State: StateDeployed,
1380+
}
1381+
deployResult := deployTask.Execute()
1382+
if deployResult.Error != nil {
1383+
t.Fatalf("failed to deploy app: %v", deployResult.Error)
1384+
}
1385+
1386+
// find the running app container
1387+
appContainerResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
1388+
Command: "docker",
1389+
Args: []string{"ps", "--filter", fmt.Sprintf("label=com.dokku.app-name=%s", appName), "--filter", "label=com.dokku.process-type=web", "--format", "{{.ID}}"},
1390+
})
1391+
if err != nil {
1392+
t.Fatalf("failed to find app container: %v", err)
1393+
}
1394+
appContainerID := strings.TrimSpace(appContainerResult.StdoutContents())
1395+
if appContainerID == "" {
1396+
t.Fatal("expected at least one running app container after deploy")
1397+
}
1398+
// take the first container if multiple lines
1399+
appContainerIDs := strings.Split(appContainerID, "\n")
1400+
appContainerID = appContainerIDs[0]
1401+
1402+
// verify the app container is running via docker inspect
1403+
appInspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
1404+
Command: "docker",
1405+
Args: []string{"inspect", "--format", "{{.State.Running}}", appContainerID},
1406+
})
1407+
if err != nil {
1408+
t.Fatalf("failed to inspect app container: %v", err)
1409+
}
1410+
if strings.TrimSpace(appInspectResult.StdoutContents()) != "true" {
1411+
t.Error("expected app container to be running")
1412+
}
1413+
1414+
// verify REDIS_URL is present inside the running container via docker exec
1415+
execResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
1416+
Command: "docker",
1417+
Args: []string{"exec", appContainerID, "env"},
1418+
})
1419+
if err != nil {
1420+
t.Fatalf("failed to exec env in app container: %v", err)
1421+
}
1422+
envOutput := execResult.StdoutContents()
1423+
if !strings.Contains(envOutput, "REDIS_URL=redis://") {
1424+
t.Error("expected REDIS_URL=redis://... to be present in app container environment")
1425+
}
1426+
11731427
// linking again should be idempotent
11741428
result = linkTask.Execute()
11751429
if result.Error != nil {

tasks/main_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ func TestRegisteredTasksExist(t *testing.T) {
163163
"dokku_network_property",
164164
"dokku_ports",
165165
"dokku_proxy_toggle",
166+
"dokku_ps_scale",
166167
"dokku_resource_limit",
167168
"dokku_resource_reserve",
168169
"dokku_service_create",
@@ -753,6 +754,55 @@ func TestGetTasksServiceLinkTaskParsedCorrectly(t *testing.T) {
753754
}
754755
}
755756

757+
func TestGetTasksPsScaleTaskParsedCorrectly(t *testing.T) {
758+
data := []byte(`---
759+
- tasks:
760+
- name: scale processes
761+
dokku_ps_scale:
762+
app: test-app
763+
scale:
764+
web: 2
765+
worker: 1
766+
skip_deploy: true
767+
`)
768+
context := map[string]interface{}{}
769+
770+
tasks, err := GetTasks(data, context)
771+
if err != nil {
772+
t.Fatalf("GetTasks failed: %v", err)
773+
}
774+
775+
task := tasks.Get("scale processes")
776+
if task == nil {
777+
t.Fatal("task 'scale processes' not found")
778+
}
779+
780+
psTask, ok := task.(*PsScaleTask)
781+
if !ok {
782+
pt, ok2 := task.(PsScaleTask)
783+
if !ok2 {
784+
t.Fatalf("task is not a PsScaleTask (type is %T)", task)
785+
}
786+
psTask = &pt
787+
}
788+
789+
if psTask.App != "test-app" {
790+
t.Errorf("App = %q, want %q", psTask.App, "test-app")
791+
}
792+
if len(psTask.Scale) != 2 {
793+
t.Fatalf("expected 2 scale entries, got %d", len(psTask.Scale))
794+
}
795+
if psTask.Scale["web"] != 2 {
796+
t.Errorf("Scale[web] = %d, want %d", psTask.Scale["web"], 2)
797+
}
798+
if psTask.Scale["worker"] != 1 {
799+
t.Errorf("Scale[worker] = %d, want %d", psTask.Scale["worker"], 1)
800+
}
801+
if !psTask.SkipDeploy {
802+
t.Error("SkipDeploy = false, want true (YAML value should be preserved)")
803+
}
804+
}
805+
756806
func TestGetTasksServiceLinkWithTemplateContext(t *testing.T) {
757807
data := []byte(`---
758808
- tasks:

0 commit comments

Comments
 (0)